@securecoms/agent-runtime 0.7.0 → 0.9.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/README.md +74 -7
- package/dist/bin.js +1454 -275
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -1,17 +1,375 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync9 } from "node:fs";
|
|
5
5
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
6
|
-
import { dirname as
|
|
6
|
+
import { dirname as dirname6, join as join8 } from "node:path";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
|
-
import { accessSync, constants, existsSync as existsSync6, mkdirSync, realpathSync, rmSync, statSync as statSync2, writeFileSync as
|
|
8
|
+
import { accessSync, constants, existsSync as existsSync6, mkdirSync as mkdirSync3, realpathSync, rmSync, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
9
9
|
import { homedir as homedir4, userInfo } from "node:os";
|
|
10
10
|
|
|
11
11
|
// src/config.ts
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
|
-
import { dirname, join } from "node:path";
|
|
13
|
+
import { dirname as dirname2, join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
// src/service-log.ts
|
|
17
|
+
var TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
18
|
+
var CLASS = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
|
|
19
|
+
function errorClass(error) {
|
|
20
|
+
if (error === null) return "NullThrown";
|
|
21
|
+
if (typeof error !== "object" && typeof error !== "function") {
|
|
22
|
+
const primitive = {
|
|
23
|
+
string: "StringThrown",
|
|
24
|
+
number: "NumberThrown",
|
|
25
|
+
boolean: "BooleanThrown",
|
|
26
|
+
bigint: "BigIntThrown",
|
|
27
|
+
symbol: "SymbolThrown",
|
|
28
|
+
undefined: "UndefinedThrown"
|
|
29
|
+
};
|
|
30
|
+
return primitive[typeof error] ?? "UnknownThrown";
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const name = Object.getPrototypeOf(error)?.constructor?.name;
|
|
34
|
+
return typeof name === "string" && CLASS.test(name) ? name : "Error";
|
|
35
|
+
} catch {
|
|
36
|
+
return "Error";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function formatServiceLog(level, event, fields = {}, error) {
|
|
40
|
+
const safeEvent = TOKEN.test(event) ? event : "invalid_event";
|
|
41
|
+
const parts = [`[runtime] ${level} ${safeEvent}`];
|
|
42
|
+
for (const [key, value] of Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))) {
|
|
43
|
+
if (!TOKEN.test(key)) continue;
|
|
44
|
+
if (typeof value === "boolean") parts.push(`${key}=${value}`);
|
|
45
|
+
else if (typeof value === "number" && Number.isFinite(value)) parts.push(`${key}=${value}`);
|
|
46
|
+
}
|
|
47
|
+
if (error !== void 0) parts.push(`error_class=${errorClass(error)}`);
|
|
48
|
+
return parts.join(" ");
|
|
49
|
+
}
|
|
50
|
+
function failureEpisodeReporter(warn) {
|
|
51
|
+
let lastClass = null;
|
|
52
|
+
return {
|
|
53
|
+
failed(error) {
|
|
54
|
+
const cls = errorClass(error);
|
|
55
|
+
if (cls === lastClass) return;
|
|
56
|
+
lastClass = cls;
|
|
57
|
+
try {
|
|
58
|
+
warn(error);
|
|
59
|
+
} catch {
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
succeeded() {
|
|
63
|
+
lastClass = null;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function writeServiceLog(level, event, fields, error) {
|
|
68
|
+
const line = formatServiceLog(level, event, fields, error);
|
|
69
|
+
if (level === "error") console.error(line);
|
|
70
|
+
else if (level === "warn") console.warn(line);
|
|
71
|
+
else console.log(line);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/llm-dispatch.ts
|
|
75
|
+
var LLM_REQUEST_MAX_BYTES = 128 * 1024;
|
|
76
|
+
var LLM_RESPONSE_MAX_BYTES = 128 * 1024;
|
|
77
|
+
var LLM_REQUEST_TIMEOUT_MS = 18e4;
|
|
78
|
+
var DEFAULT_MAX_OUTPUT_TOKENS = 8192;
|
|
79
|
+
var CAP_REJECTED_MAX = 256;
|
|
80
|
+
var capRejectedModels = /* @__PURE__ */ new Set();
|
|
81
|
+
var CAP_ERROR_SCAN_BYTES = 16 * 1024;
|
|
82
|
+
var CAP_FIELD_NAMED = /max_(?:completion_|output_)?tokens/i;
|
|
83
|
+
var CONTEXT_OVERFLOW_NAMED = /prompt|context|input[\s_-]*(?:length|tokens)/i;
|
|
84
|
+
async function errorNamesOutputCap(resp) {
|
|
85
|
+
const reader = resp.body?.getReader();
|
|
86
|
+
if (!reader) return false;
|
|
87
|
+
const chunks = [];
|
|
88
|
+
let total = 0;
|
|
89
|
+
try {
|
|
90
|
+
while (total < CAP_ERROR_SCAN_BYTES) {
|
|
91
|
+
const { done, value } = await reader.read();
|
|
92
|
+
if (done) break;
|
|
93
|
+
if (!value) continue;
|
|
94
|
+
chunks.push(value);
|
|
95
|
+
total += value.byteLength;
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
} finally {
|
|
100
|
+
await reader.cancel().catch(() => {
|
|
101
|
+
});
|
|
102
|
+
reader.releaseLock();
|
|
103
|
+
}
|
|
104
|
+
const text = new TextDecoder().decode(Buffer.concat(chunks).subarray(0, CAP_ERROR_SCAN_BYTES));
|
|
105
|
+
return CAP_FIELD_NAMED.test(text) && !CONTEXT_OVERFLOW_NAMED.test(text);
|
|
106
|
+
}
|
|
107
|
+
function outputCapField(endpoint) {
|
|
108
|
+
try {
|
|
109
|
+
return new URL(endpoint).hostname === "api.openai.com" ? "max_completion_tokens" : "max_tokens";
|
|
110
|
+
} catch {
|
|
111
|
+
return "max_tokens";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function normalizeEndpoint(endpoint) {
|
|
115
|
+
return endpoint.replace(/\/+$/, "").replace(/\/v1$/, "").replace(/\/+$/, "");
|
|
116
|
+
}
|
|
117
|
+
var LLMDispatcher = class {
|
|
118
|
+
endpoint;
|
|
119
|
+
model;
|
|
120
|
+
apiKey;
|
|
121
|
+
chatPath;
|
|
122
|
+
chatAuth;
|
|
123
|
+
authHeaderName;
|
|
124
|
+
fetch;
|
|
125
|
+
timeoutMs;
|
|
126
|
+
requestMaxBytes;
|
|
127
|
+
responseMaxBytes;
|
|
128
|
+
maxOutputTokens;
|
|
129
|
+
log;
|
|
130
|
+
constructor(endpoint, model, opts = {}) {
|
|
131
|
+
this.endpoint = normalizeEndpoint(endpoint);
|
|
132
|
+
this.model = model;
|
|
133
|
+
this.apiKey = opts.apiKey;
|
|
134
|
+
this.chatPath = opts.chatPath ?? "/v1/chat/completions";
|
|
135
|
+
this.chatAuth = opts.chatAuth ?? "bearer";
|
|
136
|
+
this.authHeaderName = opts.authHeaderName ?? "x-api-key";
|
|
137
|
+
this.fetch = opts.fetch ?? fetch;
|
|
138
|
+
this.timeoutMs = opts.timeoutMs ?? LLM_REQUEST_TIMEOUT_MS;
|
|
139
|
+
this.requestMaxBytes = opts.requestMaxBytes ?? LLM_REQUEST_MAX_BYTES;
|
|
140
|
+
this.responseMaxBytes = opts.responseMaxBytes ?? LLM_RESPONSE_MAX_BYTES;
|
|
141
|
+
this.maxOutputTokens = opts.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
|
142
|
+
this.log = opts.log ?? writeServiceLog;
|
|
143
|
+
}
|
|
144
|
+
async complete(messages, tools, options = {}) {
|
|
145
|
+
const body = {
|
|
146
|
+
model: this.model,
|
|
147
|
+
messages,
|
|
148
|
+
stream: false
|
|
149
|
+
};
|
|
150
|
+
const capKey = `${this.endpoint}\0${this.model}`;
|
|
151
|
+
const capField = outputCapField(this.endpoint);
|
|
152
|
+
if (tools && tools.length > 0) {
|
|
153
|
+
body.tools = tools;
|
|
154
|
+
}
|
|
155
|
+
const url = `${this.endpoint}${this.chatPath}`;
|
|
156
|
+
const headers = { "Content-Type": "application/json" };
|
|
157
|
+
if (this.apiKey && this.chatAuth === "bearer") {
|
|
158
|
+
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
159
|
+
} else if (this.apiKey && this.chatAuth === "x-api-key") {
|
|
160
|
+
headers[this.authHeaderName] = this.apiKey;
|
|
161
|
+
}
|
|
162
|
+
const encode = (withCap2) => {
|
|
163
|
+
const encoded = JSON.stringify(withCap2 ? { ...body, [capField]: this.maxOutputTokens } : body);
|
|
164
|
+
if (Buffer.byteLength(encoded, "utf8") > this.requestMaxBytes) {
|
|
165
|
+
throw new Error("LLM request exceeded the size limit");
|
|
166
|
+
}
|
|
167
|
+
return encoded;
|
|
168
|
+
};
|
|
169
|
+
let withCap = !capRejectedModels.has(capKey);
|
|
170
|
+
let encodedBody = encode(withCap);
|
|
171
|
+
const controller = new AbortController();
|
|
172
|
+
let timedOut = false;
|
|
173
|
+
const cancel = () => controller.abort(options.signal?.reason);
|
|
174
|
+
if (options.signal?.aborted) cancel();
|
|
175
|
+
else options.signal?.addEventListener("abort", cancel, { once: true });
|
|
176
|
+
const timer = setTimeout(() => {
|
|
177
|
+
timedOut = true;
|
|
178
|
+
controller.abort(new Error("LLM request deadline exceeded"));
|
|
179
|
+
}, this.timeoutMs);
|
|
180
|
+
try {
|
|
181
|
+
const post = () => this.fetch(url, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers,
|
|
184
|
+
body: encodedBody,
|
|
185
|
+
signal: controller.signal,
|
|
186
|
+
redirect: "error"
|
|
187
|
+
});
|
|
188
|
+
let resp = await post();
|
|
189
|
+
if (resp.status === 400 && withCap && await errorNamesOutputCap(resp)) {
|
|
190
|
+
if (capRejectedModels.size >= CAP_REJECTED_MAX) {
|
|
191
|
+
const oldest = capRejectedModels.values().next();
|
|
192
|
+
if (!oldest.done) capRejectedModels.delete(oldest.value);
|
|
193
|
+
}
|
|
194
|
+
capRejectedModels.add(capKey);
|
|
195
|
+
this.log("warn", "llm.output_cap_rejected", {});
|
|
196
|
+
withCap = false;
|
|
197
|
+
encodedBody = encode(false);
|
|
198
|
+
resp = await post();
|
|
199
|
+
}
|
|
200
|
+
if (!resp.ok) {
|
|
201
|
+
await resp.body?.cancel();
|
|
202
|
+
throw new Error(`LLM request failed (${resp.status})`);
|
|
203
|
+
}
|
|
204
|
+
const data = await readJsonCapped(resp, this.responseMaxBytes);
|
|
205
|
+
const choice = data.choices?.[0];
|
|
206
|
+
if (!choice) throw new Error("LLM returned no choices");
|
|
207
|
+
const totalTokens = data.usage?.total_tokens;
|
|
208
|
+
return {
|
|
209
|
+
content: choice.message.content,
|
|
210
|
+
toolCalls: choice.message.tool_calls || [],
|
|
211
|
+
finishReason: choice.finish_reason,
|
|
212
|
+
...typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens >= 0 ? { usage: { totalTokens } } : {}
|
|
213
|
+
};
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (timedOut) throw new Error(`LLM request timed out after ${this.timeoutMs}ms`);
|
|
216
|
+
if (options.signal?.aborted) throw new Error("LLM request cancelled");
|
|
217
|
+
throw error;
|
|
218
|
+
} finally {
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
async function readJsonCapped(resp, maxBytes) {
|
|
225
|
+
const reader = resp.body?.getReader();
|
|
226
|
+
if (!reader) throw new Error("LLM returned an empty response");
|
|
227
|
+
const chunks = [];
|
|
228
|
+
let total = 0;
|
|
229
|
+
try {
|
|
230
|
+
while (true) {
|
|
231
|
+
const { done, value } = await reader.read();
|
|
232
|
+
if (done) break;
|
|
233
|
+
if (!value) continue;
|
|
234
|
+
total += value.byteLength;
|
|
235
|
+
if (total > maxBytes) {
|
|
236
|
+
await reader.cancel().catch(() => {
|
|
237
|
+
});
|
|
238
|
+
throw new Error("LLM response exceeded the size limit");
|
|
239
|
+
}
|
|
240
|
+
chunks.push(value);
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
await reader.cancel().catch(() => {
|
|
244
|
+
});
|
|
245
|
+
throw error;
|
|
246
|
+
} finally {
|
|
247
|
+
reader.releaseLock();
|
|
248
|
+
}
|
|
249
|
+
const joined = new Uint8Array(total);
|
|
250
|
+
let offset = 0;
|
|
251
|
+
for (const chunk of chunks) {
|
|
252
|
+
joined.set(chunk, offset);
|
|
253
|
+
offset += chunk.byteLength;
|
|
254
|
+
}
|
|
255
|
+
return JSON.parse(new TextDecoder().decode(joined));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/llm-budget.ts
|
|
259
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
260
|
+
import { dirname } from "node:path";
|
|
261
|
+
var DEFAULT_DAILY_CALL_LIMIT = 2e3;
|
|
262
|
+
var DEFAULT_DAILY_TOKEN_LIMIT = 5e6;
|
|
263
|
+
var DailyLimitReachedError = class extends Error {
|
|
264
|
+
constructor(day) {
|
|
265
|
+
super("daily LLM budget reached");
|
|
266
|
+
this.day = day;
|
|
267
|
+
this.name = "DailyLimitReachedError";
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
function utcDay(d) {
|
|
271
|
+
return d.toISOString().slice(0, 10);
|
|
272
|
+
}
|
|
273
|
+
var DailyLlmBudget = class {
|
|
274
|
+
callLimit;
|
|
275
|
+
tokenLimit;
|
|
276
|
+
statePath;
|
|
277
|
+
now;
|
|
278
|
+
log;
|
|
279
|
+
state;
|
|
280
|
+
exhaustionLoggedFor = null;
|
|
281
|
+
constructor(opts) {
|
|
282
|
+
this.callLimit = opts.callLimit;
|
|
283
|
+
this.tokenLimit = opts.tokenLimit;
|
|
284
|
+
this.statePath = opts.statePath;
|
|
285
|
+
this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
286
|
+
this.log = opts.log ?? (() => {
|
|
287
|
+
});
|
|
288
|
+
this.state = this.load();
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Reserve one provider call against today's budget, or throw
|
|
292
|
+
* {@link DailyLimitReachedError} without reserving anything.
|
|
293
|
+
*/
|
|
294
|
+
beginCall() {
|
|
295
|
+
this.roll();
|
|
296
|
+
const { calls, tokens, day } = this.state;
|
|
297
|
+
const callsSpent = this.callLimit > 0 && calls >= this.callLimit;
|
|
298
|
+
const tokensSpent = this.tokenLimit > 0 && tokens >= this.tokenLimit;
|
|
299
|
+
if (callsSpent || tokensSpent) {
|
|
300
|
+
if (this.exhaustionLoggedFor !== day) {
|
|
301
|
+
this.exhaustionLoggedFor = day;
|
|
302
|
+
this.log("warn", "llm.daily_budget_exhausted", {
|
|
303
|
+
calls,
|
|
304
|
+
tokens,
|
|
305
|
+
call_limit: this.callLimit,
|
|
306
|
+
token_limit: this.tokenLimit
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
throw new DailyLimitReachedError(day);
|
|
310
|
+
}
|
|
311
|
+
this.state.calls++;
|
|
312
|
+
this.save();
|
|
313
|
+
}
|
|
314
|
+
/** Add provider-reported tokens to today's total. */
|
|
315
|
+
recordTokens(totalTokens) {
|
|
316
|
+
if (!Number.isFinite(totalTokens) || totalTokens <= 0) return;
|
|
317
|
+
this.roll();
|
|
318
|
+
this.state.tokens += Math.floor(totalTokens);
|
|
319
|
+
this.save();
|
|
320
|
+
}
|
|
321
|
+
snapshot() {
|
|
322
|
+
this.roll();
|
|
323
|
+
return { ...this.state };
|
|
324
|
+
}
|
|
325
|
+
roll() {
|
|
326
|
+
const today = utcDay(this.now());
|
|
327
|
+
if (this.state.day !== today) this.state = { day: today, calls: 0, tokens: 0 };
|
|
328
|
+
}
|
|
329
|
+
load() {
|
|
330
|
+
const fresh = { day: utcDay(this.now()), calls: 0, tokens: 0 };
|
|
331
|
+
if (!this.statePath) return fresh;
|
|
332
|
+
let raw;
|
|
333
|
+
try {
|
|
334
|
+
raw = readFileSync(this.statePath, "utf8");
|
|
335
|
+
} catch {
|
|
336
|
+
return fresh;
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
const parsed = JSON.parse(raw);
|
|
340
|
+
if (parsed.day === fresh.day && Number.isInteger(parsed.calls) && parsed.calls >= 0 && Number.isInteger(parsed.tokens) && parsed.tokens >= 0) {
|
|
341
|
+
return { day: fresh.day, calls: parsed.calls, tokens: parsed.tokens };
|
|
342
|
+
}
|
|
343
|
+
return fresh;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
this.log("warn", "llm.budget_state_unreadable", {}, error);
|
|
346
|
+
return fresh;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
save() {
|
|
350
|
+
if (!this.statePath) return;
|
|
351
|
+
try {
|
|
352
|
+
mkdirSync(dirname(this.statePath), { recursive: true });
|
|
353
|
+
const tmp = `${this.statePath}.tmp`;
|
|
354
|
+
writeFileSync(tmp, JSON.stringify(this.state), { mode: 384 });
|
|
355
|
+
renameSync(tmp, this.statePath);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
this.log("warn", "llm.budget_state_write_failed", {}, error);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
function withDailyBudget(llm, budget) {
|
|
362
|
+
return {
|
|
363
|
+
async complete(messages, tools, options) {
|
|
364
|
+
budget.beginCall();
|
|
365
|
+
const result = await llm.complete(messages, tools, options);
|
|
366
|
+
if (result.usage) budget.recordTokens(result.usage.totalTokens);
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/config.ts
|
|
15
373
|
var MISSING_IDENTITY_MESSAGE = "Missing agent identity: pass --identity <id> or set SECURECOMS_IDENTITY";
|
|
16
374
|
var LOG_LEVELS = /* @__PURE__ */ new Set(["debug", "info", "warn", "error"]);
|
|
17
375
|
var TOOL_DEFAULTS = /* @__PURE__ */ new Set(["allow", "deny"]);
|
|
@@ -48,8 +406,16 @@ function parseList(value) {
|
|
|
48
406
|
const items = value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
49
407
|
return items.length > 0 ? items : void 0;
|
|
50
408
|
}
|
|
409
|
+
function parseIntSetting(env, name, fallback, min) {
|
|
410
|
+
const raw = env[name]?.trim();
|
|
411
|
+
if (!raw) return fallback;
|
|
412
|
+
if (!/^\d+$/.test(raw) || Number(raw) < min || !Number.isSafeInteger(Number(raw))) {
|
|
413
|
+
throw new Error(`Invalid ${name} "${raw}": expected a whole number >= ${min}`);
|
|
414
|
+
}
|
|
415
|
+
return Number(raw);
|
|
416
|
+
}
|
|
51
417
|
function defaultSkillsDir() {
|
|
52
|
-
return join(
|
|
418
|
+
return join(dirname2(fileURLToPath(import.meta.url)), "..", "skills");
|
|
53
419
|
}
|
|
54
420
|
var DEFAULT_TOOL_ALLOW = [
|
|
55
421
|
"echo",
|
|
@@ -99,7 +465,10 @@ function loadConfig(argv, env) {
|
|
|
99
465
|
toolGateEnv: {
|
|
100
466
|
...parsedAllow !== void 0 ? { allow: parsedAllow } : {},
|
|
101
467
|
...deny !== void 0 ? { deny } : {}
|
|
102
|
-
}
|
|
468
|
+
},
|
|
469
|
+
llmMaxOutputTokens: parseIntSetting(env, "SECURECOMS_LLM_MAX_OUTPUT_TOKENS", DEFAULT_MAX_OUTPUT_TOKENS, 1),
|
|
470
|
+
llmDailyCallLimit: parseIntSetting(env, "SECURECOMS_LLM_DAILY_CALL_LIMIT", DEFAULT_DAILY_CALL_LIMIT, 0),
|
|
471
|
+
llmDailyTokenLimit: parseIntSetting(env, "SECURECOMS_LLM_DAILY_TOKEN_LIMIT", DEFAULT_DAILY_TOKEN_LIMIT, 0)
|
|
103
472
|
};
|
|
104
473
|
}
|
|
105
474
|
|
|
@@ -154,9 +523,23 @@ function parsePersonaVerb(argv) {
|
|
|
154
523
|
const sub = argv[3];
|
|
155
524
|
return sub === "init" || sub === "show" ? sub : null;
|
|
156
525
|
}
|
|
526
|
+
function parseSideEffectsVerb(argv) {
|
|
527
|
+
if (argv[2] !== "side-effects") return null;
|
|
528
|
+
const sub = argv[3];
|
|
529
|
+
return sub === "list" || sub === "resolve" ? sub : null;
|
|
530
|
+
}
|
|
531
|
+
function parseSideEffectResolveFlags(argv) {
|
|
532
|
+
const outcome = flagOrEnv(argv, "--outcome", {});
|
|
533
|
+
return {
|
|
534
|
+
idempotencyKey: flagOrEnv(argv, "--idempotency-key", {}),
|
|
535
|
+
operationSha256: flagOrEnv(argv, "--operation-sha256", {}),
|
|
536
|
+
confirmationId: flagOrEnv(argv, "--confirmation-id", {}),
|
|
537
|
+
outcome: outcome === "completed" || outcome === "not_applied" ? outcome : void 0
|
|
538
|
+
};
|
|
539
|
+
}
|
|
157
540
|
|
|
158
541
|
// src/service/install.ts
|
|
159
|
-
import { dirname as
|
|
542
|
+
import { dirname as dirname3 } from "node:path";
|
|
160
543
|
|
|
161
544
|
// src/service/config.ts
|
|
162
545
|
import { isAbsolute, resolve } from "node:path";
|
|
@@ -388,8 +771,8 @@ function installService(input, deps) {
|
|
|
388
771
|
const plan = prepareInstall(input, deps.platform, deps);
|
|
389
772
|
const keyFile = plan.keyFile;
|
|
390
773
|
if (deps.platform === "darwin") {
|
|
391
|
-
deps.mkdirp(
|
|
392
|
-
deps.mkdirp(
|
|
774
|
+
deps.mkdirp(dirname3(cfg.plistPath));
|
|
775
|
+
deps.mkdirp(dirname3(cfg.logPath));
|
|
393
776
|
deps.writeFile(cfg.plistPath, plan.contents);
|
|
394
777
|
deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
|
|
395
778
|
requireOk(deps.run("launchctl", ["bootstrap", `gui/${cfg.uid}`, cfg.plistPath]), "launchctl bootstrap");
|
|
@@ -397,7 +780,7 @@ function installService(input, deps) {
|
|
|
397
780
|
return { label: cfg.label, filePath: cfg.plistPath, logHint: cfg.logPath, ...keyFile ? { keyFile } : {} };
|
|
398
781
|
}
|
|
399
782
|
if (deps.platform === "linux") {
|
|
400
|
-
deps.mkdirp(
|
|
783
|
+
deps.mkdirp(dirname3(cfg.unitPath));
|
|
401
784
|
deps.writeFile(cfg.unitPath, plan.contents);
|
|
402
785
|
deps.run("systemctl", ["--user", "daemon-reload"]);
|
|
403
786
|
deps.run("loginctl", ["enable-linger", deps.user]);
|
|
@@ -431,63 +814,6 @@ function uninstallService(input, deps) {
|
|
|
431
814
|
throw new ServiceUnsupportedError(`Service uninstall isn't supported on ${deps.platform} yet.`);
|
|
432
815
|
}
|
|
433
816
|
|
|
434
|
-
// src/llm-dispatch.ts
|
|
435
|
-
function normalizeEndpoint(endpoint) {
|
|
436
|
-
return endpoint.replace(/\/+$/, "").replace(/\/v1$/, "").replace(/\/+$/, "");
|
|
437
|
-
}
|
|
438
|
-
var LLMDispatcher = class {
|
|
439
|
-
endpoint;
|
|
440
|
-
model;
|
|
441
|
-
apiKey;
|
|
442
|
-
chatPath;
|
|
443
|
-
chatAuth;
|
|
444
|
-
authHeaderName;
|
|
445
|
-
constructor(endpoint, model, opts = {}) {
|
|
446
|
-
this.endpoint = normalizeEndpoint(endpoint);
|
|
447
|
-
this.model = model;
|
|
448
|
-
this.apiKey = opts.apiKey;
|
|
449
|
-
this.chatPath = opts.chatPath ?? "/v1/chat/completions";
|
|
450
|
-
this.chatAuth = opts.chatAuth ?? "bearer";
|
|
451
|
-
this.authHeaderName = opts.authHeaderName ?? "x-api-key";
|
|
452
|
-
}
|
|
453
|
-
async complete(messages, tools) {
|
|
454
|
-
const body = {
|
|
455
|
-
model: this.model,
|
|
456
|
-
messages,
|
|
457
|
-
stream: false
|
|
458
|
-
};
|
|
459
|
-
if (tools && tools.length > 0) {
|
|
460
|
-
body.tools = tools;
|
|
461
|
-
}
|
|
462
|
-
const url = `${this.endpoint}${this.chatPath}`;
|
|
463
|
-
const headers = { "Content-Type": "application/json" };
|
|
464
|
-
if (this.apiKey && this.chatAuth === "bearer") {
|
|
465
|
-
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
466
|
-
} else if (this.apiKey && this.chatAuth === "x-api-key") {
|
|
467
|
-
headers[this.authHeaderName] = this.apiKey;
|
|
468
|
-
}
|
|
469
|
-
const resp = await fetch(url, {
|
|
470
|
-
method: "POST",
|
|
471
|
-
headers,
|
|
472
|
-
body: JSON.stringify(body)
|
|
473
|
-
});
|
|
474
|
-
if (!resp.ok) {
|
|
475
|
-
await resp.body?.cancel();
|
|
476
|
-
throw new Error(`LLM request failed (${resp.status})`);
|
|
477
|
-
}
|
|
478
|
-
const data = await resp.json();
|
|
479
|
-
const choice = data.choices?.[0];
|
|
480
|
-
if (!choice) {
|
|
481
|
-
throw new Error("LLM returned no choices");
|
|
482
|
-
}
|
|
483
|
-
return {
|
|
484
|
-
content: choice.message.content,
|
|
485
|
-
toolCalls: choice.message.tool_calls || [],
|
|
486
|
-
finishReason: choice.finish_reason
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
};
|
|
490
|
-
|
|
491
817
|
// src/llm-source.ts
|
|
492
818
|
import { join as join2 } from "node:path";
|
|
493
819
|
|
|
@@ -661,34 +987,20 @@ function resolveLlmSource(cfg, explicit, deps) {
|
|
|
661
987
|
}
|
|
662
988
|
|
|
663
989
|
// src/llm-shim.ts
|
|
664
|
-
function endpointHost(endpoint) {
|
|
665
|
-
try {
|
|
666
|
-
return new URL(endpoint).host;
|
|
667
|
-
} catch {
|
|
668
|
-
return "(unparseable endpoint)";
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
990
|
function buildLlmShim(cfg, explicit, deps) {
|
|
672
991
|
return {
|
|
673
|
-
async complete(messages, tools) {
|
|
992
|
+
async complete(messages, tools, options) {
|
|
674
993
|
const src = resolveLlmSource(cfg, explicit, deps);
|
|
675
994
|
const dispatcher = new LLMDispatcher(src.endpoint, src.model, {
|
|
676
995
|
apiKey: src.apiKey,
|
|
677
996
|
chatPath: src.chatPath,
|
|
678
|
-
chatAuth: src.chatAuth
|
|
997
|
+
chatAuth: src.chatAuth,
|
|
998
|
+
maxOutputTokens: cfg.llmMaxOutputTokens
|
|
679
999
|
});
|
|
680
|
-
return dispatcher.complete(messages, tools);
|
|
1000
|
+
return dispatcher.complete(messages, tools, options);
|
|
681
1001
|
}
|
|
682
1002
|
};
|
|
683
1003
|
}
|
|
684
|
-
function bootLine(cfg, explicit, deps) {
|
|
685
|
-
try {
|
|
686
|
-
const src = resolveLlmSource(cfg, explicit, deps);
|
|
687
|
-
return `securecoms-agent ${cfg.identityId.slice(0, 8)} \u2192 llm source=${src.origin} ${endpointHost(src.endpoint)} (${src.model})`;
|
|
688
|
-
} catch (e) {
|
|
689
|
-
return `llm: source unavailable \u2014 ${e instanceof Error ? e.message : String(e)}`;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
1004
|
|
|
693
1005
|
// src/memory.ts
|
|
694
1006
|
var MAX_HISTORY = 50;
|
|
@@ -742,7 +1054,7 @@ ${scored.map((entry) => `- ${entry.content}`).join("\n")}`;
|
|
|
742
1054
|
};
|
|
743
1055
|
|
|
744
1056
|
// src/skill-invoker.ts
|
|
745
|
-
import { readFileSync as
|
|
1057
|
+
import { readFileSync as readFileSync5, readdirSync, existsSync as existsSync3, statSync } from "node:fs";
|
|
746
1058
|
import { join as join4 } from "node:path";
|
|
747
1059
|
|
|
748
1060
|
// src/skill-md.ts
|
|
@@ -866,16 +1178,35 @@ ${guidance}` : description,
|
|
|
866
1178
|
}
|
|
867
1179
|
|
|
868
1180
|
// src/requires.ts
|
|
869
|
-
function hasReadableKeyFile(paths, read) {
|
|
1181
|
+
function hasReadableKeyFile(paths, read, onReadError) {
|
|
870
1182
|
return paths.some((path) => {
|
|
871
1183
|
if (!path) return false;
|
|
872
1184
|
try {
|
|
873
1185
|
return read(path).trim() !== "";
|
|
874
|
-
} catch {
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
try {
|
|
1188
|
+
onReadError?.(path, error);
|
|
1189
|
+
} catch {
|
|
1190
|
+
}
|
|
875
1191
|
return false;
|
|
876
1192
|
}
|
|
877
1193
|
});
|
|
878
1194
|
}
|
|
1195
|
+
function searchKeyGateReporter(log, isBravePath) {
|
|
1196
|
+
const logged = /* @__PURE__ */ new Set();
|
|
1197
|
+
return (path, error) => {
|
|
1198
|
+
if (error?.code === "ENOENT") return;
|
|
1199
|
+
const signature = `${path}|${errorClass(error)}`;
|
|
1200
|
+
if (logged.has(signature)) return;
|
|
1201
|
+
logged.add(signature);
|
|
1202
|
+
log(
|
|
1203
|
+
"warn",
|
|
1204
|
+
"runtime.search_key_unreadable",
|
|
1205
|
+
{ at_gate: true, provider_brave: isBravePath(path) },
|
|
1206
|
+
error
|
|
1207
|
+
);
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
879
1210
|
function requiresEnvFrom(cfg, hasSearchKey) {
|
|
880
1211
|
return {
|
|
881
1212
|
hasSearchKey,
|
|
@@ -898,7 +1229,7 @@ function resolveRequires(req, env) {
|
|
|
898
1229
|
}
|
|
899
1230
|
|
|
900
1231
|
// src/skills-config.ts
|
|
901
|
-
import { existsSync as existsSync2, readFileSync } from "node:fs";
|
|
1232
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
902
1233
|
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
|
|
903
1234
|
|
|
904
1235
|
// src/config-dirs.ts
|
|
@@ -927,13 +1258,13 @@ function loadSkillsConfigFile(path, dataDir) {
|
|
|
927
1258
|
if (!existsSync2(path)) return empty();
|
|
928
1259
|
let raw;
|
|
929
1260
|
try {
|
|
930
|
-
raw = JSON.parse(
|
|
1261
|
+
raw = JSON.parse(readFileSync2(path, "utf8"));
|
|
931
1262
|
} catch (e) {
|
|
932
|
-
|
|
1263
|
+
writeServiceLog("warn", "skills_config.parse_failed", {}, e);
|
|
933
1264
|
return empty();
|
|
934
1265
|
}
|
|
935
1266
|
if (typeof raw !== "object" || raw === null) {
|
|
936
|
-
|
|
1267
|
+
writeServiceLog("warn", "skills_config.root_invalid");
|
|
937
1268
|
return empty();
|
|
938
1269
|
}
|
|
939
1270
|
const src = raw;
|
|
@@ -941,7 +1272,7 @@ function loadSkillsConfigFile(path, dataDir) {
|
|
|
941
1272
|
for (const [name, v] of Object.entries(src.endpoints ?? {})) {
|
|
942
1273
|
const e = v;
|
|
943
1274
|
if (typeof e?.baseUrl !== "string" || e.baseUrl === "") {
|
|
944
|
-
|
|
1275
|
+
writeServiceLog("warn", "skills_config.endpoint_invalid");
|
|
945
1276
|
continue;
|
|
946
1277
|
}
|
|
947
1278
|
endpoints[name] = {
|
|
@@ -955,7 +1286,7 @@ function loadSkillsConfigFile(path, dataDir) {
|
|
|
955
1286
|
if (typeof d?.sqlite === "string" && d.sqlite !== "") databases[name] = { sqlite: abs2(d.sqlite, dataDir) };
|
|
956
1287
|
else if (typeof d?.postgresUrlFile === "string" && d.postgresUrlFile !== "") {
|
|
957
1288
|
databases[name] = { postgresUrlFile: abs2(d.postgresUrlFile, dataDir) };
|
|
958
|
-
} else
|
|
1289
|
+
} else writeServiceLog("warn", "skills_config.database_invalid");
|
|
959
1290
|
}
|
|
960
1291
|
const flags = Array.isArray(src.flags) ? src.flags.filter((f) => typeof f === "string") : [];
|
|
961
1292
|
const strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : void 0;
|
|
@@ -968,9 +1299,9 @@ function loadSkillsConfigFile(path, dataDir) {
|
|
|
968
1299
|
const s = src.search;
|
|
969
1300
|
if (s && typeof s === "object") {
|
|
970
1301
|
if (s.provider !== "brave" && s.provider !== "tavily") {
|
|
971
|
-
|
|
1302
|
+
writeServiceLog("warn", "skills_config.search_provider_invalid");
|
|
972
1303
|
} else if (typeof s.keyFile !== "string" || s.keyFile === "") {
|
|
973
|
-
|
|
1304
|
+
writeServiceLog("warn", "skills_config.search_key_file_missing");
|
|
974
1305
|
} else {
|
|
975
1306
|
search = { provider: s.provider, keyFile: abs2(s.keyFile, dataDir) };
|
|
976
1307
|
}
|
|
@@ -1002,12 +1333,30 @@ function htmlToText(html) {
|
|
|
1002
1333
|
}
|
|
1003
1334
|
|
|
1004
1335
|
// src/net-guard.ts
|
|
1005
|
-
import {
|
|
1336
|
+
import { Resolver as DnsResolver } from "node:dns/promises";
|
|
1006
1337
|
import { isIPv4, isIPv6 } from "node:net";
|
|
1007
1338
|
var BlockedAddressError = class extends Error {
|
|
1008
1339
|
};
|
|
1009
1340
|
function systemResolver() {
|
|
1010
|
-
return async (host
|
|
1341
|
+
return async (host, signal) => {
|
|
1342
|
+
if (isIPv4(host) || isIPv6(host)) return [host];
|
|
1343
|
+
if (signal?.aborted) throw signal.reason ?? new Error("DNS lookup cancelled");
|
|
1344
|
+
const resolver = new DnsResolver();
|
|
1345
|
+
const cancel = () => resolver.cancel();
|
|
1346
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
1347
|
+
try {
|
|
1348
|
+
const results = await Promise.allSettled([resolver.resolve4(host), resolver.resolve6(host)]);
|
|
1349
|
+
if (signal?.aborted) throw signal.reason ?? new Error("DNS lookup cancelled");
|
|
1350
|
+
const addresses = results.flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
|
1351
|
+
if (addresses.length === 0) {
|
|
1352
|
+
const rejected = results.find((result) => result.status === "rejected");
|
|
1353
|
+
throw rejected?.reason ?? new Error("DNS lookup returned no addresses");
|
|
1354
|
+
}
|
|
1355
|
+
return addresses;
|
|
1356
|
+
} finally {
|
|
1357
|
+
signal?.removeEventListener("abort", cancel);
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1011
1360
|
}
|
|
1012
1361
|
function v4Parts(ip) {
|
|
1013
1362
|
if (!isIPv4(ip)) return null;
|
|
@@ -1054,7 +1403,7 @@ function isPrivateAddress(ip) {
|
|
|
1054
1403
|
}
|
|
1055
1404
|
return (v6[0] & 65024) === 64512 || (v6[0] & 65472) === 65152;
|
|
1056
1405
|
}
|
|
1057
|
-
async function assertPublicHost(url, resolve3) {
|
|
1406
|
+
async function assertPublicHost(url, resolve3, signal) {
|
|
1058
1407
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1059
1408
|
throw new BlockedAddressError(`refused ${url.protocol}//: only http and https are allowed`);
|
|
1060
1409
|
}
|
|
@@ -1064,7 +1413,9 @@ async function assertPublicHost(url, resolve3) {
|
|
|
1064
1413
|
}
|
|
1065
1414
|
let addresses;
|
|
1066
1415
|
try {
|
|
1067
|
-
|
|
1416
|
+
if (signal?.aborted) throw signal.reason ?? new Error("network request cancelled");
|
|
1417
|
+
addresses = await resolve3(host, signal);
|
|
1418
|
+
if (signal?.aborted) throw signal.reason ?? new Error("network request cancelled");
|
|
1068
1419
|
} catch (e) {
|
|
1069
1420
|
throw new BlockedAddressError(`refused ${url.hostname}: cannot resolve (${e instanceof Error ? e.message : String(e)})`);
|
|
1070
1421
|
}
|
|
@@ -1082,14 +1433,20 @@ async function safeFetch(url, opts) {
|
|
|
1082
1433
|
const maxRedirects = opts.maxRedirects ?? 3;
|
|
1083
1434
|
const maxBytes = opts.maxBytes ?? 2e6;
|
|
1084
1435
|
const controller = new AbortController();
|
|
1085
|
-
const
|
|
1436
|
+
const cancel = () => controller.abort(opts.signal?.reason);
|
|
1437
|
+
if (opts.signal?.aborted) cancel();
|
|
1438
|
+
else opts.signal?.addEventListener("abort", cancel, { once: true });
|
|
1439
|
+
const timer = setTimeout(
|
|
1440
|
+
() => controller.abort(new Error("network request deadline exceeded")),
|
|
1441
|
+
opts.timeoutMs ?? 1e4
|
|
1442
|
+
);
|
|
1086
1443
|
try {
|
|
1087
1444
|
let current = new URL(url);
|
|
1088
1445
|
let method = opts.method ?? "GET";
|
|
1089
1446
|
let headers = opts.headers;
|
|
1090
1447
|
let body = opts.body;
|
|
1091
1448
|
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
1092
|
-
await assertPublicHost(current, opts.resolve);
|
|
1449
|
+
await assertPublicHost(current, opts.resolve, controller.signal);
|
|
1093
1450
|
const res = await opts.fetch(current.toString(), {
|
|
1094
1451
|
method,
|
|
1095
1452
|
headers,
|
|
@@ -1122,6 +1479,7 @@ async function safeFetch(url, opts) {
|
|
|
1122
1479
|
throw new BlockedAddressError(`too many redirects (>${maxRedirects})`);
|
|
1123
1480
|
} finally {
|
|
1124
1481
|
clearTimeout(timer);
|
|
1482
|
+
opts.signal?.removeEventListener("abort", cancel);
|
|
1125
1483
|
}
|
|
1126
1484
|
}
|
|
1127
1485
|
function headerMap(res) {
|
|
@@ -1171,7 +1529,8 @@ var fetchUrl = async (args, ctx) => {
|
|
|
1171
1529
|
fetch: ctx.fetch,
|
|
1172
1530
|
maxRedirects: 3,
|
|
1173
1531
|
timeoutMs: 1e4,
|
|
1174
|
-
maxBytes: 2e6
|
|
1532
|
+
maxBytes: 2e6,
|
|
1533
|
+
signal: ctx.signal
|
|
1175
1534
|
});
|
|
1176
1535
|
const type = res.headers["content-type"] ?? "";
|
|
1177
1536
|
const text = /html|xml/i.test(type) ? htmlToText(res.text) : res.text.replace(/\s+/g, " ").trim();
|
|
@@ -1182,7 +1541,7 @@ var fetchUrl = async (args, ctx) => {
|
|
|
1182
1541
|
};
|
|
1183
1542
|
|
|
1184
1543
|
// src/builtins/http-request.ts
|
|
1185
|
-
import { readFileSync as
|
|
1544
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
1186
1545
|
var METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "DELETE"]);
|
|
1187
1546
|
var httpRequest = async (args, ctx) => {
|
|
1188
1547
|
const name = typeof args.name === "string" ? args.name : "";
|
|
@@ -1225,7 +1584,7 @@ var httpRequest = async (args, ctx) => {
|
|
|
1225
1584
|
const headers = { accept: "application/json" };
|
|
1226
1585
|
if (endpoint.headersFile) {
|
|
1227
1586
|
try {
|
|
1228
|
-
for (const [k, v] of Object.entries(JSON.parse(
|
|
1587
|
+
for (const [k, v] of Object.entries(JSON.parse(readFileSync3(endpoint.headersFile, "utf8")))) {
|
|
1229
1588
|
if (typeof v === "string") headers[k] = v;
|
|
1230
1589
|
}
|
|
1231
1590
|
} catch {
|
|
@@ -1242,7 +1601,8 @@ var httpRequest = async (args, ctx) => {
|
|
|
1242
1601
|
headers,
|
|
1243
1602
|
body,
|
|
1244
1603
|
timeoutMs: 1e4,
|
|
1245
|
-
maxBytes: 2e6
|
|
1604
|
+
maxBytes: 2e6,
|
|
1605
|
+
signal: ctx.signal
|
|
1246
1606
|
});
|
|
1247
1607
|
return JSON.stringify({ status: res.status, body: capTo(res.text, FETCH_TEXT_CAP) });
|
|
1248
1608
|
} catch (e) {
|
|
@@ -1251,7 +1611,8 @@ var httpRequest = async (args, ctx) => {
|
|
|
1251
1611
|
};
|
|
1252
1612
|
|
|
1253
1613
|
// src/builtins/sql-query.ts
|
|
1254
|
-
import { readFileSync as
|
|
1614
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
1615
|
+
import { spawn } from "node:child_process";
|
|
1255
1616
|
|
|
1256
1617
|
// src/sql-guard.ts
|
|
1257
1618
|
var SqlNotReadOnlyError = class extends Error {
|
|
@@ -1305,16 +1666,160 @@ function assertReadOnlySelect(sql) {
|
|
|
1305
1666
|
}
|
|
1306
1667
|
|
|
1307
1668
|
// src/builtins/sql-query.ts
|
|
1669
|
+
var SQLITE_PROCESS_SOURCE = String.raw`
|
|
1670
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
1671
|
+
|
|
1672
|
+
let input = "";
|
|
1673
|
+
process.stdin.setEncoding("utf8");
|
|
1674
|
+
process.stdin.on("data", (chunk) => { input += chunk; });
|
|
1675
|
+
process.stdin.on("end", () => {
|
|
1676
|
+
let handle;
|
|
1677
|
+
try {
|
|
1678
|
+
const request = JSON.parse(input);
|
|
1679
|
+
handle = new DatabaseSync(request.path, {
|
|
1680
|
+
readOnly: true,
|
|
1681
|
+
timeout: request.busyTimeoutMs,
|
|
1682
|
+
});
|
|
1683
|
+
// Keep SQLite-owned allocations bounded too, not only V8's heap. Temp
|
|
1684
|
+
// work stays in the limited heap instead of consuming unbounded disk,
|
|
1685
|
+
// and mmap is disabled so reads cannot bypass the allocation ceiling.
|
|
1686
|
+
const heapLimit = handle.prepare(
|
|
1687
|
+
"PRAGMA hard_heap_limit = " + request.sqliteHeapLimitBytes
|
|
1688
|
+
).get();
|
|
1689
|
+
const effectiveHeapLimit = Number(Object.values(heapLimit ?? {})[0]);
|
|
1690
|
+
if (!(effectiveHeapLimit > 0 && effectiveHeapLimit <= request.sqliteHeapLimitBytes)) {
|
|
1691
|
+
throw new Error("SQLite heap limit could not be enforced");
|
|
1692
|
+
}
|
|
1693
|
+
handle.exec("PRAGMA temp_store = MEMORY; PRAGMA mmap_size = 0");
|
|
1694
|
+
const statement = handle.prepare(request.sql);
|
|
1695
|
+
const rows = [];
|
|
1696
|
+
let columns = [];
|
|
1697
|
+
let truncated = false;
|
|
1698
|
+
|
|
1699
|
+
for (const record of statement.iterate()) {
|
|
1700
|
+
if (rows.length === request.rowCap) {
|
|
1701
|
+
truncated = true;
|
|
1702
|
+
break;
|
|
1703
|
+
}
|
|
1704
|
+
if (rows.length === 0) columns = Object.keys(record);
|
|
1705
|
+
rows.push(columns.map((column) => record[column]));
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
process.stdout.write(JSON.stringify({
|
|
1709
|
+
ok: true,
|
|
1710
|
+
result: { columns, rows, truncated },
|
|
1711
|
+
}));
|
|
1712
|
+
} catch (error) {
|
|
1713
|
+
process.stdout.write(JSON.stringify({
|
|
1714
|
+
ok: false,
|
|
1715
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1716
|
+
}));
|
|
1717
|
+
} finally {
|
|
1718
|
+
if (handle) handle.close();
|
|
1719
|
+
}
|
|
1720
|
+
});
|
|
1721
|
+
`;
|
|
1722
|
+
var SQLITE_IPC_CAP_BYTES = 1024 * 1024;
|
|
1723
|
+
var SQLITE_HEAP_CAP_BYTES = 32 * 1024 * 1024;
|
|
1724
|
+
function executeSqliteQuery(path, sql, options = {}) {
|
|
1725
|
+
const timeoutMs = options.timeoutMs ?? SQL_TIMEOUT_MS;
|
|
1726
|
+
if (options.signal?.aborted) return Promise.reject(new Error("SQLite query cancelled"));
|
|
1727
|
+
return new Promise((resolve3, reject) => {
|
|
1728
|
+
const child = spawn(process.execPath, [
|
|
1729
|
+
"--no-warnings",
|
|
1730
|
+
"--max-old-space-size=32",
|
|
1731
|
+
"--input-type=commonjs",
|
|
1732
|
+
"--eval",
|
|
1733
|
+
SQLITE_PROCESS_SOURCE
|
|
1734
|
+
], {
|
|
1735
|
+
// The query process needs no credentials or runtime configuration.
|
|
1736
|
+
// In particular, do not let NODE_OPTIONS weaken its heap boundary.
|
|
1737
|
+
env: {},
|
|
1738
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
1739
|
+
windowsHide: true
|
|
1740
|
+
});
|
|
1741
|
+
let settled = false;
|
|
1742
|
+
let stopError;
|
|
1743
|
+
let stdout = "";
|
|
1744
|
+
let stdoutBytes = 0;
|
|
1745
|
+
const cleanup = () => {
|
|
1746
|
+
clearTimeout(timer);
|
|
1747
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
1748
|
+
};
|
|
1749
|
+
const stop = (error) => {
|
|
1750
|
+
if (settled || stopError) return;
|
|
1751
|
+
stopError = error;
|
|
1752
|
+
cleanup();
|
|
1753
|
+
child.kill("SIGKILL");
|
|
1754
|
+
};
|
|
1755
|
+
const onAbort = () => stop(new Error("SQLite query cancelled"));
|
|
1756
|
+
const timer = setTimeout(
|
|
1757
|
+
() => stop(new Error(`SQLite query timed out after ${timeoutMs}ms`)),
|
|
1758
|
+
timeoutMs
|
|
1759
|
+
);
|
|
1760
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1761
|
+
child.stdout.setEncoding("utf8");
|
|
1762
|
+
child.stdout.on("data", (chunk) => {
|
|
1763
|
+
if (stopError) return;
|
|
1764
|
+
stdoutBytes += Buffer.byteLength(chunk);
|
|
1765
|
+
if (stdoutBytes > SQLITE_IPC_CAP_BYTES) {
|
|
1766
|
+
stop(new Error("SQLite query result exceeded the materialization limit"));
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
stdout += chunk;
|
|
1770
|
+
});
|
|
1771
|
+
child.once("error", (error) => {
|
|
1772
|
+
if (settled) return;
|
|
1773
|
+
settled = true;
|
|
1774
|
+
cleanup();
|
|
1775
|
+
reject(error);
|
|
1776
|
+
});
|
|
1777
|
+
child.once("close", (code) => {
|
|
1778
|
+
if (settled) return;
|
|
1779
|
+
settled = true;
|
|
1780
|
+
cleanup();
|
|
1781
|
+
if (stopError) {
|
|
1782
|
+
reject(stopError);
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
if (code !== 0) {
|
|
1786
|
+
reject(new Error(`SQLite query process exited before returning a result (code ${code})`));
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
try {
|
|
1790
|
+
const message = JSON.parse(stdout);
|
|
1791
|
+
if (message.ok && message.result) resolve3(message.result);
|
|
1792
|
+
else reject(new Error(message.error ?? "SQLite query process failed"));
|
|
1793
|
+
} catch {
|
|
1794
|
+
reject(new Error("SQLite query process returned an invalid result"));
|
|
1795
|
+
}
|
|
1796
|
+
});
|
|
1797
|
+
child.stdin.on("error", () => {
|
|
1798
|
+
});
|
|
1799
|
+
child.stdin.end(JSON.stringify({
|
|
1800
|
+
path,
|
|
1801
|
+
sql,
|
|
1802
|
+
rowCap: SQL_ROW_CAP,
|
|
1803
|
+
busyTimeoutMs: timeoutMs,
|
|
1804
|
+
sqliteHeapLimitBytes: SQLITE_HEAP_CAP_BYTES
|
|
1805
|
+
}));
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1308
1808
|
function envelope(columns, rows, truncated) {
|
|
1309
1809
|
let kept = rows;
|
|
1310
1810
|
let cut = truncated;
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1811
|
+
try {
|
|
1812
|
+
let out = JSON.stringify({ columns, rows: kept, truncated: cut });
|
|
1813
|
+
while (resultBytes(out) > RESULT_CAP && kept.length > 0) {
|
|
1814
|
+
kept = kept.slice(0, Math.floor(kept.length / 2));
|
|
1815
|
+
cut = true;
|
|
1816
|
+
out = JSON.stringify({ columns, rows: kept, truncated: cut });
|
|
1817
|
+
}
|
|
1818
|
+
if (resultBytes(out) <= RESULT_CAP) return out;
|
|
1819
|
+
} catch {
|
|
1820
|
+
return toolError("database result could not be serialized");
|
|
1316
1821
|
}
|
|
1317
|
-
return
|
|
1822
|
+
return toolError("database result metadata exceeded the response limit");
|
|
1318
1823
|
}
|
|
1319
1824
|
var sqlQuery = async (args, ctx) => {
|
|
1320
1825
|
const name = typeof args.name === "string" ? args.name : "";
|
|
@@ -1332,16 +1837,8 @@ var sqlQuery = async (args, ctx) => {
|
|
|
1332
1837
|
}
|
|
1333
1838
|
try {
|
|
1334
1839
|
if ("sqlite" in db) {
|
|
1335
|
-
const
|
|
1336
|
-
|
|
1337
|
-
try {
|
|
1338
|
-
const rows = handle.prepare(sql).all();
|
|
1339
|
-
const capped = rows.slice(0, SQL_ROW_CAP);
|
|
1340
|
-
const columns = capped.length ? Object.keys(capped[0]) : [];
|
|
1341
|
-
return envelope(columns, capped.map((r) => columns.map((c) => r[c])), rows.length > SQL_ROW_CAP);
|
|
1342
|
-
} finally {
|
|
1343
|
-
handle.close();
|
|
1344
|
-
}
|
|
1840
|
+
const result = await executeSqliteQuery(db.sqlite, sql, { signal: ctx.signal });
|
|
1841
|
+
return envelope(result.columns, result.rows, result.truncated);
|
|
1345
1842
|
}
|
|
1346
1843
|
let pg;
|
|
1347
1844
|
try {
|
|
@@ -1352,19 +1849,32 @@ var sqlQuery = async (args, ctx) => {
|
|
|
1352
1849
|
const Client = pg.Client ?? pg.default?.Client;
|
|
1353
1850
|
if (!Client) return toolError(`the installed "pg" module does not export a Client`);
|
|
1354
1851
|
const client = new Client({
|
|
1355
|
-
connectionString:
|
|
1852
|
+
connectionString: readFileSync4(db.postgresUrlFile, "utf8").trim(),
|
|
1356
1853
|
// never logged (D4)
|
|
1357
|
-
statement_timeout: SQL_TIMEOUT_MS
|
|
1854
|
+
statement_timeout: SQL_TIMEOUT_MS,
|
|
1855
|
+
connectionTimeoutMillis: SQL_TIMEOUT_MS,
|
|
1856
|
+
query_timeout: SQL_TIMEOUT_MS
|
|
1358
1857
|
});
|
|
1359
|
-
|
|
1858
|
+
let cancelled = ctx.signal?.aborted ?? false;
|
|
1859
|
+
const cancel = () => {
|
|
1860
|
+
cancelled = true;
|
|
1861
|
+
void client.end().catch(() => {
|
|
1862
|
+
});
|
|
1863
|
+
};
|
|
1864
|
+
if (cancelled) throw new Error("Postgres query cancelled");
|
|
1865
|
+
ctx.signal?.addEventListener("abort", cancel, { once: true });
|
|
1360
1866
|
try {
|
|
1867
|
+
await client.connect();
|
|
1868
|
+
if (cancelled) throw new Error("Postgres query cancelled");
|
|
1361
1869
|
await client.query("BEGIN; SET TRANSACTION READ ONLY;");
|
|
1362
1870
|
const res = await client.query({ text: sql, rowMode: "array" });
|
|
1871
|
+
if (cancelled) throw new Error("Postgres query cancelled");
|
|
1363
1872
|
const columns = res.fields.map((f) => f.name);
|
|
1364
1873
|
const rows = res.rows.slice(0, SQL_ROW_CAP);
|
|
1365
1874
|
return envelope(columns, rows, res.rows.length > SQL_ROW_CAP);
|
|
1366
1875
|
} finally {
|
|
1367
|
-
|
|
1876
|
+
ctx.signal?.removeEventListener("abort", cancel);
|
|
1877
|
+
if (!cancelled) await client.query("ROLLBACK").catch(() => {
|
|
1368
1878
|
});
|
|
1369
1879
|
await client.end().catch(() => {
|
|
1370
1880
|
});
|
|
@@ -1387,13 +1897,14 @@ var webSearch = async (args, ctx) => {
|
|
|
1387
1897
|
try {
|
|
1388
1898
|
let hits;
|
|
1389
1899
|
if (brave) {
|
|
1390
|
-
ctx.log("debug",
|
|
1900
|
+
ctx.log("debug", "tool.web_search_requested", { count, provider_brave: true });
|
|
1391
1901
|
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
|
|
1392
1902
|
const res = await safeFetch(url, {
|
|
1393
1903
|
resolve: ctx.resolve,
|
|
1394
1904
|
fetch: ctx.fetch,
|
|
1395
1905
|
timeoutMs: 1e4,
|
|
1396
1906
|
maxBytes: 2e6,
|
|
1907
|
+
signal: ctx.signal,
|
|
1397
1908
|
headers: { accept: "application/json", "X-Subscription-Token": brave }
|
|
1398
1909
|
});
|
|
1399
1910
|
if (res.status !== 200) return toolError(`brave search returned HTTP ${res.status}`);
|
|
@@ -1404,12 +1915,13 @@ var webSearch = async (args, ctx) => {
|
|
|
1404
1915
|
snippet: r.description ?? ""
|
|
1405
1916
|
}));
|
|
1406
1917
|
} else {
|
|
1407
|
-
ctx.log("debug",
|
|
1918
|
+
ctx.log("debug", "tool.web_search_requested", { count, provider_brave: false });
|
|
1408
1919
|
const res = await safeFetch("https://api.tavily.com/search", {
|
|
1409
1920
|
resolve: ctx.resolve,
|
|
1410
1921
|
fetch: ctx.fetch,
|
|
1411
1922
|
timeoutMs: 1e4,
|
|
1412
1923
|
maxBytes: 2e6,
|
|
1924
|
+
signal: ctx.signal,
|
|
1413
1925
|
method: "POST",
|
|
1414
1926
|
headers: { "content-type": "application/json" },
|
|
1415
1927
|
body: JSON.stringify({ api_key: tavily, query, max_results: count })
|
|
@@ -1432,14 +1944,70 @@ var webSearch = async (args, ctx) => {
|
|
|
1432
1944
|
var RESULT_CAP = 2e4;
|
|
1433
1945
|
var TRUNCATION_MARKER = "\u2026[truncated]";
|
|
1434
1946
|
var FETCH_TEXT_CAP = RESULT_CAP - 4096;
|
|
1947
|
+
function resultBytes(s) {
|
|
1948
|
+
return Buffer.byteLength(s, "utf8");
|
|
1949
|
+
}
|
|
1950
|
+
function utf8Prefix(s, maxBytes) {
|
|
1951
|
+
if (maxBytes <= 0) return "";
|
|
1952
|
+
const bytes = Buffer.from(s, "utf8");
|
|
1953
|
+
if (bytes.length <= maxBytes) return s;
|
|
1954
|
+
let end = maxBytes;
|
|
1955
|
+
while (end > 0) {
|
|
1956
|
+
const candidate = bytes.subarray(0, end).toString("utf8");
|
|
1957
|
+
if (!candidate.endsWith("\uFFFD") && resultBytes(candidate) <= maxBytes) return candidate;
|
|
1958
|
+
end--;
|
|
1959
|
+
}
|
|
1960
|
+
return "";
|
|
1961
|
+
}
|
|
1962
|
+
function fitJsonString(value, build) {
|
|
1963
|
+
const codePoints = Array.from(value);
|
|
1964
|
+
let low = 0;
|
|
1965
|
+
let high = codePoints.length;
|
|
1966
|
+
let best = build("");
|
|
1967
|
+
while (low <= high) {
|
|
1968
|
+
const middle = Math.floor((low + high) / 2);
|
|
1969
|
+
const candidate = build(codePoints.slice(0, middle).join(""));
|
|
1970
|
+
if (resultBytes(candidate) <= RESULT_CAP) {
|
|
1971
|
+
best = candidate;
|
|
1972
|
+
low = middle + 1;
|
|
1973
|
+
} else {
|
|
1974
|
+
high = middle - 1;
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
return best;
|
|
1978
|
+
}
|
|
1435
1979
|
function capTo(s, limit) {
|
|
1436
|
-
|
|
1980
|
+
if (resultBytes(s) <= limit) return s;
|
|
1981
|
+
const markerBytes = resultBytes(TRUNCATION_MARKER);
|
|
1982
|
+
return utf8Prefix(s, Math.max(0, limit - markerBytes)) + TRUNCATION_MARKER;
|
|
1437
1983
|
}
|
|
1438
1984
|
function capResult(s) {
|
|
1439
1985
|
return capTo(s, RESULT_CAP);
|
|
1440
1986
|
}
|
|
1987
|
+
function boundToolResult(content) {
|
|
1988
|
+
if (resultBytes(content) <= RESULT_CAP) return content;
|
|
1989
|
+
try {
|
|
1990
|
+
const parsed = JSON.parse(content);
|
|
1991
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1992
|
+
const record = parsed;
|
|
1993
|
+
if (typeof record.error === "string") {
|
|
1994
|
+
const message = typeof record.message === "string" ? record.message : record.error;
|
|
1995
|
+
return toolError(message);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
return fitJsonString(content, (result) => JSON.stringify({ result, truncated: true }));
|
|
1999
|
+
} catch {
|
|
2000
|
+
return capResult(content);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
1441
2003
|
function toolError(message) {
|
|
1442
|
-
|
|
2004
|
+
const plain = JSON.stringify({ error: "tool_failed", message });
|
|
2005
|
+
if (resultBytes(plain) <= RESULT_CAP) return plain;
|
|
2006
|
+
return fitJsonString(message, (prefix) => JSON.stringify({
|
|
2007
|
+
error: "tool_failed",
|
|
2008
|
+
message: prefix,
|
|
2009
|
+
truncated: true
|
|
2010
|
+
}));
|
|
1443
2011
|
}
|
|
1444
2012
|
var NO_CONTEXT = "no runtime context: this built-in needs the runtime's network context";
|
|
1445
2013
|
function offlineContext() {
|
|
@@ -1467,12 +2035,148 @@ var BUILTINS = {
|
|
|
1467
2035
|
sql_query: sqlQuery
|
|
1468
2036
|
};
|
|
1469
2037
|
|
|
2038
|
+
// src/side-effect-ledger.ts
|
|
2039
|
+
import { createHash } from "node:crypto";
|
|
2040
|
+
import { chmodSync, mkdirSync as mkdirSync2 } from "node:fs";
|
|
2041
|
+
import { dirname as dirname4 } from "node:path";
|
|
2042
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2043
|
+
var DEFAULT_SIDE_EFFECT_LEDGER_MAX_ENTRIES = 1e4;
|
|
2044
|
+
function canonicalJson(value) {
|
|
2045
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
2046
|
+
return JSON.stringify(value);
|
|
2047
|
+
}
|
|
2048
|
+
if (typeof value === "number") {
|
|
2049
|
+
if (!Number.isFinite(value)) throw new Error("operation arguments must be finite JSON values");
|
|
2050
|
+
return JSON.stringify(value);
|
|
2051
|
+
}
|
|
2052
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
2053
|
+
if (typeof value === "object") {
|
|
2054
|
+
const record = value;
|
|
2055
|
+
return `{${Object.keys(record).sort().map(
|
|
2056
|
+
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
|
2057
|
+
).join(",")}}`;
|
|
2058
|
+
}
|
|
2059
|
+
throw new Error("operation arguments must be JSON values");
|
|
2060
|
+
}
|
|
2061
|
+
function operationSha256(tool, args) {
|
|
2062
|
+
const normalized = tool === "http_request" && typeof args.method === "string" ? { ...args, method: args.method.toUpperCase() } : args;
|
|
2063
|
+
return createHash("sha256").update(`${tool}
|
|
2064
|
+
${canonicalJson(normalized)}`, "utf8").digest("hex");
|
|
2065
|
+
}
|
|
2066
|
+
var SideEffectLedger = class {
|
|
2067
|
+
constructor(path, maxEntries = DEFAULT_SIDE_EFFECT_LEDGER_MAX_ENTRIES) {
|
|
2068
|
+
this.maxEntries = maxEntries;
|
|
2069
|
+
mkdirSync2(dirname4(path), { recursive: true, mode: 448 });
|
|
2070
|
+
this.db = new DatabaseSync(path);
|
|
2071
|
+
chmodSync(path, 384);
|
|
2072
|
+
this.db.exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA busy_timeout=1000;");
|
|
2073
|
+
this.db.exec(`
|
|
2074
|
+
CREATE TABLE IF NOT EXISTS side_effect_claims (
|
|
2075
|
+
idempotency_key TEXT PRIMARY KEY,
|
|
2076
|
+
operation_sha256 TEXT NOT NULL,
|
|
2077
|
+
confirmation_id TEXT NOT NULL UNIQUE,
|
|
2078
|
+
expires_at TEXT NOT NULL,
|
|
2079
|
+
state TEXT NOT NULL CHECK (state IN ('in_progress', 'completed')),
|
|
2080
|
+
created_at TEXT NOT NULL,
|
|
2081
|
+
completed_at TEXT
|
|
2082
|
+
) STRICT
|
|
2083
|
+
`);
|
|
2084
|
+
}
|
|
2085
|
+
db;
|
|
2086
|
+
claim(grant, now) {
|
|
2087
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2088
|
+
try {
|
|
2089
|
+
const prior = this.db.prepare(`
|
|
2090
|
+
SELECT idempotency_key, operation_sha256, confirmation_id, state
|
|
2091
|
+
FROM side_effect_claims WHERE idempotency_key = ? OR confirmation_id = ?
|
|
2092
|
+
`).get(grant.idempotency_key, grant.confirmation_id);
|
|
2093
|
+
if (prior) {
|
|
2094
|
+
this.db.exec("COMMIT");
|
|
2095
|
+
if (prior.operation_sha256 !== grant.operation_sha256 || prior.confirmation_id !== grant.confirmation_id || prior.idempotency_key !== grant.idempotency_key) return { kind: "conflict" };
|
|
2096
|
+
return { kind: prior.state === "completed" ? "completed" : "ambiguous" };
|
|
2097
|
+
}
|
|
2098
|
+
const count = this.db.prepare("SELECT COUNT(*) AS count FROM side_effect_claims").get();
|
|
2099
|
+
if (count.count >= this.maxEntries) {
|
|
2100
|
+
this.db.exec("COMMIT");
|
|
2101
|
+
return { kind: "full" };
|
|
2102
|
+
}
|
|
2103
|
+
this.db.prepare(`
|
|
2104
|
+
INSERT INTO side_effect_claims
|
|
2105
|
+
(idempotency_key, operation_sha256, confirmation_id, expires_at, state, created_at)
|
|
2106
|
+
VALUES (?, ?, ?, ?, 'in_progress', ?)
|
|
2107
|
+
`).run(
|
|
2108
|
+
grant.idempotency_key,
|
|
2109
|
+
grant.operation_sha256,
|
|
2110
|
+
grant.confirmation_id,
|
|
2111
|
+
grant.expires_at,
|
|
2112
|
+
now.toISOString()
|
|
2113
|
+
);
|
|
2114
|
+
this.db.exec("COMMIT");
|
|
2115
|
+
return { kind: "execute" };
|
|
2116
|
+
} catch (error) {
|
|
2117
|
+
try {
|
|
2118
|
+
this.db.exec("ROLLBACK");
|
|
2119
|
+
} catch {
|
|
2120
|
+
}
|
|
2121
|
+
throw error;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
complete(grant, now) {
|
|
2125
|
+
const result = this.db.prepare(`
|
|
2126
|
+
UPDATE side_effect_claims SET state = 'completed', completed_at = ?
|
|
2127
|
+
WHERE idempotency_key = ? AND operation_sha256 = ? AND confirmation_id = ?
|
|
2128
|
+
AND state = 'in_progress'
|
|
2129
|
+
`).run(now.toISOString(), grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
|
|
2130
|
+
if (result.changes !== 1) throw new Error("side-effect claim could not be completed");
|
|
2131
|
+
}
|
|
2132
|
+
/** Content-free inventory for an operator investigating an interrupted effect. */
|
|
2133
|
+
listAmbiguous() {
|
|
2134
|
+
return this.db.prepare(`
|
|
2135
|
+
SELECT idempotency_key, operation_sha256, confirmation_id, expires_at, created_at
|
|
2136
|
+
FROM side_effect_claims WHERE state='in_progress' ORDER BY created_at, idempotency_key
|
|
2137
|
+
`).all();
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Record the operator's external reconciliation result. `completed` prevents
|
|
2141
|
+
* replay; `not_applied` removes the pre-effect claim so the exact confirmed
|
|
2142
|
+
* operation may be retried. Every binding field must match the durable row.
|
|
2143
|
+
*/
|
|
2144
|
+
reconcile(grant, outcome, now) {
|
|
2145
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2146
|
+
try {
|
|
2147
|
+
const row = this.db.prepare(`
|
|
2148
|
+
SELECT state FROM side_effect_claims
|
|
2149
|
+
WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=?
|
|
2150
|
+
`).get(grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
|
|
2151
|
+
if (!row) throw new Error("ambiguous side-effect claim not found or binding mismatch");
|
|
2152
|
+
if (row.state !== "in_progress") throw new Error("side-effect claim is already completed");
|
|
2153
|
+
if (outcome === "completed") {
|
|
2154
|
+
this.db.prepare(`
|
|
2155
|
+
UPDATE side_effect_claims SET state='completed', completed_at=?
|
|
2156
|
+
WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=? AND state='in_progress'
|
|
2157
|
+
`).run(now.toISOString(), grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
|
|
2158
|
+
} else {
|
|
2159
|
+
this.db.prepare(`
|
|
2160
|
+
DELETE FROM side_effect_claims
|
|
2161
|
+
WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=? AND state='in_progress'
|
|
2162
|
+
`).run(grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
|
|
2163
|
+
}
|
|
2164
|
+
this.db.exec("COMMIT");
|
|
2165
|
+
} catch (error) {
|
|
2166
|
+
try {
|
|
2167
|
+
this.db.exec("ROLLBACK");
|
|
2168
|
+
} catch {
|
|
2169
|
+
}
|
|
2170
|
+
throw error;
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
close() {
|
|
2174
|
+
this.db.close();
|
|
2175
|
+
}
|
|
2176
|
+
};
|
|
2177
|
+
|
|
1470
2178
|
// src/skill-invoker.ts
|
|
1471
2179
|
var SkillInvoker = class {
|
|
1472
|
-
skills = /* @__PURE__ */ new Map();
|
|
1473
|
-
sources = /* @__PURE__ */ new Map();
|
|
1474
|
-
skillsDir;
|
|
1475
|
-
ctx;
|
|
1476
2180
|
/**
|
|
1477
2181
|
* `ctx` carries everything a built-in may touch: the network, DNS, the clock
|
|
1478
2182
|
* and the key files. A caller that has no runtime (the loader tests, and any
|
|
@@ -1480,10 +2184,15 @@ var SkillInvoker = class {
|
|
|
1480
2184
|
* that need nothing still run and the ones that need the network fail as a
|
|
1481
2185
|
* tool_failed result instead of throwing.
|
|
1482
2186
|
*/
|
|
1483
|
-
constructor(skillsDir, ctx) {
|
|
2187
|
+
constructor(skillsDir, ctx, sideEffectLedger) {
|
|
2188
|
+
this.sideEffectLedger = sideEffectLedger;
|
|
1484
2189
|
this.skillsDir = skillsDir;
|
|
1485
2190
|
this.ctx = ctx ?? offlineContext();
|
|
1486
2191
|
}
|
|
2192
|
+
skills = /* @__PURE__ */ new Map();
|
|
2193
|
+
sources = /* @__PURE__ */ new Map();
|
|
2194
|
+
skillsDir;
|
|
2195
|
+
ctx;
|
|
1487
2196
|
/**
|
|
1488
2197
|
* Load all skill manifests from the skills directory. Supports two
|
|
1489
2198
|
* layouts: flat `<skillsDir>/*.json` files, and one level of nesting via
|
|
@@ -1491,7 +2200,7 @@ var SkillInvoker = class {
|
|
|
1491
2200
|
*/
|
|
1492
2201
|
loadSkills() {
|
|
1493
2202
|
if (!existsSync3(this.skillsDir)) {
|
|
1494
|
-
|
|
2203
|
+
writeServiceLog("warn", "skills.directory_missing");
|
|
1495
2204
|
return;
|
|
1496
2205
|
}
|
|
1497
2206
|
const flat = [], manifests = [], skillMds = [];
|
|
@@ -1507,40 +2216,38 @@ var SkillInvoker = class {
|
|
|
1507
2216
|
}
|
|
1508
2217
|
for (const p of [...flat.sort(), ...manifests.sort()]) this.loadManifestFile(p);
|
|
1509
2218
|
for (const p of skillMds.sort()) this.loadSkillMdFile(p);
|
|
1510
|
-
|
|
2219
|
+
writeServiceLog("info", "skills.loaded", { count: this.skills.size });
|
|
1511
2220
|
}
|
|
1512
2221
|
/** Later file wins, with a warning naming both paths (spec §3). */
|
|
1513
2222
|
register(manifest, path) {
|
|
1514
2223
|
const prior = this.sources.get(manifest.name);
|
|
1515
|
-
if (prior)
|
|
2224
|
+
if (prior) writeServiceLog("warn", "skills.duplicate_replaced");
|
|
1516
2225
|
this.skills.set(manifest.name, manifest);
|
|
1517
2226
|
this.sources.set(manifest.name, path);
|
|
1518
2227
|
}
|
|
1519
2228
|
loadSkillMdFile(path) {
|
|
1520
2229
|
try {
|
|
1521
|
-
this.register(parseSkillMd(
|
|
2230
|
+
this.register(parseSkillMd(readFileSync5(path, "utf-8"), path), path);
|
|
1522
2231
|
} catch (err) {
|
|
1523
|
-
|
|
2232
|
+
writeServiceLog("warn", "skills.skill_md_invalid", {}, err);
|
|
1524
2233
|
}
|
|
1525
2234
|
}
|
|
1526
2235
|
loadManifestFile(path) {
|
|
1527
2236
|
try {
|
|
1528
|
-
const raw =
|
|
2237
|
+
const raw = readFileSync5(path, "utf-8");
|
|
1529
2238
|
const manifest = JSON.parse(raw);
|
|
1530
2239
|
if (typeof manifest.handler !== "string" || !(manifest.handler.startsWith("builtin:") || manifest.handler.startsWith("script:"))) {
|
|
1531
|
-
|
|
1532
|
-
`[SkillInvoker] Skipping manifest with invalid handler ${path}: expected "builtin:<name>" or "script:<path>"`
|
|
1533
|
-
);
|
|
2240
|
+
writeServiceLog("warn", "skills.manifest_handler_invalid");
|
|
1534
2241
|
return;
|
|
1535
2242
|
}
|
|
1536
2243
|
if (manifest.name && manifest.description) {
|
|
1537
2244
|
this.register(manifest, path);
|
|
1538
2245
|
} else {
|
|
1539
2246
|
const missing = !manifest.name ? "name" : "description";
|
|
1540
|
-
|
|
2247
|
+
writeServiceLog("warn", "skills.manifest_field_missing", { name_missing: missing === "name" });
|
|
1541
2248
|
}
|
|
1542
2249
|
} catch (err) {
|
|
1543
|
-
|
|
2250
|
+
writeServiceLog("warn", "skills.manifest_load_failed", {}, err);
|
|
1544
2251
|
}
|
|
1545
2252
|
}
|
|
1546
2253
|
/** Convert loaded skills to OpenAI tool definitions for LLM dispatch. */
|
|
@@ -1580,27 +2287,76 @@ var SkillInvoker = class {
|
|
|
1580
2287
|
async invoke(name, args, context) {
|
|
1581
2288
|
const skill = this.skills.get(name);
|
|
1582
2289
|
if (!skill) {
|
|
1583
|
-
return JSON.stringify({ error: `Unknown skill: ${name}` });
|
|
2290
|
+
return boundToolResult(JSON.stringify({ error: `Unknown skill: ${name}` }));
|
|
2291
|
+
}
|
|
2292
|
+
const execute = async () => {
|
|
2293
|
+
const handler = skill.handler;
|
|
2294
|
+
if (handler.startsWith("builtin:")) {
|
|
2295
|
+
return this.invokeBuiltin(handler.slice(8), args, context?.signal);
|
|
2296
|
+
}
|
|
2297
|
+
if (handler.startsWith("script:")) {
|
|
2298
|
+
return this.invokeScript(handler.slice(7), args, context?.signal);
|
|
2299
|
+
}
|
|
2300
|
+
return boundToolResult(JSON.stringify({ error: `Unknown handler type: ${handler}` }));
|
|
2301
|
+
};
|
|
2302
|
+
return context?.sideEffect ? this.invokeSideEffect(name, args, context.sideEffect, execute) : execute();
|
|
2303
|
+
}
|
|
2304
|
+
async invokeSideEffect(name, args, grant, execute) {
|
|
2305
|
+
const method = typeof args.method === "string" ? args.method.toUpperCase() : "GET";
|
|
2306
|
+
if (name !== "http_request" || !(/* @__PURE__ */ new Set(["POST", "PUT", "DELETE"])).has(method)) {
|
|
2307
|
+
return toolError("This tool has no supported side-effect replay contract");
|
|
2308
|
+
}
|
|
2309
|
+
if (grant.tool !== name || operationSha256(name, args) !== grant.operation_sha256) {
|
|
2310
|
+
return toolError("Confirmed operation digest does not match this tool call");
|
|
2311
|
+
}
|
|
2312
|
+
const now = this.ctx.now();
|
|
2313
|
+
const expiresAt = Date.parse(grant.expires_at);
|
|
2314
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now.getTime()) {
|
|
2315
|
+
return toolError("Confirmed operation grant is expired or invalid");
|
|
2316
|
+
}
|
|
2317
|
+
if (!this.sideEffectLedger) {
|
|
2318
|
+
return toolError("Side-effect replay protection is unavailable");
|
|
2319
|
+
}
|
|
2320
|
+
let claim;
|
|
2321
|
+
try {
|
|
2322
|
+
claim = this.sideEffectLedger.claim(grant, now);
|
|
2323
|
+
} catch {
|
|
2324
|
+
return toolError("Side-effect replay claim could not be persisted");
|
|
1584
2325
|
}
|
|
1585
|
-
|
|
1586
|
-
if (
|
|
1587
|
-
return
|
|
2326
|
+
if (claim.kind === "completed") return JSON.stringify({ status: "already_applied" });
|
|
2327
|
+
if (claim.kind === "ambiguous") {
|
|
2328
|
+
return toolError("Side-effect outcome is ambiguous; reconcile it before retrying");
|
|
1588
2329
|
}
|
|
1589
|
-
if (
|
|
1590
|
-
return
|
|
2330
|
+
if (claim.kind === "conflict") {
|
|
2331
|
+
return toolError("Idempotency key was already bound to a different confirmed operation");
|
|
1591
2332
|
}
|
|
1592
|
-
|
|
2333
|
+
if (claim.kind === "full") {
|
|
2334
|
+
return toolError("Side-effect replay ledger is full; reconcile it before running new operations");
|
|
2335
|
+
}
|
|
2336
|
+
let result;
|
|
2337
|
+
try {
|
|
2338
|
+
result = await execute();
|
|
2339
|
+
} catch {
|
|
2340
|
+
return toolError("Side-effect outcome is ambiguous; reconcile it before retrying");
|
|
2341
|
+
}
|
|
2342
|
+
if (isToolFailure(result)) return result;
|
|
2343
|
+
try {
|
|
2344
|
+
this.sideEffectLedger.complete(grant, this.ctx.now());
|
|
2345
|
+
} catch {
|
|
2346
|
+
return toolError("Side-effect completed but replay state is ambiguous; reconcile before retrying");
|
|
2347
|
+
}
|
|
2348
|
+
return result;
|
|
1593
2349
|
}
|
|
1594
|
-
async invokeBuiltin(name, args) {
|
|
2350
|
+
async invokeBuiltin(name, args, signal) {
|
|
1595
2351
|
const builtin = BUILTINS[name];
|
|
1596
2352
|
if (!builtin) return JSON.stringify({ error: `Unknown builtin: ${name}` });
|
|
1597
2353
|
try {
|
|
1598
|
-
return
|
|
2354
|
+
return boundToolResult(await builtin(args, { ...this.ctx, signal }));
|
|
1599
2355
|
} catch (e) {
|
|
1600
2356
|
return toolError(e instanceof Error ? e.message : String(e));
|
|
1601
2357
|
}
|
|
1602
2358
|
}
|
|
1603
|
-
async invokeScript(scriptPath, args) {
|
|
2359
|
+
async invokeScript(scriptPath, args, signal) {
|
|
1604
2360
|
const { resolve: resolve3, relative } = await import("node:path");
|
|
1605
2361
|
const resolved = resolve3(this.skillsDir, scriptPath);
|
|
1606
2362
|
const rel = relative(this.skillsDir, resolved);
|
|
@@ -1620,26 +2376,82 @@ var SkillInvoker = class {
|
|
|
1620
2376
|
try {
|
|
1621
2377
|
accessSync2(resolved, constants2.X_OK);
|
|
1622
2378
|
} catch {
|
|
1623
|
-
return
|
|
2379
|
+
return toolError(`Script not found or not executable: ${scriptPath}`);
|
|
1624
2380
|
}
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
timeout: 3e4,
|
|
1630
|
-
maxBuffer: 1024 * 1024,
|
|
1631
|
-
encoding: "utf-8",
|
|
2381
|
+
if (signal?.aborted) return toolError("Script execution cancelled");
|
|
2382
|
+
const { spawn: spawn2 } = await import("node:child_process");
|
|
2383
|
+
return new Promise((resolveResult) => {
|
|
2384
|
+
const child = spawn2(resolved, [], {
|
|
1632
2385
|
cwd: this.skillsDir,
|
|
1633
|
-
env: { PATH: "/usr/bin:/bin", HOME: "/tmp" }
|
|
2386
|
+
env: { PATH: "/usr/bin:/bin", HOME: "/tmp" },
|
|
2387
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
2388
|
+
windowsHide: true,
|
|
2389
|
+
// A script may launch children. On POSIX, make one process group so
|
|
2390
|
+
// cancellation kills the whole operation rather than orphaning them.
|
|
2391
|
+
detached: process.platform !== "win32"
|
|
1634
2392
|
});
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
2393
|
+
let stdout = "";
|
|
2394
|
+
let stdoutBytes = 0;
|
|
2395
|
+
let stopMessage = null;
|
|
2396
|
+
let settled = false;
|
|
2397
|
+
const cleanup = () => {
|
|
2398
|
+
clearTimeout(timer);
|
|
2399
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2400
|
+
};
|
|
2401
|
+
const stop = (message) => {
|
|
2402
|
+
if (settled || stopMessage) return;
|
|
2403
|
+
stopMessage = message;
|
|
2404
|
+
if (process.platform !== "win32" && child.pid) {
|
|
2405
|
+
try {
|
|
2406
|
+
process.kill(-child.pid, "SIGKILL");
|
|
2407
|
+
} catch {
|
|
2408
|
+
child.kill("SIGKILL");
|
|
2409
|
+
}
|
|
2410
|
+
} else {
|
|
2411
|
+
child.kill("SIGKILL");
|
|
2412
|
+
}
|
|
2413
|
+
};
|
|
2414
|
+
const onAbort = () => stop("Script execution cancelled");
|
|
2415
|
+
const timer = setTimeout(() => stop("Script execution timed out after 30000ms"), 3e4);
|
|
2416
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2417
|
+
child.stdout.setEncoding("utf8");
|
|
2418
|
+
child.stdout.on("data", (chunk) => {
|
|
2419
|
+
if (stopMessage) return;
|
|
2420
|
+
stdoutBytes += Buffer.byteLength(chunk);
|
|
2421
|
+
if (stdoutBytes > 1024 * 1024) {
|
|
2422
|
+
stop("Script output exceeded the materialization limit");
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
stdout += chunk;
|
|
1639
2426
|
});
|
|
1640
|
-
|
|
2427
|
+
child.once("error", () => {
|
|
2428
|
+
if (settled) return;
|
|
2429
|
+
settled = true;
|
|
2430
|
+
cleanup();
|
|
2431
|
+
resolveResult(toolError("Script execution failed"));
|
|
2432
|
+
});
|
|
2433
|
+
child.once("close", (code) => {
|
|
2434
|
+
if (settled) return;
|
|
2435
|
+
settled = true;
|
|
2436
|
+
cleanup();
|
|
2437
|
+
if (stopMessage) resolveResult(toolError(stopMessage));
|
|
2438
|
+
else if (code !== 0) resolveResult(toolError(`Script execution failed (code ${code ?? "unknown"})`));
|
|
2439
|
+
else resolveResult(boundToolResult(stdout));
|
|
2440
|
+
});
|
|
2441
|
+
child.stdin.on("error", () => {
|
|
2442
|
+
});
|
|
2443
|
+
child.stdin.end(JSON.stringify(args));
|
|
2444
|
+
});
|
|
1641
2445
|
}
|
|
1642
2446
|
};
|
|
2447
|
+
function isToolFailure(result) {
|
|
2448
|
+
try {
|
|
2449
|
+
const parsed = JSON.parse(result);
|
|
2450
|
+
return parsed.error === "tool_failed";
|
|
2451
|
+
} catch {
|
|
2452
|
+
return false;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
1643
2455
|
|
|
1644
2456
|
// src/tool-gate.ts
|
|
1645
2457
|
function createLocalToolGate(policy) {
|
|
@@ -1688,7 +2500,7 @@ function createDynamicToolGate(policyFor) {
|
|
|
1688
2500
|
}
|
|
1689
2501
|
|
|
1690
2502
|
// src/data-plane.ts
|
|
1691
|
-
import { readFileSync as
|
|
2503
|
+
import { readFileSync as readFileSync6 } from "node:fs";
|
|
1692
2504
|
import { WebSocket } from "node:http";
|
|
1693
2505
|
import { homedir as homedir3 } from "node:os";
|
|
1694
2506
|
import { join as join5 } from "node:path";
|
|
@@ -1733,7 +2545,7 @@ var NO_TURN_AUTHORIZATION = Object.freeze({
|
|
|
1733
2545
|
function parseTurnAuthorization(value) {
|
|
1734
2546
|
if (!value || typeof value !== "object" || Array.isArray(value)) return NO_TURN_AUTHORIZATION;
|
|
1735
2547
|
const raw = value;
|
|
1736
|
-
if (raw.version !== 1 || typeof raw.audience !== "string" || !AUDIENCES.has(raw.audience) || raw.sender_membership !== "active" && raw.sender_membership !== "unknown" || !Array.isArray(raw.tool_grants)) return NO_TURN_AUTHORIZATION;
|
|
2548
|
+
if (raw.version !== 1 || typeof raw.audience !== "string" || !AUDIENCES.has(raw.audience) || raw.sender_membership !== "active" && raw.sender_membership !== "unknown" || !Array.isArray(raw.tool_grants) || raw.tool_grants.length > 100) return NO_TURN_AUTHORIZATION;
|
|
1737
2549
|
const grants = [];
|
|
1738
2550
|
for (const value2 of raw.tool_grants) {
|
|
1739
2551
|
if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) return NO_TURN_AUTHORIZATION;
|
|
@@ -1743,7 +2555,7 @@ function parseTurnAuthorization(value) {
|
|
|
1743
2555
|
grants.push(Object.freeze({ tool: grant.tool, mode: "read" }));
|
|
1744
2556
|
continue;
|
|
1745
2557
|
}
|
|
1746
|
-
if (grant.mode !== "side_effect" || typeof grant.operation_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(grant.operation_sha256) || typeof grant.confirmation_id !== "string" || !grant.confirmation_id || typeof grant.idempotency_key !== "string" || !grant.idempotency_key || typeof grant.expires_at !== "string" || !grant.expires_at) return NO_TURN_AUTHORIZATION;
|
|
2558
|
+
if (grant.mode !== "side_effect" || typeof grant.operation_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(grant.operation_sha256) || typeof grant.confirmation_id !== "string" || !grant.confirmation_id || grant.confirmation_id.length > 200 || typeof grant.idempotency_key !== "string" || !grant.idempotency_key || grant.idempotency_key.length > 200 || typeof grant.expires_at !== "string" || !grant.expires_at || grant.expires_at.length > 40) return NO_TURN_AUTHORIZATION;
|
|
1747
2559
|
grants.push(Object.freeze({
|
|
1748
2560
|
tool: grant.tool,
|
|
1749
2561
|
mode: "side_effect",
|
|
@@ -1761,9 +2573,7 @@ function parseTurnAuthorization(value) {
|
|
|
1761
2573
|
});
|
|
1762
2574
|
}
|
|
1763
2575
|
var SEEN_MAX = 5e3;
|
|
1764
|
-
|
|
1765
|
-
return e instanceof Error ? e.message : String(e);
|
|
1766
|
-
}
|
|
2576
|
+
var TERMINAL_REPLY_ERRORS = /* @__PURE__ */ new Set(["agent_paused", "send_forbidden"]);
|
|
1767
2577
|
var SecureComsDataPlane = class {
|
|
1768
2578
|
identityId;
|
|
1769
2579
|
connectionPath;
|
|
@@ -1773,7 +2583,18 @@ var SecureComsDataPlane = class {
|
|
|
1773
2583
|
log;
|
|
1774
2584
|
sleep;
|
|
1775
2585
|
random;
|
|
2586
|
+
healthTimeoutMs;
|
|
1776
2587
|
onMessage;
|
|
2588
|
+
noticeReplies;
|
|
2589
|
+
/**
|
|
2590
|
+
* Final-wave I1(b): sockets whose daemon advertised the `nack` feature. On
|
|
2591
|
+
* those a failed turn is NACKED — only that message is retried, behind the
|
|
2592
|
+
* daemon's backoff, and the socket (with every other channel's turn) stays
|
|
2593
|
+
* up. Anywhere else a failed turn still closes the socket, as before.
|
|
2594
|
+
*/
|
|
2595
|
+
nackCapable = /* @__PURE__ */ new WeakSet();
|
|
2596
|
+
/** Sockets whose hello is unanswered: a refusal there is expected, not an error. */
|
|
2597
|
+
helloPending = /* @__PURE__ */ new WeakSet();
|
|
1777
2598
|
seen = /* @__PURE__ */ new Set();
|
|
1778
2599
|
stopped = false;
|
|
1779
2600
|
running = false;
|
|
@@ -1800,6 +2621,16 @@ var SecureComsDataPlane = class {
|
|
|
1800
2621
|
* generation is current, so a restart can never end up with two live loops.
|
|
1801
2622
|
*/
|
|
1802
2623
|
generation = 0;
|
|
2624
|
+
/** Turn controllers grouped by the socket whose result would receive them. */
|
|
2625
|
+
turns = /* @__PURE__ */ new Map();
|
|
2626
|
+
/**
|
|
2627
|
+
* Phase 3 F2: per-channel processing chains. A channel's messages are
|
|
2628
|
+
* handled strictly one after another in arrival order (the receipt lookup,
|
|
2629
|
+
* the turn and the reply), never concurrently — even when a daemon flushes a
|
|
2630
|
+
* whole backlog at once. Different channels still run alongside each other.
|
|
2631
|
+
*/
|
|
2632
|
+
channelChains = /* @__PURE__ */ new Map();
|
|
2633
|
+
healthController = null;
|
|
1803
2634
|
constructor(cfg, onMessage) {
|
|
1804
2635
|
this.identityId = cfg.identityId;
|
|
1805
2636
|
const dir = cfg.connectionsDir ?? join5(homedir3(), ".securecoms", "connections");
|
|
@@ -1807,10 +2638,7 @@ var SecureComsDataPlane = class {
|
|
|
1807
2638
|
this.establishedMs = cfg.establishedMs ?? 5e3;
|
|
1808
2639
|
this.initialDelayMs = cfg.initialDelayMs ?? 250;
|
|
1809
2640
|
this.maxDelayMs = cfg.maxDelayMs ?? 3e4;
|
|
1810
|
-
this.log = cfg.log ??
|
|
1811
|
-
if (level === "info") console.log(msg);
|
|
1812
|
-
else console.error(msg);
|
|
1813
|
-
});
|
|
2641
|
+
this.log = cfg.log ?? writeServiceLog;
|
|
1814
2642
|
this.sleep = cfg.sleep ?? ((ms) => new Promise((resolve3) => {
|
|
1815
2643
|
this.sleepTimer = setTimeout(() => {
|
|
1816
2644
|
this.sleepTimer = null;
|
|
@@ -1818,7 +2646,9 @@ var SecureComsDataPlane = class {
|
|
|
1818
2646
|
}, ms);
|
|
1819
2647
|
}));
|
|
1820
2648
|
this.random = cfg.random ?? Math.random;
|
|
2649
|
+
this.healthTimeoutMs = cfg.healthTimeoutMs ?? 2e3;
|
|
1821
2650
|
this.onMessage = onMessage;
|
|
2651
|
+
this.noticeReplies = cfg.noticeReplies ?? /* @__PURE__ */ new Set();
|
|
1822
2652
|
}
|
|
1823
2653
|
/**
|
|
1824
2654
|
* Resolves once the reconnect loop is running — NOT once attached. The loop
|
|
@@ -1837,13 +2667,19 @@ var SecureComsDataPlane = class {
|
|
|
1837
2667
|
this.generation++;
|
|
1838
2668
|
this.clearSettle();
|
|
1839
2669
|
this.cancelSleep();
|
|
2670
|
+
this.healthController?.abort(new Error("transport stopped"));
|
|
2671
|
+
this.healthController = null;
|
|
2672
|
+
for (const controllers of this.turns.values()) {
|
|
2673
|
+
for (const controller of controllers) controller.abort(new Error("transport stopped"));
|
|
2674
|
+
}
|
|
2675
|
+
this.turns.clear();
|
|
1840
2676
|
const ws = this.socket;
|
|
1841
2677
|
this.socket = null;
|
|
1842
2678
|
if (ws) {
|
|
1843
2679
|
try {
|
|
1844
2680
|
ws.close();
|
|
1845
2681
|
} catch (e) {
|
|
1846
|
-
this.log("warn",
|
|
2682
|
+
this.log("warn", "transport.socket_close_failed", {}, e);
|
|
1847
2683
|
}
|
|
1848
2684
|
}
|
|
1849
2685
|
}
|
|
@@ -1897,7 +2733,7 @@ var SecureComsDataPlane = class {
|
|
|
1897
2733
|
await this.attemptOnce();
|
|
1898
2734
|
delay = this.initialDelayMs;
|
|
1899
2735
|
} catch (e) {
|
|
1900
|
-
this.log("error",
|
|
2736
|
+
this.log("error", "transport.connection_attempt_failed", {}, e);
|
|
1901
2737
|
}
|
|
1902
2738
|
if (!live()) break;
|
|
1903
2739
|
await this.wait(this.random() * delay);
|
|
@@ -1906,10 +2742,22 @@ var SecureComsDataPlane = class {
|
|
|
1906
2742
|
if (this.generation === generation) this.running = false;
|
|
1907
2743
|
}
|
|
1908
2744
|
async attemptOnce() {
|
|
1909
|
-
const conn = JSON.parse(
|
|
2745
|
+
const conn = JSON.parse(readFileSync6(this.connectionPath, "utf8"));
|
|
1910
2746
|
const httpBase = conn.bridge_url.replace(/^ws/, "http").replace(/\/v1\/ws$/, "");
|
|
1911
|
-
const token =
|
|
1912
|
-
const
|
|
2747
|
+
const token = readFileSync6(conn.token_file, "utf8").trim();
|
|
2748
|
+
const healthController = new AbortController();
|
|
2749
|
+
this.healthController = healthController;
|
|
2750
|
+
const healthTimer = setTimeout(
|
|
2751
|
+
() => healthController.abort(new Error("health gate deadline exceeded")),
|
|
2752
|
+
this.healthTimeoutMs
|
|
2753
|
+
);
|
|
2754
|
+
let health;
|
|
2755
|
+
try {
|
|
2756
|
+
health = await (await fetch(`${httpBase}/healthz`, { signal: healthController.signal })).json();
|
|
2757
|
+
} finally {
|
|
2758
|
+
clearTimeout(healthTimer);
|
|
2759
|
+
if (this.healthController === healthController) this.healthController = null;
|
|
2760
|
+
}
|
|
1913
2761
|
if (conn.identity_id !== health.identity_id || this.identityId !== health.identity_id) {
|
|
1914
2762
|
throw new Error(
|
|
1915
2763
|
`gate refused: expected ${this.identityId}, connection file says ${conn.identity_id}, daemon on this port is ${health.identity_id}`
|
|
@@ -1923,7 +2771,7 @@ var SecureComsDataPlane = class {
|
|
|
1923
2771
|
try {
|
|
1924
2772
|
await new Promise((resolve3, reject) => {
|
|
1925
2773
|
ws.addEventListener("error", (e) => {
|
|
1926
|
-
reject(new Error(
|
|
2774
|
+
reject(Object.assign(new Error("socket_error"), { cause: e }));
|
|
1927
2775
|
});
|
|
1928
2776
|
ws.addEventListener("close", (e) => {
|
|
1929
2777
|
if (established) resolve3();
|
|
@@ -1936,19 +2784,29 @@ var SecureComsDataPlane = class {
|
|
|
1936
2784
|
}
|
|
1937
2785
|
});
|
|
1938
2786
|
ws.addEventListener("open", () => {
|
|
2787
|
+
this.helloPending.add(ws);
|
|
2788
|
+
try {
|
|
2789
|
+
ws.send(JSON.stringify({ type: "hello", features: ["nack"] }));
|
|
2790
|
+
} catch {
|
|
2791
|
+
}
|
|
1939
2792
|
settle = setTimeout(() => {
|
|
1940
2793
|
established = true;
|
|
1941
|
-
this.log("info",
|
|
2794
|
+
this.log("info", "transport.attached");
|
|
1942
2795
|
}, this.establishedMs);
|
|
1943
2796
|
this.settle = settle;
|
|
1944
2797
|
});
|
|
1945
2798
|
ws.addEventListener("message", (ev) => {
|
|
1946
|
-
void this.handleFrame(ws, ev.data).catch((e) => {
|
|
1947
|
-
this.log("error",
|
|
2799
|
+
void this.handleFrame(ws, ev.data, httpBase, token).catch((e) => {
|
|
2800
|
+
this.log("error", "transport.frame_handling_failed", {}, e);
|
|
1948
2801
|
});
|
|
1949
2802
|
});
|
|
1950
2803
|
});
|
|
1951
2804
|
} finally {
|
|
2805
|
+
const controllers = this.turns.get(ws);
|
|
2806
|
+
if (controllers) {
|
|
2807
|
+
for (const controller of controllers) controller.abort(new Error("transport disconnected"));
|
|
2808
|
+
this.turns.delete(ws);
|
|
2809
|
+
}
|
|
1952
2810
|
if (settle !== null) clearTimeout(settle);
|
|
1953
2811
|
if (this.settle === settle) this.settle = null;
|
|
1954
2812
|
try {
|
|
@@ -1958,28 +2816,101 @@ var SecureComsDataPlane = class {
|
|
|
1958
2816
|
if (this.socket === ws) this.socket = null;
|
|
1959
2817
|
}
|
|
1960
2818
|
}
|
|
1961
|
-
|
|
2819
|
+
/**
|
|
2820
|
+
* Final-wave I1(b): fail ONE message. Against a nack-capable daemon, send a
|
|
2821
|
+
* `nack` — only this message is retried and the socket stays up for every
|
|
2822
|
+
* other channel's turn. Otherwise close the socket, the only failure
|
|
2823
|
+
* signal an older daemon understands (it re-buffers what was in flight).
|
|
2824
|
+
*/
|
|
2825
|
+
failTurn(ws, messageId, reason) {
|
|
2826
|
+
if (this.nackCapable.has(ws) && ws.readyState === ws.OPEN) {
|
|
2827
|
+
try {
|
|
2828
|
+
ws.send(JSON.stringify({ type: "nack", message_id: messageId, reason }));
|
|
2829
|
+
return;
|
|
2830
|
+
} catch {
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
try {
|
|
2834
|
+
ws.close();
|
|
2835
|
+
} catch {
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
async handleFrame(ws, data, httpBase, token) {
|
|
1962
2839
|
let frame;
|
|
1963
2840
|
try {
|
|
1964
2841
|
frame = JSON.parse(String(data));
|
|
1965
2842
|
} catch (e) {
|
|
1966
|
-
this.log("error",
|
|
2843
|
+
this.log("error", "transport.frame_parse_failed", {}, e);
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
if (frame?.type === "hello") {
|
|
2847
|
+
this.helloPending.delete(ws);
|
|
2848
|
+
if (Array.isArray(frame.features) && frame.features.includes("nack")) this.nackCapable.add(ws);
|
|
1967
2849
|
return;
|
|
1968
2850
|
}
|
|
1969
2851
|
if (frame?.type === "error") {
|
|
1970
|
-
|
|
2852
|
+
if (frame.in_reply_to === void 0 && this.helloPending.has(ws)) {
|
|
2853
|
+
this.helloPending.delete(ws);
|
|
2854
|
+
this.log("info", "transport.daemon_without_nack");
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
if (typeof frame.in_reply_to === "string" && TERMINAL_REPLY_ERRORS.has(String(frame.error))) {
|
|
2858
|
+
this.log("warn", "transport.reply_terminal", { paused: frame.error === "agent_paused" });
|
|
2859
|
+
this.remember(frame.in_reply_to);
|
|
2860
|
+
ws.send(JSON.stringify({ type: "ack", message_id: frame.in_reply_to }));
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2863
|
+
this.log("error", "transport.daemon_error_frame");
|
|
2864
|
+
if (typeof frame.in_reply_to === "string") this.failTurn(ws, frame.in_reply_to, "reply_failed");
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
if (frame?.type === "sent" && typeof frame.in_reply_to === "string") {
|
|
2868
|
+
this.remember(frame.in_reply_to);
|
|
2869
|
+
ws.send(JSON.stringify({ type: "ack", message_id: frame.in_reply_to }));
|
|
1971
2870
|
return;
|
|
1972
2871
|
}
|
|
1973
2872
|
if (frame?.type !== "message") return;
|
|
1974
2873
|
const messageId = frame.message_id;
|
|
1975
2874
|
if (typeof messageId !== "string") {
|
|
1976
|
-
this.log("error", "
|
|
2875
|
+
this.log("error", "transport.message_id_missing");
|
|
1977
2876
|
return;
|
|
1978
2877
|
}
|
|
1979
2878
|
const channelId = String(frame.channel_id);
|
|
2879
|
+
const previous = this.channelChains.get(channelId) ?? Promise.resolve();
|
|
2880
|
+
const run = () => this.handleMessage(ws, frame, messageId, channelId, httpBase, token);
|
|
2881
|
+
const mine = previous.then(run, run);
|
|
2882
|
+
this.channelChains.set(channelId, mine);
|
|
2883
|
+
try {
|
|
2884
|
+
await mine;
|
|
2885
|
+
} finally {
|
|
2886
|
+
if (this.channelChains.get(channelId) === mine) this.channelChains.delete(channelId);
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
async handleMessage(ws, frame, messageId, channelId, httpBase, token) {
|
|
2890
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
2891
|
+
let turnController = null;
|
|
1980
2892
|
try {
|
|
1981
2893
|
if (!this.seen.has(messageId)) {
|
|
1982
|
-
|
|
2894
|
+
const receipt = await fetch(
|
|
2895
|
+
`${httpBase}/v1/reply-receipt?in_reply_to=${encodeURIComponent(messageId)}`,
|
|
2896
|
+
{
|
|
2897
|
+
headers: { authorization: `Bearer ${token}` },
|
|
2898
|
+
signal: AbortSignal.timeout(this.healthTimeoutMs)
|
|
2899
|
+
}
|
|
2900
|
+
);
|
|
2901
|
+
if (!receipt.ok) throw new Error(`reply receipt lookup failed: HTTP ${receipt.status}`);
|
|
2902
|
+
if ((await receipt.json()).found === true) {
|
|
2903
|
+
this.remember(messageId);
|
|
2904
|
+
ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
|
|
2905
|
+
return;
|
|
2906
|
+
}
|
|
2907
|
+
turnController = new AbortController();
|
|
2908
|
+
let socketTurns = this.turns.get(ws);
|
|
2909
|
+
if (!socketTurns) {
|
|
2910
|
+
socketTurns = /* @__PURE__ */ new Set();
|
|
2911
|
+
this.turns.set(ws, socketTurns);
|
|
2912
|
+
}
|
|
2913
|
+
socketTurns.add(turnController);
|
|
1983
2914
|
const reply = await this.onMessage({
|
|
1984
2915
|
text: String(frame.text ?? ""),
|
|
1985
2916
|
senderId: String(frame.sender_id ?? ""),
|
|
@@ -1987,23 +2918,31 @@ var SecureComsDataPlane = class {
|
|
|
1987
2918
|
messageId,
|
|
1988
2919
|
authorization: parseTurnAuthorization(frame.authorization),
|
|
1989
2920
|
memory: parseTurnMemory(frame.memory)
|
|
1990
|
-
});
|
|
2921
|
+
}, turnController.signal);
|
|
1991
2922
|
if (reply != null) {
|
|
2923
|
+
const text = String(reply);
|
|
1992
2924
|
ws.send(JSON.stringify({
|
|
1993
2925
|
type: "send",
|
|
1994
2926
|
channel_id: channelId,
|
|
1995
|
-
text
|
|
1996
|
-
in_reply_to: messageId
|
|
2927
|
+
text,
|
|
2928
|
+
in_reply_to: messageId,
|
|
2929
|
+
...this.noticeReplies.has(text) ? { notice: true } : {}
|
|
1997
2930
|
}));
|
|
2931
|
+
return;
|
|
1998
2932
|
}
|
|
2933
|
+
this.remember(messageId);
|
|
2934
|
+
ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
|
|
2935
|
+
} else {
|
|
2936
|
+
ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
|
|
1999
2937
|
}
|
|
2000
2938
|
} catch (e) {
|
|
2001
|
-
this.log("error",
|
|
2939
|
+
this.log("error", "transport.handler_failed", {}, e);
|
|
2940
|
+
this.failTurn(ws, messageId, "turn_failed");
|
|
2002
2941
|
} finally {
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2942
|
+
if (turnController) {
|
|
2943
|
+
const socketTurns = this.turns.get(ws);
|
|
2944
|
+
socketTurns?.delete(turnController);
|
|
2945
|
+
if (socketTurns?.size === 0) this.turns.delete(ws);
|
|
2007
2946
|
}
|
|
2008
2947
|
}
|
|
2009
2948
|
}
|
|
@@ -2016,6 +2955,14 @@ function createSecureComsDataPlane(cfg, onMessage) {
|
|
|
2016
2955
|
async function runToolCalls(toolCalls, skills, context) {
|
|
2017
2956
|
const out = [];
|
|
2018
2957
|
for (const call of toolCalls) {
|
|
2958
|
+
if (context?.signal?.aborted) {
|
|
2959
|
+
out.push({
|
|
2960
|
+
role: "tool",
|
|
2961
|
+
content: toolError("Tool execution cancelled"),
|
|
2962
|
+
tool_call_id: call.id
|
|
2963
|
+
});
|
|
2964
|
+
continue;
|
|
2965
|
+
}
|
|
2019
2966
|
let args;
|
|
2020
2967
|
try {
|
|
2021
2968
|
args = JSON.parse(call.function.arguments);
|
|
@@ -2024,12 +2971,14 @@ async function runToolCalls(toolCalls, skills, context) {
|
|
|
2024
2971
|
}
|
|
2025
2972
|
let content;
|
|
2026
2973
|
try {
|
|
2027
|
-
content = await skills.invoke(call.function.name, args,
|
|
2974
|
+
content = boundToolResult(await skills.invoke(call.function.name, args, {
|
|
2975
|
+
roomId: context?.roomId,
|
|
2976
|
+
groupId: context?.groupId,
|
|
2977
|
+
signal: context?.signal,
|
|
2978
|
+
sideEffect: context?.sideEffects?.get(call.id)
|
|
2979
|
+
}));
|
|
2028
2980
|
} catch (err) {
|
|
2029
|
-
content =
|
|
2030
|
-
error: "tool_failed",
|
|
2031
|
-
message: String(err instanceof Error ? err.message : err)
|
|
2032
|
-
});
|
|
2981
|
+
content = toolError(String(err instanceof Error ? err.message : err));
|
|
2033
2982
|
}
|
|
2034
2983
|
out.push({ role: "tool", content, tool_call_id: call.id });
|
|
2035
2984
|
}
|
|
@@ -2039,14 +2988,24 @@ async function runToolCalls(toolCalls, skills, context) {
|
|
|
2039
2988
|
// src/turn-loop.ts
|
|
2040
2989
|
var DEFAULT_SYSTEM_PROMPT = "You are a SecureComs agent. Respond helpfully and concisely.";
|
|
2041
2990
|
var DEFAULT_MAX_ITERATIONS = 5;
|
|
2991
|
+
var DEFAULT_MAX_CONCURRENT_TURNS = 4;
|
|
2992
|
+
var DEFAULT_TURN_TIMEOUT_MS = 3e5;
|
|
2993
|
+
var DAILY_LIMIT_RESPONSE = "This agent has reached its daily usage limit and will respond again after 00:00 UTC.";
|
|
2042
2994
|
var READ_ONLY_TOOLS = /* @__PURE__ */ new Set(["echo", "web_search", "fetch_url", "sql_query", "current_time"]);
|
|
2043
2995
|
function hasReadGrant(auth, toolName) {
|
|
2044
2996
|
return auth.sender_membership === "active" && auth.tool_grants.some((grant) => grant.tool === toolName && grant.mode === "read");
|
|
2045
2997
|
}
|
|
2998
|
+
function hasSideEffectGrant(auth, toolName) {
|
|
2999
|
+
return auth.sender_membership === "active" && auth.tool_grants.some((grant) => grant.tool === toolName && grant.mode === "side_effect");
|
|
3000
|
+
}
|
|
2046
3001
|
function toolIsOffered(auth, toolName) {
|
|
2047
|
-
|
|
3002
|
+
if (READ_ONLY_TOOLS.has(toolName)) return hasReadGrant(auth, toolName);
|
|
3003
|
+
if (toolName === "http_request") {
|
|
3004
|
+
return hasReadGrant(auth, toolName) || hasSideEffectGrant(auth, toolName);
|
|
3005
|
+
}
|
|
3006
|
+
return false;
|
|
2048
3007
|
}
|
|
2049
|
-
function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames) {
|
|
3008
|
+
function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames, now = /* @__PURE__ */ new Date()) {
|
|
2050
3009
|
if (auth.sender_membership !== "active") {
|
|
2051
3010
|
return { allowed: false, reason: "Sender membership is not active" };
|
|
2052
3011
|
}
|
|
@@ -2057,8 +3016,26 @@ function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames) {
|
|
|
2057
3016
|
try {
|
|
2058
3017
|
const args = JSON.parse(argumentsJson);
|
|
2059
3018
|
const method = typeof args.method === "string" ? args.method.toUpperCase() : "GET";
|
|
2060
|
-
if (method
|
|
2061
|
-
|
|
3019
|
+
if (method === "GET") {
|
|
3020
|
+
if (!hasReadGrant(auth, toolName)) {
|
|
3021
|
+
return { allowed: false, reason: `Tool "${toolName}" has no active channel read grant` };
|
|
3022
|
+
}
|
|
3023
|
+
} else {
|
|
3024
|
+
if (!(/* @__PURE__ */ new Set(["POST", "PUT", "DELETE"])).has(method)) {
|
|
3025
|
+
return { allowed: false, reason: `HTTP method "${method}" is not supported` };
|
|
3026
|
+
}
|
|
3027
|
+
const digest = operationSha256(toolName, args);
|
|
3028
|
+
const grant = auth.tool_grants.find(
|
|
3029
|
+
(candidate) => candidate.mode === "side_effect" && candidate.tool === toolName && candidate.operation_sha256 === digest
|
|
3030
|
+
);
|
|
3031
|
+
if (!grant) {
|
|
3032
|
+
return { allowed: false, reason: "State-changing HTTP calls require an exact confirmed operation grant" };
|
|
3033
|
+
}
|
|
3034
|
+
const expiresAt = Date.parse(grant.expires_at);
|
|
3035
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now.getTime()) {
|
|
3036
|
+
return { allowed: false, reason: "The confirmed operation grant is expired or invalid" };
|
|
3037
|
+
}
|
|
3038
|
+
return { allowed: true, reason: `Tool "${toolName}" has an exact confirmed operation grant`, sideEffect: grant };
|
|
2062
3039
|
}
|
|
2063
3040
|
} catch {
|
|
2064
3041
|
return { allowed: false, reason: "Tool arguments are not valid JSON" };
|
|
@@ -2068,16 +3045,133 @@ function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames) {
|
|
|
2068
3045
|
}
|
|
2069
3046
|
function buildHandler(deps) {
|
|
2070
3047
|
const maxIterations = deps.maxIterations ?? DEFAULT_MAX_ITERATIONS;
|
|
3048
|
+
const maxConcurrentTurns = deps.maxConcurrentTurns ?? DEFAULT_MAX_CONCURRENT_TURNS;
|
|
3049
|
+
const turnTimeoutMs = deps.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
|
|
2071
3050
|
const log = deps.log ?? (() => {
|
|
2072
3051
|
});
|
|
3052
|
+
const now = deps.now ?? (() => /* @__PURE__ */ new Date());
|
|
2073
3053
|
const { llm, memoryFor, skills, gate } = deps;
|
|
2074
|
-
|
|
3054
|
+
const channelTails = /* @__PURE__ */ new Map();
|
|
3055
|
+
const slotWaiters = [];
|
|
3056
|
+
let activeTurns = 0;
|
|
3057
|
+
const limitNotified = /* @__PURE__ */ new Map();
|
|
3058
|
+
function cancelledError() {
|
|
3059
|
+
return new Error("Agent turn cancelled");
|
|
3060
|
+
}
|
|
3061
|
+
function waitUnlessAborted(gate2, signal) {
|
|
3062
|
+
if (!signal) return gate2;
|
|
3063
|
+
if (signal.aborted) return Promise.reject(cancelledError());
|
|
3064
|
+
return new Promise((resolve3, reject) => {
|
|
3065
|
+
const onAbort = () => reject(cancelledError());
|
|
3066
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3067
|
+
void gate2.then(() => {
|
|
3068
|
+
signal.removeEventListener("abort", onAbort);
|
|
3069
|
+
resolve3();
|
|
3070
|
+
});
|
|
3071
|
+
});
|
|
3072
|
+
}
|
|
3073
|
+
async function lockChannel(channelId, signal) {
|
|
3074
|
+
const previous = channelTails.get(channelId) ?? Promise.resolve();
|
|
3075
|
+
let release;
|
|
3076
|
+
const mine = new Promise((resolve3) => {
|
|
3077
|
+
release = resolve3;
|
|
3078
|
+
});
|
|
3079
|
+
const tail = previous.then(() => mine);
|
|
3080
|
+
channelTails.set(channelId, tail);
|
|
3081
|
+
let released = false;
|
|
3082
|
+
const done = () => {
|
|
3083
|
+
if (released) return;
|
|
3084
|
+
released = true;
|
|
3085
|
+
release();
|
|
3086
|
+
if (channelTails.get(channelId) === tail) channelTails.delete(channelId);
|
|
3087
|
+
};
|
|
3088
|
+
try {
|
|
3089
|
+
await waitUnlessAborted(previous, signal);
|
|
3090
|
+
} catch (error) {
|
|
3091
|
+
done();
|
|
3092
|
+
throw error;
|
|
3093
|
+
}
|
|
3094
|
+
return done;
|
|
3095
|
+
}
|
|
3096
|
+
function acquireSlot(signal) {
|
|
3097
|
+
let released = false;
|
|
3098
|
+
const release = () => {
|
|
3099
|
+
if (released) return;
|
|
3100
|
+
released = true;
|
|
3101
|
+
activeTurns--;
|
|
3102
|
+
slotWaiters.shift()?.();
|
|
3103
|
+
};
|
|
3104
|
+
if (activeTurns < maxConcurrentTurns) {
|
|
3105
|
+
activeTurns++;
|
|
3106
|
+
return Promise.resolve(release);
|
|
3107
|
+
}
|
|
3108
|
+
if (signal?.aborted) return Promise.reject(cancelledError());
|
|
3109
|
+
return new Promise((resolve3, reject) => {
|
|
3110
|
+
const grant = () => {
|
|
3111
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3112
|
+
activeTurns++;
|
|
3113
|
+
resolve3(release);
|
|
3114
|
+
};
|
|
3115
|
+
const onAbort = () => {
|
|
3116
|
+
const i = slotWaiters.indexOf(grant);
|
|
3117
|
+
if (i >= 0) slotWaiters.splice(i, 1);
|
|
3118
|
+
reject(cancelledError());
|
|
3119
|
+
};
|
|
3120
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3121
|
+
slotWaiters.push(grant);
|
|
3122
|
+
});
|
|
3123
|
+
}
|
|
3124
|
+
function dailyLimitReply(channelId, day) {
|
|
3125
|
+
if (limitNotified.get(channelId) === day) return null;
|
|
3126
|
+
for (const [ch, d] of limitNotified) if (d !== day) limitNotified.delete(ch);
|
|
3127
|
+
limitNotified.set(channelId, day);
|
|
3128
|
+
log("warn", "turn.daily_limit_reply");
|
|
3129
|
+
return DAILY_LIMIT_RESPONSE;
|
|
3130
|
+
}
|
|
3131
|
+
return async (m, upstreamSignal) => {
|
|
2075
3132
|
const text = m.text?.trim() ? m.text : "";
|
|
2076
3133
|
if (!text) return null;
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
3134
|
+
const mustWait = channelTails.has(m.channelId) || activeTurns >= maxConcurrentTurns;
|
|
3135
|
+
if (mustWait) {
|
|
3136
|
+
log("info", "turn.queued", {
|
|
3137
|
+
same_channel: channelTails.has(m.channelId),
|
|
3138
|
+
active_turns: activeTurns
|
|
3139
|
+
});
|
|
3140
|
+
}
|
|
3141
|
+
const releaseChannel = await lockChannel(m.channelId, upstreamSignal);
|
|
3142
|
+
let releaseSlot;
|
|
3143
|
+
try {
|
|
3144
|
+
releaseSlot = await acquireSlot(upstreamSignal);
|
|
3145
|
+
} catch (error) {
|
|
3146
|
+
releaseChannel();
|
|
3147
|
+
throw error;
|
|
3148
|
+
}
|
|
3149
|
+
const controller = new AbortController();
|
|
3150
|
+
let timedOut = false;
|
|
3151
|
+
const cancel = () => controller.abort(upstreamSignal?.reason);
|
|
3152
|
+
if (upstreamSignal?.aborted) cancel();
|
|
3153
|
+
else upstreamSignal?.addEventListener("abort", cancel, { once: true });
|
|
3154
|
+
const timer = setTimeout(() => {
|
|
3155
|
+
timedOut = true;
|
|
3156
|
+
controller.abort(new Error("turn deadline exceeded"));
|
|
3157
|
+
}, turnTimeoutMs);
|
|
3158
|
+
try {
|
|
3159
|
+
return await runTurn(m, controller.signal);
|
|
3160
|
+
} catch (error) {
|
|
3161
|
+
if (error instanceof DailyLimitReachedError) return dailyLimitReply(m.channelId, error.day);
|
|
3162
|
+
if (timedOut) throw new Error(`Agent turn timed out after ${turnTimeoutMs}ms`);
|
|
3163
|
+
if (upstreamSignal?.aborted) throw cancelledError();
|
|
3164
|
+
throw error;
|
|
3165
|
+
} finally {
|
|
3166
|
+
clearTimeout(timer);
|
|
3167
|
+
upstreamSignal?.removeEventListener("abort", cancel);
|
|
3168
|
+
releaseSlot();
|
|
3169
|
+
releaseChannel();
|
|
3170
|
+
}
|
|
3171
|
+
};
|
|
3172
|
+
async function runTurn(m, signal) {
|
|
3173
|
+
const text = m.text;
|
|
3174
|
+
log("info", "turn.message_received", { chars: text.length });
|
|
2081
3175
|
const memory = memoryFor(m.channelId);
|
|
2082
3176
|
memory.replace(m.memory);
|
|
2083
3177
|
const messages = [{ role: "system", content: deps.systemPromptFor() }];
|
|
@@ -2090,7 +3184,7 @@ function buildHandler(deps) {
|
|
|
2090
3184
|
const offeredNames = new Set(tools.map((tool) => tool.function.name));
|
|
2091
3185
|
let response = null;
|
|
2092
3186
|
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
2093
|
-
const result = await llm.complete(messages, tools.length > 0 ? tools : void 0);
|
|
3187
|
+
const result = await llm.complete(messages, tools.length > 0 ? tools : void 0, { signal });
|
|
2094
3188
|
if (result.toolCalls.length === 0) {
|
|
2095
3189
|
response = result.content;
|
|
2096
3190
|
break;
|
|
@@ -2105,26 +3199,38 @@ function buildHandler(deps) {
|
|
|
2105
3199
|
m.authorization,
|
|
2106
3200
|
call.function.name,
|
|
2107
3201
|
call.function.arguments,
|
|
2108
|
-
offeredNames
|
|
3202
|
+
offeredNames,
|
|
3203
|
+
now()
|
|
2109
3204
|
);
|
|
3205
|
+
const localVerdict = channelVerdict.allowed ? gate.gate(call.function.name) : channelVerdict;
|
|
2110
3206
|
return {
|
|
2111
3207
|
call,
|
|
2112
|
-
verdict:
|
|
3208
|
+
verdict: {
|
|
3209
|
+
...localVerdict,
|
|
3210
|
+
...localVerdict.allowed && channelVerdict.sideEffect ? { sideEffect: channelVerdict.sideEffect } : {}
|
|
3211
|
+
}
|
|
2113
3212
|
};
|
|
2114
3213
|
});
|
|
2115
3214
|
const allowed = verdicts.filter((v) => v.verdict.allowed).map((v) => v.call);
|
|
3215
|
+
const sideEffects = new Map(
|
|
3216
|
+
verdicts.flatMap(({ call, verdict }) => verdict.allowed && verdict.sideEffect ? [[call.id, verdict.sideEffect]] : [])
|
|
3217
|
+
);
|
|
2116
3218
|
for (const v of verdicts) {
|
|
2117
3219
|
if (!v.verdict.allowed) {
|
|
2118
|
-
log("warn",
|
|
3220
|
+
log("warn", "turn.tool_call_denied");
|
|
2119
3221
|
}
|
|
2120
3222
|
}
|
|
2121
|
-
const executed = await runToolCalls(allowed, skills, {
|
|
3223
|
+
const executed = await runToolCalls(allowed, skills, {
|
|
3224
|
+
roomId: m.channelId,
|
|
3225
|
+
signal,
|
|
3226
|
+
sideEffects
|
|
3227
|
+
});
|
|
2122
3228
|
const byId = new Map(executed.map((r) => [r.tool_call_id, r]));
|
|
2123
3229
|
for (const { call, verdict } of verdicts) {
|
|
2124
3230
|
if (verdict.allowed) {
|
|
2125
3231
|
const done = byId.get(call.id);
|
|
2126
3232
|
if (done) {
|
|
2127
|
-
messages.push({ role: "tool", content:
|
|
3233
|
+
messages.push({ role: "tool", content: done.content, tool_call_id: done.tool_call_id });
|
|
2128
3234
|
continue;
|
|
2129
3235
|
}
|
|
2130
3236
|
messages.push({
|
|
@@ -2142,23 +3248,23 @@ function buildHandler(deps) {
|
|
|
2142
3248
|
}
|
|
2143
3249
|
}
|
|
2144
3250
|
if (!response) {
|
|
2145
|
-
log("warn",
|
|
3251
|
+
log("warn", "turn.no_final_answer", { iterations: maxIterations });
|
|
2146
3252
|
return null;
|
|
2147
3253
|
}
|
|
2148
3254
|
memory.addMessage({ role: "assistant", content: response });
|
|
2149
|
-
log("info",
|
|
3255
|
+
log("info", "turn.response_ready", { chars: response.length });
|
|
2150
3256
|
return response;
|
|
2151
|
-
}
|
|
3257
|
+
}
|
|
2152
3258
|
}
|
|
2153
3259
|
|
|
2154
3260
|
// src/persona.ts
|
|
2155
|
-
import { existsSync as existsSync5, readFileSync as
|
|
2156
|
-
import { dirname as
|
|
3261
|
+
import { existsSync as existsSync5, readFileSync as readFileSync8, renameSync as renameSync3, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
3262
|
+
import { dirname as dirname5, join as join7 } from "node:path";
|
|
2157
3263
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
2158
3264
|
|
|
2159
3265
|
// src/long-term-memory.ts
|
|
2160
|
-
import { createHash } from "node:crypto";
|
|
2161
|
-
import { chmodSync, existsSync as existsSync4, lstatSync, readFileSync as
|
|
3266
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
3267
|
+
import { chmodSync as chmodSync2, existsSync as existsSync4, lstatSync, readFileSync as readFileSync7, readdirSync as readdirSync2, renameSync as renameSync2 } from "node:fs";
|
|
2162
3268
|
import { join as join6 } from "node:path";
|
|
2163
3269
|
var MEMORY_FILE = "MEMORY.md";
|
|
2164
3270
|
var QUARANTINE_MEMORY_FILE = "MEMORY.quarantined.md";
|
|
@@ -2173,14 +3279,14 @@ function quarantineOrder(a, b) {
|
|
|
2173
3279
|
return index(a) - index(b) || a.localeCompare(b);
|
|
2174
3280
|
}
|
|
2175
3281
|
function sourceId(kind, channelId, path, bytes) {
|
|
2176
|
-
return
|
|
3282
|
+
return createHash2("sha256").update(kind).update("\0").update(channelId ?? "").update("\0").update(path).update("\0").update(bytes).digest("hex");
|
|
2177
3283
|
}
|
|
2178
3284
|
function readLegacySource(path, kind, contentBudget, channelId) {
|
|
2179
3285
|
try {
|
|
2180
3286
|
let st = lstatSync(path);
|
|
2181
3287
|
if (st.isSymbolicLink() || !st.isFile()) return null;
|
|
2182
3288
|
if ((st.mode & 511) !== 384) {
|
|
2183
|
-
|
|
3289
|
+
chmodSync2(path, 384);
|
|
2184
3290
|
st = lstatSync(path);
|
|
2185
3291
|
}
|
|
2186
3292
|
if (st.size > LEGACY_MEMORY_SOURCE_MAX_BYTES) {
|
|
@@ -2196,7 +3302,7 @@ function readLegacySource(path, kind, contentBudget, channelId) {
|
|
|
2196
3302
|
};
|
|
2197
3303
|
}
|
|
2198
3304
|
if (st.size > contentBudget) return null;
|
|
2199
|
-
const bytes =
|
|
3305
|
+
const bytes = readFileSync7(path);
|
|
2200
3306
|
if (bytes.byteLength > LEGACY_MEMORY_SOURCE_MAX_BYTES || bytes.byteLength > contentBudget) return null;
|
|
2201
3307
|
return {
|
|
2202
3308
|
id: sourceId(kind, channelId, path, bytes),
|
|
@@ -2281,7 +3387,7 @@ function legacyMemoryFingerprint(managedDir, dataDir) {
|
|
|
2281
3387
|
}
|
|
2282
3388
|
} catch {
|
|
2283
3389
|
}
|
|
2284
|
-
return
|
|
3390
|
+
return createHash2("sha256").update(metadata.join("\n")).digest("hex");
|
|
2285
3391
|
}
|
|
2286
3392
|
function nextQuarantinePath(dir) {
|
|
2287
3393
|
const first = join6(dir, QUARANTINE_MEMORY_FILE);
|
|
@@ -2300,8 +3406,8 @@ function quarantineLegacyMemory(managedDir, dataDir) {
|
|
|
2300
3406
|
throw new Error(`refusing to quarantine symlinked legacy memory: ${source}`);
|
|
2301
3407
|
}
|
|
2302
3408
|
const target = nextQuarantinePath(dir);
|
|
2303
|
-
|
|
2304
|
-
|
|
3409
|
+
renameSync2(source, target);
|
|
3410
|
+
chmodSync2(target, 384);
|
|
2305
3411
|
moved.push(target);
|
|
2306
3412
|
}
|
|
2307
3413
|
return moved;
|
|
@@ -2322,7 +3428,7 @@ var SAFETY_PREAMBLE = [
|
|
|
2322
3428
|
"- Only use the tools you were given; do not invent capabilities."
|
|
2323
3429
|
].join("\n");
|
|
2324
3430
|
function defaultPersonaDir() {
|
|
2325
|
-
return join7(
|
|
3431
|
+
return join7(dirname5(fileURLToPath2(import.meta.url)), "..", "persona");
|
|
2326
3432
|
}
|
|
2327
3433
|
function assemblePrompt(parts) {
|
|
2328
3434
|
return parts.map((p) => `${p.heading}
|
|
@@ -2335,13 +3441,13 @@ function loadPersona(dirs) {
|
|
|
2335
3441
|
if (!path) continue;
|
|
2336
3442
|
let body;
|
|
2337
3443
|
try {
|
|
2338
|
-
body =
|
|
2339
|
-
} catch {
|
|
2340
|
-
|
|
3444
|
+
body = readFileSync8(path, "utf8");
|
|
3445
|
+
} catch (error) {
|
|
3446
|
+
writeServiceLog("warn", "persona.read_failed", {}, error);
|
|
2341
3447
|
continue;
|
|
2342
3448
|
}
|
|
2343
3449
|
if (Buffer.byteLength(body, "utf8") > PERSONA_MAX_BYTES) {
|
|
2344
|
-
|
|
3450
|
+
writeServiceLog("warn", "persona.truncated", { max_bytes: PERSONA_MAX_BYTES });
|
|
2345
3451
|
body = body.slice(0, PERSONA_MAX_BYTES);
|
|
2346
3452
|
}
|
|
2347
3453
|
const trimmed = body.trim();
|
|
@@ -2361,7 +3467,7 @@ function writeLocalConfigReport(managedDir, dataDir, now, flags) {
|
|
|
2361
3467
|
const path2 = join7(dataDir, file);
|
|
2362
3468
|
if (!existsSync5(path2)) continue;
|
|
2363
3469
|
try {
|
|
2364
|
-
persona[file] =
|
|
3470
|
+
persona[file] = readFileSync8(path2, "utf8").slice(0, PERSONA_MAX_BYTES);
|
|
2365
3471
|
} catch {
|
|
2366
3472
|
}
|
|
2367
3473
|
}
|
|
@@ -2378,9 +3484,9 @@ function writeLocalConfigReport(managedDir, dataDir, now, flags) {
|
|
|
2378
3484
|
const path = join7(managedDir, "local-config.json");
|
|
2379
3485
|
const tmp = `${path}.${process.pid}.tmp`;
|
|
2380
3486
|
try {
|
|
2381
|
-
|
|
3487
|
+
writeFileSync2(tmp, `${JSON.stringify(doc, null, 2)}
|
|
2382
3488
|
`, { mode: 384 });
|
|
2383
|
-
|
|
3489
|
+
renameSync3(tmp, path);
|
|
2384
3490
|
} catch (e) {
|
|
2385
3491
|
try {
|
|
2386
3492
|
unlinkSync(tmp);
|
|
@@ -2399,7 +3505,7 @@ function personaInit(dataDir, defaultsDir, writeFile, exists) {
|
|
|
2399
3505
|
skipped.push(target);
|
|
2400
3506
|
continue;
|
|
2401
3507
|
}
|
|
2402
|
-
writeFile(target,
|
|
3508
|
+
writeFile(target, readFileSync8(join7(defaultsDir, file), "utf8"));
|
|
2403
3509
|
written.push(target);
|
|
2404
3510
|
}
|
|
2405
3511
|
return { written, skipped };
|
|
@@ -2431,31 +3537,32 @@ function installWarningFilter(proc = process) {
|
|
|
2431
3537
|
proc.removeListener("warning", listener);
|
|
2432
3538
|
}
|
|
2433
3539
|
proc.on("warning", (w) => {
|
|
2434
|
-
|
|
2435
|
-
|
|
3540
|
+
try {
|
|
3541
|
+
if (w.name === "ExperimentalWarning" && /SQLite/i.test(w.message)) return;
|
|
3542
|
+
} catch {
|
|
3543
|
+
}
|
|
3544
|
+
writeServiceLog("warn", "runtime.node_warning", {}, w);
|
|
2436
3545
|
});
|
|
2437
3546
|
}
|
|
2438
3547
|
|
|
2439
3548
|
// src/index.ts
|
|
2440
3549
|
if (process.argv.includes("--version")) {
|
|
2441
|
-
const pkg = JSON.parse(
|
|
3550
|
+
const pkg = JSON.parse(readFileSync9(join8(dirname6(dirname6(fileURLToPath3(import.meta.url))), "package.json"), "utf8"));
|
|
2442
3551
|
console.log(pkg.version);
|
|
2443
3552
|
process.exit(0);
|
|
2444
3553
|
}
|
|
2445
3554
|
var LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
2446
3555
|
function makeLogger(cfg) {
|
|
2447
3556
|
const floor = LEVEL_RANK[cfg.logLevel];
|
|
2448
|
-
return (level,
|
|
3557
|
+
return (level, event, fields, error) => {
|
|
2449
3558
|
if (LEVEL_RANK[level] < floor) return;
|
|
2450
|
-
|
|
2451
|
-
if (level === "warn" || level === "error") console.error(line);
|
|
2452
|
-
else console.log(line);
|
|
3559
|
+
writeServiceLog(level, event, fields, error);
|
|
2453
3560
|
};
|
|
2454
3561
|
}
|
|
2455
3562
|
function realLlmSourceDeps() {
|
|
2456
3563
|
return {
|
|
2457
3564
|
home: homedir4(),
|
|
2458
|
-
readFile: (p) =>
|
|
3565
|
+
readFile: (p) => readFileSync9(p, "utf8"),
|
|
2459
3566
|
statFile: (p) => ({ mode: statSync2(p).mode }),
|
|
2460
3567
|
exists: (p) => existsSync6(p)
|
|
2461
3568
|
};
|
|
@@ -2467,9 +3574,9 @@ function realServiceDeps() {
|
|
|
2467
3574
|
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
2468
3575
|
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
2469
3576
|
},
|
|
2470
|
-
writeFile: (p, data) =>
|
|
3577
|
+
writeFile: (p, data) => writeFileSync3(p, data, { mode: 420 }),
|
|
2471
3578
|
removeFile: (p) => rmSync(p, { force: true }),
|
|
2472
|
-
mkdirp: (p) =>
|
|
3579
|
+
mkdirp: (p) => mkdirSync3(p, { recursive: true }),
|
|
2473
3580
|
fileExists: (p) => existsSync6(p),
|
|
2474
3581
|
canRead: (p) => {
|
|
2475
3582
|
try {
|
|
@@ -2566,6 +3673,46 @@ async function main() {
|
|
|
2566
3673
|
installWarningFilter();
|
|
2567
3674
|
const verb = parseVerb(process.argv);
|
|
2568
3675
|
if (verb) runServiceVerb(verb);
|
|
3676
|
+
const sideEffectsVerb = parseSideEffectsVerb(process.argv);
|
|
3677
|
+
if (process.argv[2] === "side-effects" && !sideEffectsVerb) {
|
|
3678
|
+
console.error("securecoms-agent: side-effects requires list or resolve");
|
|
3679
|
+
process.exit(2);
|
|
3680
|
+
}
|
|
3681
|
+
if (sideEffectsVerb) {
|
|
3682
|
+
let cfgForLedger;
|
|
3683
|
+
try {
|
|
3684
|
+
cfgForLedger = loadConfig(process.argv.slice(2), process.env);
|
|
3685
|
+
} catch (e) {
|
|
3686
|
+
console.error(`securecoms-agent: ${e instanceof Error ? e.message : String(e)}`);
|
|
3687
|
+
process.exit(2);
|
|
3688
|
+
return;
|
|
3689
|
+
}
|
|
3690
|
+
const ledger = new SideEffectLedger(join8(cfgForLedger.dataDir, "side-effect-ledger.sqlite"));
|
|
3691
|
+
try {
|
|
3692
|
+
if (sideEffectsVerb === "list") {
|
|
3693
|
+
console.log(JSON.stringify({ ambiguous: ledger.listAmbiguous() }, null, 2));
|
|
3694
|
+
} else {
|
|
3695
|
+
const flags = parseSideEffectResolveFlags(process.argv);
|
|
3696
|
+
if (!flags.idempotencyKey || !flags.operationSha256 || !/^[0-9a-f]{64}$/.test(flags.operationSha256) || !flags.confirmationId || !flags.outcome) {
|
|
3697
|
+
throw new Error(
|
|
3698
|
+
"resolve requires --idempotency-key, --operation-sha256, --confirmation-id, and --outcome completed|not_applied"
|
|
3699
|
+
);
|
|
3700
|
+
}
|
|
3701
|
+
ledger.reconcile({
|
|
3702
|
+
idempotency_key: flags.idempotencyKey,
|
|
3703
|
+
operation_sha256: flags.operationSha256,
|
|
3704
|
+
confirmation_id: flags.confirmationId
|
|
3705
|
+
}, flags.outcome, /* @__PURE__ */ new Date());
|
|
3706
|
+
console.log(JSON.stringify({ reconciled: true, outcome: flags.outcome }));
|
|
3707
|
+
}
|
|
3708
|
+
} catch (e) {
|
|
3709
|
+
console.error(`securecoms-agent: side-effects ${sideEffectsVerb} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3710
|
+
process.exitCode = 1;
|
|
3711
|
+
} finally {
|
|
3712
|
+
ledger.close();
|
|
3713
|
+
}
|
|
3714
|
+
process.exit(process.exitCode ?? 0);
|
|
3715
|
+
}
|
|
2569
3716
|
const personaVerb = parsePersonaVerb(process.argv);
|
|
2570
3717
|
if (personaVerb) {
|
|
2571
3718
|
let cfgForPersona;
|
|
@@ -2577,11 +3724,11 @@ async function main() {
|
|
|
2577
3724
|
return;
|
|
2578
3725
|
}
|
|
2579
3726
|
if (personaVerb === "init") {
|
|
2580
|
-
|
|
3727
|
+
mkdirSync3(cfgForPersona.dataDir, { recursive: true });
|
|
2581
3728
|
const r = personaInit(
|
|
2582
3729
|
cfgForPersona.dataDir,
|
|
2583
3730
|
defaultPersonaDir(),
|
|
2584
|
-
(p, d) =>
|
|
3731
|
+
(p, d) => writeFileSync3(p, d, { mode: 384 }),
|
|
2585
3732
|
(p) => existsSync6(p)
|
|
2586
3733
|
);
|
|
2587
3734
|
for (const p of r.written) console.log(`wrote ${p}`);
|
|
@@ -2600,12 +3747,27 @@ async function main() {
|
|
|
2600
3747
|
try {
|
|
2601
3748
|
cfg = loadConfig(process.argv.slice(2), process.env);
|
|
2602
3749
|
} catch (e) {
|
|
2603
|
-
|
|
3750
|
+
const argv = process.argv.slice(2);
|
|
3751
|
+
const hasIdentityArg = argv.some(
|
|
3752
|
+
(arg, index) => arg === "--identity" && !!argv[index + 1] || arg.startsWith("--identity=") && arg.length > 11
|
|
3753
|
+
);
|
|
3754
|
+
writeServiceLog(
|
|
3755
|
+
"error",
|
|
3756
|
+
!hasIdentityArg && !process.env.SECURECOMS_IDENTITY ? "runtime.identity_required.use_--identity" : "runtime.config_invalid",
|
|
3757
|
+
{},
|
|
3758
|
+
e
|
|
3759
|
+
);
|
|
2604
3760
|
process.exit(2);
|
|
2605
3761
|
return;
|
|
2606
3762
|
}
|
|
2607
3763
|
const log = makeLogger(cfg);
|
|
2608
|
-
const
|
|
3764
|
+
const budget = new DailyLlmBudget({
|
|
3765
|
+
callLimit: cfg.llmDailyCallLimit ?? DEFAULT_DAILY_CALL_LIMIT,
|
|
3766
|
+
tokenLimit: cfg.llmDailyTokenLimit ?? DEFAULT_DAILY_TOKEN_LIMIT,
|
|
3767
|
+
statePath: join8(cfg.dataDir, `llm-budget-${cfg.identityId.replace(/[^A-Za-z0-9_-]/g, "_")}.json`),
|
|
3768
|
+
log
|
|
3769
|
+
});
|
|
3770
|
+
const llm = withDailyBudget(buildLlmShim(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps()), budget);
|
|
2609
3771
|
const memories = /* @__PURE__ */ new Map();
|
|
2610
3772
|
const memoryFor = (channelId) => {
|
|
2611
3773
|
let mem = memories.get(channelId);
|
|
@@ -2629,18 +3791,29 @@ async function main() {
|
|
|
2629
3791
|
const path = keyFilePath(which);
|
|
2630
3792
|
if (!path) return null;
|
|
2631
3793
|
try {
|
|
2632
|
-
return
|
|
2633
|
-
} catch {
|
|
2634
|
-
log("warn",
|
|
3794
|
+
return readFileSync9(path, "utf8").trim() || null;
|
|
3795
|
+
} catch (e) {
|
|
3796
|
+
log("warn", "runtime.search_key_unreadable", { provider_brave: which === "brave" }, e);
|
|
2635
3797
|
return null;
|
|
2636
3798
|
}
|
|
2637
3799
|
};
|
|
2638
3800
|
const configDirs = configSearchPath(cfg.identityId, cfg.dataDir);
|
|
2639
|
-
const
|
|
3801
|
+
const reportSearchKeyGate = searchKeyGateReporter(log, (p) => p === keyFilePath("brave"));
|
|
3802
|
+
const hasSearchKey = () => hasReadableKeyFile(
|
|
3803
|
+
[keyFilePath("brave"), keyFilePath("tavily")],
|
|
3804
|
+
(p) => readFileSync9(p, "utf8"),
|
|
3805
|
+
reportSearchKeyGate
|
|
3806
|
+
);
|
|
3807
|
+
const reportLlmSourceUnresolved = failureEpisodeReporter(
|
|
3808
|
+
(e) => log("warn", "runtime.llm_source_unresolved", {}, e)
|
|
3809
|
+
);
|
|
2640
3810
|
const hasLlmKey = () => {
|
|
2641
3811
|
try {
|
|
2642
|
-
|
|
2643
|
-
|
|
3812
|
+
const source = resolveLlmSource(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps());
|
|
3813
|
+
reportLlmSourceUnresolved.succeeded();
|
|
3814
|
+
return hasLlmKeyFor(source);
|
|
3815
|
+
} catch (e) {
|
|
3816
|
+
reportLlmSourceUnresolved.failed(e);
|
|
2644
3817
|
return false;
|
|
2645
3818
|
}
|
|
2646
3819
|
};
|
|
@@ -2659,7 +3832,8 @@ async function main() {
|
|
|
2659
3832
|
now: () => /* @__PURE__ */ new Date(),
|
|
2660
3833
|
log
|
|
2661
3834
|
};
|
|
2662
|
-
const
|
|
3835
|
+
const sideEffectLedger = new SideEffectLedger(join8(cfg.dataDir, "side-effect-ledger.sqlite"));
|
|
3836
|
+
const skills = new SkillInvoker(cfg.skillsDir, builtinCtx, sideEffectLedger);
|
|
2663
3837
|
skills.loadSkills();
|
|
2664
3838
|
const policyFor = () => resolveToolPolicy(cfg.toolGateEnv, currentSkillsConfig().tools, {
|
|
2665
3839
|
default: cfg.toolGate.default,
|
|
@@ -2673,11 +3847,11 @@ async function main() {
|
|
|
2673
3847
|
reportLocalConfig({ hasSearchKey: searchKey, hasLlmKey: hasLlmKey() });
|
|
2674
3848
|
return skills.available(requiresEnvFrom(sc, searchKey)).filter((t) => g.gate(t.function.name).allowed);
|
|
2675
3849
|
};
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
}
|
|
3850
|
+
const unavailableCount = skills.unavailable(requiresEnvFrom(currentSkillsConfig(), hasSearchKey())).length;
|
|
3851
|
+
if (unavailableCount > 0) log("info", "runtime.skills_unavailable", { count: unavailableCount });
|
|
2679
3852
|
const plane = createSecureComsDataPlane(
|
|
2680
|
-
|
|
3853
|
+
// Final-wave I2: the daily-limit reply is a notice, not an agent turn.
|
|
3854
|
+
{ identityId: cfg.identityId, log, noticeReplies: /* @__PURE__ */ new Set([DAILY_LIMIT_RESPONSE]) },
|
|
2681
3855
|
buildHandler({
|
|
2682
3856
|
llm,
|
|
2683
3857
|
memoryFor,
|
|
@@ -2691,16 +3865,21 @@ async function main() {
|
|
|
2691
3865
|
tools: installationEligibleTools
|
|
2692
3866
|
})
|
|
2693
3867
|
);
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
3868
|
+
log("info", "runtime.started", {
|
|
3869
|
+
configured_skills: installationEligibleTools().length,
|
|
3870
|
+
tool_gate_default_deny: cfg.toolGate.default === "deny"
|
|
3871
|
+
});
|
|
2697
3872
|
await plane.start();
|
|
2698
3873
|
const shutdown = () => {
|
|
2699
|
-
log("info", "
|
|
3874
|
+
log("info", "runtime.shutting_down");
|
|
2700
3875
|
plane.stop();
|
|
3876
|
+
sideEffectLedger.close();
|
|
2701
3877
|
process.exit(0);
|
|
2702
3878
|
};
|
|
2703
3879
|
process.on("SIGINT", shutdown);
|
|
2704
3880
|
process.on("SIGTERM", shutdown);
|
|
2705
3881
|
}
|
|
2706
|
-
void main()
|
|
3882
|
+
void main().catch((error) => {
|
|
3883
|
+
writeServiceLog("error", "runtime.fatal", {}, error);
|
|
3884
|
+
process.exit(1);
|
|
3885
|
+
});
|