@ouro.bot/cli 0.1.0-alpha.955 → 0.1.0-alpha.957
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/changelog.json +24 -0
- package/deploy/unraid/credit-gateway.md +95 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +22 -8
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/gateway/keys.js +117 -0
- package/dist/gateway/server.js +429 -0
- package/dist/heart/auth/auth-flow.js +17 -3
- package/dist/heart/config.js +5 -1
- package/dist/heart/core.js +33 -12
- package/dist/heart/daemon/cli-exec.js +25 -0
- package/dist/heart/daemon/cli-help.js +14 -2
- package/dist/heart/daemon/cli-parse.js +83 -0
- package/dist/heart/external-events/router.js +20 -12
- package/dist/heart/identity.js +1 -1
- package/dist/heart/provider-credentials.js +2 -2
- package/dist/heart/provider-ping.js +2 -1
- package/dist/heart/providers/anthropic.js +65 -7
- package/dist/heart/runtime-credentials.js +2 -1
- package/dist/repertoire/house-sweep.js +10 -8
- package/dist/repertoire/tools-continuity.js +16 -10
- package/dist/senses/private-runtime-worker.js +53 -1
- package/dist/senses/sanctuary-health-acceptance-probe.js +1 -1
- package/dist/senses/sanctuary-health.js +31 -5
- package/dist/senses/telegram-handoff.js +75 -1
- package/dist/senses/telegram.js +35 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.GATEWAY_MAX_REQUEST_BYTES = exports.GATEWAY_DEFAULT_COOLDOWN_MS = exports.GATEWAY_DEFAULT_UPSTREAM = exports.GATEWAY_DEFAULT_PORT = exports.GATEWAY_DEFAULT_HOST = void 0;
|
|
37
|
+
exports.isCreditExhaustedBody = isCreditExhaustedBody;
|
|
38
|
+
exports.creditGatewayOptionsFromCli = creditGatewayOptionsFromCli;
|
|
39
|
+
exports.startCreditGateway = startCreditGateway;
|
|
40
|
+
exports.waitForShutdownSignal = waitForShutdownSignal;
|
|
41
|
+
exports.runCreditGatewayCommand = runCreditGatewayCommand;
|
|
42
|
+
const http = __importStar(require("node:http"));
|
|
43
|
+
const https = __importStar(require("node:https"));
|
|
44
|
+
const path = __importStar(require("node:path"));
|
|
45
|
+
const zlib = __importStar(require("node:zlib"));
|
|
46
|
+
const node_crypto_1 = require("node:crypto");
|
|
47
|
+
const node_stream_1 = require("node:stream");
|
|
48
|
+
const runtime_1 = require("../nerves/runtime");
|
|
49
|
+
const keys_1 = require("./keys");
|
|
50
|
+
exports.GATEWAY_DEFAULT_HOST = "127.0.0.1";
|
|
51
|
+
exports.GATEWAY_DEFAULT_PORT = 8787;
|
|
52
|
+
exports.GATEWAY_DEFAULT_UPSTREAM = "https://api.anthropic.com";
|
|
53
|
+
exports.GATEWAY_DEFAULT_COOLDOWN_MS = 60 * 60 * 1000;
|
|
54
|
+
exports.GATEWAY_MAX_REQUEST_BYTES = 32 * 1024 * 1024;
|
|
55
|
+
const CREDIT_LOW_PHRASE = "credit balance is too low";
|
|
56
|
+
const CREDIT_CHECK_STATUSES = new Set([400, 402, 403]);
|
|
57
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
58
|
+
"connection",
|
|
59
|
+
"keep-alive",
|
|
60
|
+
"proxy-authenticate",
|
|
61
|
+
"proxy-authorization",
|
|
62
|
+
"proxy-connection",
|
|
63
|
+
"te",
|
|
64
|
+
"trailer",
|
|
65
|
+
"transfer-encoding",
|
|
66
|
+
"upgrade",
|
|
67
|
+
]);
|
|
68
|
+
class RequestTooLargeError extends Error {
|
|
69
|
+
}
|
|
70
|
+
function anthropicError(type, message) {
|
|
71
|
+
return JSON.stringify({ type: "error", error: { type, message } });
|
|
72
|
+
}
|
|
73
|
+
/* v8 ignore start -- defensive: Node and the HTTP stack always throw Error instances @preserve */
|
|
74
|
+
function errorMessage(error) {
|
|
75
|
+
return error instanceof Error ? error.message : String(error);
|
|
76
|
+
}
|
|
77
|
+
/* v8 ignore stop */
|
|
78
|
+
function digest(value) {
|
|
79
|
+
return (0, node_crypto_1.createHash)("sha256").update(value).digest();
|
|
80
|
+
}
|
|
81
|
+
/** Compares digests so the comparison takes the same time whatever the presented key's length. */
|
|
82
|
+
function matchesMasterKey(presented, masterDigest) {
|
|
83
|
+
if (!presented)
|
|
84
|
+
return false;
|
|
85
|
+
return (0, node_crypto_1.timingSafeEqual)(digest(presented), masterDigest);
|
|
86
|
+
}
|
|
87
|
+
function presentedKey(req) {
|
|
88
|
+
const apiKey = req.headers["x-api-key"];
|
|
89
|
+
if (typeof apiKey === "string" && apiKey.trim())
|
|
90
|
+
return apiKey.trim();
|
|
91
|
+
const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "");
|
|
92
|
+
return bearer ? bearer[1].trim() : undefined;
|
|
93
|
+
}
|
|
94
|
+
function readRequestBody(req, maxBytes) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const chunks = [];
|
|
97
|
+
let size = 0;
|
|
98
|
+
req.on("data", (chunk) => {
|
|
99
|
+
size += chunk.length;
|
|
100
|
+
if (size > maxBytes) {
|
|
101
|
+
reject(new RequestTooLargeError());
|
|
102
|
+
req.pause();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
chunks.push(chunk);
|
|
106
|
+
});
|
|
107
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
108
|
+
req.on("error", reject);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function readAll(stream) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const chunks = [];
|
|
114
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
115
|
+
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
116
|
+
stream.on("error", reject);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function forwardedRequestHeaders(req, upstreamKey, bodyLength) {
|
|
120
|
+
const headers = {};
|
|
121
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
122
|
+
if (HOP_BY_HOP_HEADERS.has(name) || name === "host" || name === "content-length" || name === "x-api-key" || name === "authorization")
|
|
123
|
+
continue;
|
|
124
|
+
headers[name] = value;
|
|
125
|
+
}
|
|
126
|
+
headers["x-api-key"] = upstreamKey;
|
|
127
|
+
if (bodyLength > 0)
|
|
128
|
+
headers["content-length"] = String(bodyLength);
|
|
129
|
+
return headers;
|
|
130
|
+
}
|
|
131
|
+
function responseHeaders(headers) {
|
|
132
|
+
const result = {};
|
|
133
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
134
|
+
if (HOP_BY_HOP_HEADERS.has(name))
|
|
135
|
+
continue;
|
|
136
|
+
result[name] = value;
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
/** Decodes a compressed body for inspection only; the client still gets the original bytes. */
|
|
141
|
+
function decodeForInspection(body, encoding) {
|
|
142
|
+
try {
|
|
143
|
+
const normalized = (encoding ?? "").trim().toLowerCase();
|
|
144
|
+
if (normalized === "gzip" || normalized === "x-gzip")
|
|
145
|
+
return zlib.gunzipSync(body).toString("utf8");
|
|
146
|
+
if (normalized === "deflate")
|
|
147
|
+
return zlib.inflateSync(body).toString("utf8");
|
|
148
|
+
if (normalized === "br")
|
|
149
|
+
return zlib.brotliDecompressSync(body).toString("utf8");
|
|
150
|
+
return body.toString("utf8");
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return body.toString("utf8");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function isCreditExhaustedBody(body, encoding) {
|
|
157
|
+
return decodeForInspection(body, encoding).toLowerCase().includes(CREDIT_LOW_PHRASE);
|
|
158
|
+
}
|
|
159
|
+
function upstreamUrl(base, requestUrl) {
|
|
160
|
+
const prefix = base.pathname.replace(/\/+$/, "");
|
|
161
|
+
return new URL(`${prefix}${requestUrl}`, base.origin);
|
|
162
|
+
}
|
|
163
|
+
function sendUpstream(target, method, headers, body) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const transport = target.protocol === "https:" ? https : http;
|
|
166
|
+
const request = transport.request(target, { method, headers }, (response) => resolve({ response, request }));
|
|
167
|
+
request.on("error", reject);
|
|
168
|
+
request.end(body.length > 0 ? body : undefined);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
function writeBuffered(res, buffered) {
|
|
172
|
+
res.writeHead(buffered.status, { ...buffered.headers, "content-length": String(buffered.body.length) });
|
|
173
|
+
res.end(buffered.body);
|
|
174
|
+
}
|
|
175
|
+
function writeJsonError(res, status, type, message) {
|
|
176
|
+
const body = Buffer.from(anthropicError(type, message));
|
|
177
|
+
writeBuffered(res, { status, headers: { "content-type": "application/json" }, body });
|
|
178
|
+
}
|
|
179
|
+
/** Maps `ouro gateway serve` flags to gateway options. */
|
|
180
|
+
function creditGatewayOptionsFromCli(command) {
|
|
181
|
+
return {
|
|
182
|
+
keysDir: command.keysDir,
|
|
183
|
+
...(command.statePath ? { statePath: command.statePath } : {}),
|
|
184
|
+
...(command.host ? { host: command.host } : {}),
|
|
185
|
+
...(command.port !== undefined ? { port: command.port } : {}),
|
|
186
|
+
...(command.upstream ? { upstream: command.upstream } : {}),
|
|
187
|
+
...(command.cooldownMinutes !== undefined ? { cooldownMs: command.cooldownMinutes * 60 * 1000 } : {}),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/* v8 ignore start -- the default log writes to the real stderr; tests inject log @preserve */
|
|
191
|
+
function defaultLog(line) {
|
|
192
|
+
process.stderr.write(`${line}\n`);
|
|
193
|
+
}
|
|
194
|
+
/* v8 ignore stop */
|
|
195
|
+
/**
|
|
196
|
+
* Starts the credit gateway: a byte-passthrough proxy to the Anthropic API that accepts one master key
|
|
197
|
+
* and spends the upstream keys in a fixed order, moving to the next key only when the current one
|
|
198
|
+
* reports that its credit balance is too low.
|
|
199
|
+
*/
|
|
200
|
+
async function startCreditGateway(options) {
|
|
201
|
+
const keys = (0, keys_1.loadGatewayKeys)(options.keysDir);
|
|
202
|
+
const statePath = options.statePath ?? path.join(options.keysDir, "gateway-state.json");
|
|
203
|
+
const state = (0, keys_1.loadGatewayState)(statePath);
|
|
204
|
+
const host = options.host ?? exports.GATEWAY_DEFAULT_HOST;
|
|
205
|
+
const upstreamBase = new URL(options.upstream ?? exports.GATEWAY_DEFAULT_UPSTREAM);
|
|
206
|
+
const cooldownMs = options.cooldownMs ?? exports.GATEWAY_DEFAULT_COOLDOWN_MS;
|
|
207
|
+
const maxRequestBytes = options.maxRequestBytes ?? exports.GATEWAY_MAX_REQUEST_BYTES;
|
|
208
|
+
const now = options.now ?? Date.now;
|
|
209
|
+
const log = options.log ?? defaultLog;
|
|
210
|
+
const masterDigest = digest(keys.masterKey);
|
|
211
|
+
function persist() {
|
|
212
|
+
try {
|
|
213
|
+
(0, keys_1.saveGatewayState)(statePath, state);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
(0, runtime_1.emitNervesEvent)({
|
|
217
|
+
level: "warn",
|
|
218
|
+
component: "gateway",
|
|
219
|
+
event: "gateway.state_save_error",
|
|
220
|
+
message: "credit gateway could not save its state",
|
|
221
|
+
meta: { statePath, error: errorMessage(error) },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Returns the skip-until time for a label, or undefined when the key is usable. Expired entries are dropped. */
|
|
226
|
+
function skipUntil(label) {
|
|
227
|
+
const until = state.exhausted[label];
|
|
228
|
+
if (!until)
|
|
229
|
+
return undefined;
|
|
230
|
+
if (Date.parse(until) > now())
|
|
231
|
+
return until;
|
|
232
|
+
delete state.exhausted[label];
|
|
233
|
+
persist();
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
function markExhausted(entry) {
|
|
237
|
+
state.exhausted[entry.label] = new Date(now() + cooldownMs).toISOString();
|
|
238
|
+
persist();
|
|
239
|
+
log(`gateway: ${entry.label} out of credits; moving on`);
|
|
240
|
+
(0, runtime_1.emitNervesEvent)({
|
|
241
|
+
level: "warn",
|
|
242
|
+
component: "gateway",
|
|
243
|
+
event: "gateway.key_exhausted",
|
|
244
|
+
message: "credit gateway key is out of credits",
|
|
245
|
+
meta: { label: entry.label, skipUntil: state.exhausted[entry.label] },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
function logRequest(label, method, pathname, status, startedAt) {
|
|
249
|
+
const ms = now() - startedAt;
|
|
250
|
+
log(`gateway: ${label} ${method} ${pathname} ${status} ${ms}ms`);
|
|
251
|
+
(0, runtime_1.emitNervesEvent)({
|
|
252
|
+
component: "gateway",
|
|
253
|
+
event: "gateway.request",
|
|
254
|
+
message: "credit gateway request",
|
|
255
|
+
meta: { label, method, path: pathname, status, ms },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async function handle(req, res) {
|
|
259
|
+
const startedAt = now();
|
|
260
|
+
/* v8 ignore start -- Node always supplies url and method for accepted HTTP requests @preserve */
|
|
261
|
+
const requestUrl = req.url ?? "/";
|
|
262
|
+
const method = req.method ?? "GET";
|
|
263
|
+
/* v8 ignore stop */
|
|
264
|
+
const pathname = new URL(requestUrl, "http://gateway.local").pathname;
|
|
265
|
+
if (method === "GET" && pathname === "/health") {
|
|
266
|
+
const body = JSON.stringify({
|
|
267
|
+
ok: true,
|
|
268
|
+
keys: keys.upstream.map((entry) => {
|
|
269
|
+
const until = skipUntil(entry.label);
|
|
270
|
+
return until ? { label: entry.label, available: false, skipUntil: until } : { label: entry.label, available: true };
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
writeBuffered(res, { status: 200, headers: { "content-type": "application/json" }, body: Buffer.from(body) });
|
|
274
|
+
logRequest("-", method, pathname, 200, startedAt);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (!matchesMasterKey(presentedKey(req), masterDigest)) {
|
|
278
|
+
writeJsonError(res, 401, "authentication_error", "invalid x-api-key");
|
|
279
|
+
logRequest("-", method, pathname, 401, startedAt);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const target = upstreamUrl(upstreamBase, requestUrl);
|
|
283
|
+
if (target.origin !== upstreamBase.origin) {
|
|
284
|
+
writeJsonError(res, 400, "invalid_request_error", "invalid request path");
|
|
285
|
+
logRequest("-", method, pathname, 400, startedAt);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
let body;
|
|
289
|
+
try {
|
|
290
|
+
body = await readRequestBody(req, maxRequestBytes);
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
/* v8 ignore start -- the client went away while sending its body; there is no one to answer @preserve */
|
|
294
|
+
if (!(error instanceof RequestTooLargeError)) {
|
|
295
|
+
res.destroy();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
/* v8 ignore stop */
|
|
299
|
+
res.setHeader("connection", "close");
|
|
300
|
+
writeJsonError(res, 413, "request_too_large", `request body exceeds ${maxRequestBytes} bytes`);
|
|
301
|
+
logRequest("-", method, pathname, 413, startedAt);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
let lastCredit;
|
|
305
|
+
let lastCreditLabel = "-";
|
|
306
|
+
for (const entry of keys.upstream) {
|
|
307
|
+
if (skipUntil(entry.label))
|
|
308
|
+
continue;
|
|
309
|
+
let upstream;
|
|
310
|
+
try {
|
|
311
|
+
upstream = await sendUpstream(target, method, forwardedRequestHeaders(req, entry.key, body.length), body);
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
(0, runtime_1.emitNervesEvent)({
|
|
315
|
+
level: "error",
|
|
316
|
+
component: "gateway",
|
|
317
|
+
event: "gateway.upstream_error",
|
|
318
|
+
message: "credit gateway could not reach the upstream API",
|
|
319
|
+
meta: { label: entry.label, error: errorMessage(error) },
|
|
320
|
+
});
|
|
321
|
+
writeJsonError(res, 502, "api_error", "credit gateway could not reach the upstream API");
|
|
322
|
+
logRequest(entry.label, method, pathname, 502, startedAt);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
/* v8 ignore next -- Node always sets statusCode on an upstream response @preserve */
|
|
326
|
+
const status = upstream.response.statusCode ?? 502;
|
|
327
|
+
if (CREDIT_CHECK_STATUSES.has(status)) {
|
|
328
|
+
const buffered = {
|
|
329
|
+
status,
|
|
330
|
+
headers: responseHeaders(upstream.response.headers),
|
|
331
|
+
body: await readAll(upstream.response),
|
|
332
|
+
};
|
|
333
|
+
if (isCreditExhaustedBody(buffered.body, upstream.response.headers["content-encoding"])) {
|
|
334
|
+
markExhausted(entry);
|
|
335
|
+
lastCredit = buffered;
|
|
336
|
+
lastCreditLabel = entry.label;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
writeBuffered(res, buffered);
|
|
340
|
+
logRequest(entry.label, method, pathname, status, startedAt);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
res.writeHead(status, responseHeaders(upstream.response.headers));
|
|
344
|
+
res.flushHeaders();
|
|
345
|
+
(0, node_stream_1.pipeline)(upstream.response, res, (error) => {
|
|
346
|
+
if (error)
|
|
347
|
+
upstream.request.destroy();
|
|
348
|
+
logRequest(entry.label, method, pathname, status, startedAt);
|
|
349
|
+
});
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (lastCredit) {
|
|
353
|
+
writeBuffered(res, { ...lastCredit, status: 400 });
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
writeJsonError(res, 400, "invalid_request_error", "Your credit balance is too low on every gateway key");
|
|
357
|
+
}
|
|
358
|
+
logRequest(lastCreditLabel, method, pathname, 400, startedAt);
|
|
359
|
+
}
|
|
360
|
+
const server = http.createServer((req, res) => {
|
|
361
|
+
handle(req, res).catch((error) => {
|
|
362
|
+
(0, runtime_1.emitNervesEvent)({
|
|
363
|
+
level: "error",
|
|
364
|
+
component: "gateway",
|
|
365
|
+
event: "gateway.request_error",
|
|
366
|
+
message: "credit gateway request failed",
|
|
367
|
+
meta: { error: errorMessage(error) },
|
|
368
|
+
});
|
|
369
|
+
/* v8 ignore next 4 -- failures after the headers went out are reported by the stream pipeline instead @preserve */
|
|
370
|
+
if (res.headersSent) {
|
|
371
|
+
res.destroy();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
writeJsonError(res, 502, "api_error", "credit gateway request failed");
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
await new Promise((resolve, reject) => {
|
|
378
|
+
server.once("error", reject);
|
|
379
|
+
/* v8 ignore next -- tests always bind port 0; the default is for production @preserve */
|
|
380
|
+
const port = options.port ?? exports.GATEWAY_DEFAULT_PORT;
|
|
381
|
+
server.listen(port, host, () => {
|
|
382
|
+
server.off("error", reject);
|
|
383
|
+
resolve();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
const address = server.address();
|
|
387
|
+
const url = `http://${host}:${address.port}`;
|
|
388
|
+
(0, runtime_1.emitNervesEvent)({
|
|
389
|
+
component: "gateway",
|
|
390
|
+
event: "gateway.server_started",
|
|
391
|
+
message: "credit gateway started",
|
|
392
|
+
meta: { url, upstream: upstreamBase.origin, labels: keys.upstream.map((entry) => entry.label), statePath },
|
|
393
|
+
});
|
|
394
|
+
return {
|
|
395
|
+
server,
|
|
396
|
+
url,
|
|
397
|
+
port: address.port,
|
|
398
|
+
close: () => new Promise((resolve) => {
|
|
399
|
+
server.closeAllConnections();
|
|
400
|
+
server.close(() => {
|
|
401
|
+
(0, runtime_1.emitNervesEvent)({
|
|
402
|
+
component: "gateway",
|
|
403
|
+
event: "gateway.server_stopped",
|
|
404
|
+
message: "credit gateway stopped",
|
|
405
|
+
meta: { url },
|
|
406
|
+
});
|
|
407
|
+
resolve();
|
|
408
|
+
});
|
|
409
|
+
}),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/* v8 ignore start -- waits for real process signals; tests inject waitForStop @preserve */
|
|
413
|
+
function waitForShutdownSignal() {
|
|
414
|
+
return new Promise((resolve) => {
|
|
415
|
+
process.once("SIGINT", () => resolve());
|
|
416
|
+
process.once("SIGTERM", () => resolve());
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
/* v8 ignore stop */
|
|
420
|
+
/** Runs `ouro gateway serve` in the foreground until `waitForStop` resolves. */
|
|
421
|
+
async function runCreditGatewayCommand(command, deps) {
|
|
422
|
+
const handle = await startCreditGateway(creditGatewayOptionsFromCli(command));
|
|
423
|
+
const message = `credit gateway listening on ${handle.url}\nhealth: ${handle.url}/health`;
|
|
424
|
+
deps.writeStdout(message);
|
|
425
|
+
/* v8 ignore next -- production waits for SIGINT/SIGTERM; tests inject waitForStop @preserve */
|
|
426
|
+
await (deps.waitForStop ?? waitForShutdownSignal)();
|
|
427
|
+
await handle.close();
|
|
428
|
+
return message;
|
|
429
|
+
}
|
|
@@ -361,6 +361,16 @@ async function collectRuntimeAuthCredentials(input, deps) {
|
|
|
361
361
|
}
|
|
362
362
|
return credentials;
|
|
363
363
|
}
|
|
364
|
+
if (input.provider === "anthropic" && input.apiKey) {
|
|
365
|
+
(0, runtime_1.emitNervesEvent)({
|
|
366
|
+
component: "daemon",
|
|
367
|
+
event: "daemon.auth_anthropic_api_key",
|
|
368
|
+
message: "storing anthropic API key for runtime auth",
|
|
369
|
+
meta: { agentName: input.agentName, hasBaseUrl: !!input.baseUrl },
|
|
370
|
+
});
|
|
371
|
+
writeAuthProgress(input, "using the supplied anthropic API key...");
|
|
372
|
+
return { apiKey: input.apiKey.trim(), ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}) };
|
|
373
|
+
}
|
|
364
374
|
if (input.provider === "anthropic") {
|
|
365
375
|
(0, runtime_1.emitNervesEvent)({
|
|
366
376
|
component: "daemon",
|
|
@@ -414,12 +424,16 @@ async function collectRuntimeAuthCredentials(input, deps) {
|
|
|
414
424
|
}
|
|
415
425
|
return creds;
|
|
416
426
|
}
|
|
427
|
+
/** True when the record holds `required` or one of the provider's alternative field sets in full. */
|
|
428
|
+
function hasRequiredProviderCredentials(provider, record) {
|
|
429
|
+
const requirements = identity_1.PROVIDER_CREDENTIALS[provider];
|
|
430
|
+
return [requirements.required, ...(requirements.alternativeRequired ?? [])].some((fields) => fields.every((key) => !!record[key]));
|
|
431
|
+
}
|
|
417
432
|
async function resolveHatchCredentials(input) {
|
|
418
433
|
const credentials = { ...(input.credentials ?? {}) };
|
|
419
434
|
// If all required fields are already provided, return as-is
|
|
420
435
|
const cred = credentials;
|
|
421
|
-
|
|
422
|
-
if (!missing)
|
|
436
|
+
if (hasRequiredProviderCredentials(input.provider, cred))
|
|
423
437
|
return credentials;
|
|
424
438
|
// Try the full auth flow (wraps collectRuntimeAuthCredentials + writes secrets)
|
|
425
439
|
if (input.runAuthFlow) {
|
|
@@ -431,7 +445,7 @@ async function resolveHatchCredentials(input) {
|
|
|
431
445
|
});
|
|
432
446
|
Object.assign(credentials, result.credentials);
|
|
433
447
|
/* v8 ignore next 3 -- branch: auth flow always fills all required fields in production @preserve */
|
|
434
|
-
if (
|
|
448
|
+
if (hasRequiredProviderCredentials(input.provider, credentials)) {
|
|
435
449
|
return credentials;
|
|
436
450
|
}
|
|
437
451
|
}
|
package/dist/heart/config.js
CHANGED
|
@@ -235,7 +235,11 @@ function getMinimaxConfig() {
|
|
|
235
235
|
}
|
|
236
236
|
function getAnthropicConfig() {
|
|
237
237
|
const raw = readProviderConfig("anthropic");
|
|
238
|
-
return {
|
|
238
|
+
return {
|
|
239
|
+
setupToken: typeof raw.setupToken === "string" ? raw.setupToken : "",
|
|
240
|
+
...(typeof raw.apiKey === "string" && raw.apiKey ? { apiKey: raw.apiKey } : {}),
|
|
241
|
+
...(typeof raw.baseUrl === "string" && raw.baseUrl ? { baseUrl: raw.baseUrl } : {}),
|
|
242
|
+
};
|
|
239
243
|
}
|
|
240
244
|
function getOpenAICodexConfig() {
|
|
241
245
|
const raw = readProviderConfig("openai-codex");
|
package/dist/heart/core.js
CHANGED
|
@@ -285,6 +285,7 @@ exports.STEP_BUDGET_EXTENSION = 4;
|
|
|
285
285
|
/** Hard ceiling on the per-turn provider-call budget, however much progress is made. */
|
|
286
286
|
exports.MAX_PROVIDER_ITERATIONS_CEILING = 24;
|
|
287
287
|
const STEP_BUDGET_REPORT_INSTRUCTION = "the step budget for this turn is used up. do not call any more work tools; none of the tool calls in your last message were executed. call settle now with a short progress report: what is done (with evidence from tool results above), what is still in flight, and what you will do next. say you can continue where you left off when the conversation resumes.";
|
|
288
|
+
const STEP_BUDGET_REST_INSTRUCTION = "the step budget for this turn is used up. do not call any more work tools; none of the tool calls in your last message were executed. call rest now with a short note: what is done, what is still open, and what to pick up next time.";
|
|
288
289
|
function continuationCallbacks(callbacks, text) {
|
|
289
290
|
const noop = () => undefined;
|
|
290
291
|
return {
|
|
@@ -1208,6 +1209,10 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
1208
1209
|
let stepBudget = exports.MAX_PROVIDER_ITERATIONS;
|
|
1209
1210
|
let lastIterationMadeProgress = false;
|
|
1210
1211
|
let stepReportIteration = null;
|
|
1212
|
+
// The one tool a turn may call after its step budget is used up: settle for conversations, rest for private turns.
|
|
1213
|
+
let stepReportTool = "settle";
|
|
1214
|
+
// A pre-dispatch contract check may reject the same call twice in a turn; after that it is advisory, so a check no call can satisfy never traps the turn.
|
|
1215
|
+
const requiredDispatchRejectionCounts = new Map();
|
|
1211
1216
|
const budgetExhausted = () => {
|
|
1212
1217
|
if (providerIterations < stepBudget)
|
|
1213
1218
|
return false;
|
|
@@ -1444,7 +1449,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
1444
1449
|
.filter((name) => candidateToolNames.has(name)));
|
|
1445
1450
|
const forcingHistoricalEffect = forcedHistoricalToolNames.size > 0;
|
|
1446
1451
|
const activeTools = stepReportIteration !== null
|
|
1447
|
-
? candidateActiveTools.filter((tool) => tool.function.name ===
|
|
1452
|
+
? candidateActiveTools.filter((tool) => tool.function.name === stepReportTool)
|
|
1448
1453
|
: forcingHistoricalEffect
|
|
1449
1454
|
? candidateActiveTools.filter((tool) => forcedHistoricalToolNames.has(tool.function.name))
|
|
1450
1455
|
: candidateActiveTools;
|
|
@@ -1605,8 +1610,8 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
1605
1610
|
nextAttemptControls = [];
|
|
1606
1611
|
providerIterations += 1;
|
|
1607
1612
|
if (stepReportIteration !== null) {
|
|
1608
|
-
// The progress-report call is the last one: it must settle, or the turn errors.
|
|
1609
|
-
const settledReport = result.toolCalls.length === 1 && result.toolCalls[0].name ===
|
|
1613
|
+
// The progress-report call is the last one: it must settle (or rest), or the turn errors.
|
|
1614
|
+
const settledReport = result.toolCalls.length === 1 && result.toolCalls[0].name === stepReportTool;
|
|
1610
1615
|
if (providerIterations > stepReportIteration || (result.toolCalls.length > 0 && !settledReport)) {
|
|
1611
1616
|
throw new Error(`provider iteration limit exhausted at response ${providerIterations} before the progress report settled`);
|
|
1612
1617
|
}
|
|
@@ -1676,20 +1681,22 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
1676
1681
|
if (thinkingItems.length > 0) {
|
|
1677
1682
|
msg._thinking_blocks = thinkingItems;
|
|
1678
1683
|
}
|
|
1679
|
-
const settlingNow = result.toolCalls.length === 1 && result.toolCalls[0].name === "settle";
|
|
1684
|
+
const settlingNow = result.toolCalls.length === 1 && (result.toolCalls[0].name === "settle" || result.toolCalls[0].name === "rest");
|
|
1680
1685
|
if (stepReportIteration === null && result.toolCalls.length > 0 && !settlingNow && budgetExhausted()) {
|
|
1681
|
-
|
|
1686
|
+
const wrapUpTool = candidateToolNames.has("settle") ? "settle" : candidateToolNames.has("rest") ? "rest" : null;
|
|
1687
|
+
if (!wrapUpTool) {
|
|
1682
1688
|
throw new Error(`provider iteration limit exhausted at response ${stepBudget} before tool execution`);
|
|
1683
1689
|
}
|
|
1684
|
-
// Do not error into the reply: give the model one settle-only call to report progress.
|
|
1690
|
+
// Do not error into the reply: give the model one settle-only (or rest-only) call to report progress.
|
|
1685
1691
|
streamCallbackBuffer?.discard();
|
|
1686
1692
|
stepReportIteration = providerIterations + 1;
|
|
1693
|
+
stepReportTool = wrapUpTool;
|
|
1687
1694
|
rejectAttempt(msg, [
|
|
1688
1695
|
...result.toolCalls.map((tc) => ({ role: "tool", tool_call_id: tc.id, content: "not executed: the step budget for this turn is used up." })),
|
|
1689
|
-
{ role: "user", content: STEP_BUDGET_REPORT_INSTRUCTION },
|
|
1696
|
+
{ role: "user", content: wrapUpTool === "settle" ? STEP_BUDGET_REPORT_INSTRUCTION : STEP_BUDGET_REST_INSTRUCTION },
|
|
1690
1697
|
]);
|
|
1691
1698
|
callbacks.onClearText?.();
|
|
1692
|
-
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.step_budget_exhausted", message:
|
|
1699
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.step_budget_exhausted", message: `step budget used up; requesting a ${wrapUpTool}-only progress report`, meta: { stepBudget, providerIterations } });
|
|
1693
1700
|
continue;
|
|
1694
1701
|
}
|
|
1695
1702
|
if (result.toolCalls.length > 0)
|
|
@@ -2166,14 +2173,16 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
2166
2173
|
const restArgs = validatedCallArguments.get(result.toolCalls[0]);
|
|
2167
2174
|
callbacks.onToolStart("rest", restArgs);
|
|
2168
2175
|
// Attention queue gate: reject rest if items remain
|
|
2176
|
+
// A budget wrap-up rest is final: held work and fresh messages wait for the next turn.
|
|
2177
|
+
const wrappingUp = stepReportIteration !== null;
|
|
2169
2178
|
const attentionQueue = augmentedToolContext?.delegatedOrigins;
|
|
2170
|
-
if (attentionQueue && attentionQueue.length > 0) {
|
|
2179
|
+
if (!wrappingUp && attentionQueue && attentionQueue.length > 0) {
|
|
2171
2180
|
callbacks.onToolEnd("rest", (0, tools_1.summarizeArgs)("rest", restArgs, toolSelection), false);
|
|
2172
2181
|
const gateMessage = "current held-work frame still has unsurfaced items — return each listed item with surface(delegationId=...) before you rest. Older transcript claims are historical; only the current held-work frame is the gate.";
|
|
2173
2182
|
rejectToolBatch(msg, [gateMessage]);
|
|
2174
2183
|
continue;
|
|
2175
2184
|
}
|
|
2176
|
-
if (hasFreshPendingWork(options) && !freshWorkGateFired) {
|
|
2185
|
+
if (!wrappingUp && hasFreshPendingWork(options) && !freshWorkGateFired) {
|
|
2177
2186
|
freshWorkGateFired = true;
|
|
2178
2187
|
callbacks.onToolEnd("rest", (0, tools_1.summarizeArgs)("rest", restArgs, toolSelection), false);
|
|
2179
2188
|
const gateMessage = "fresh work arrived for me this turn — inspect the pending messages above and take the next concrete action before you rest.";
|
|
@@ -2206,8 +2215,20 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
2206
2215
|
for (const entry of validCalls) {
|
|
2207
2216
|
const requiredArgs = entry.validated.arguments;
|
|
2208
2217
|
const rejection = options?.requiredToolCalls?.validateToolCallBeforeDispatch?.(entry.call.name, requiredArgs);
|
|
2209
|
-
if (rejection)
|
|
2210
|
-
|
|
2218
|
+
if (!rejection)
|
|
2219
|
+
continue;
|
|
2220
|
+
const rejectionKey = `${entry.call.name}\0${rejection}`;
|
|
2221
|
+
const priorRejections = requiredDispatchRejectionCounts.get(rejectionKey) ?? 0;
|
|
2222
|
+
if (priorRejections >= 2) {
|
|
2223
|
+
(0, runtime_1.emitNervesEvent)({
|
|
2224
|
+
level: "warn", component: "engine", event: "engine.required_tool_dispatch_advisory",
|
|
2225
|
+
message: "required tool dependency rejected twice this turn; letting the call through",
|
|
2226
|
+
meta: { toolName: entry.call.name },
|
|
2227
|
+
});
|
|
2228
|
+
continue;
|
|
2229
|
+
}
|
|
2230
|
+
requiredDispatchRejectionCounts.set(rejectionKey, priorRejections + 1);
|
|
2231
|
+
requiredDispatchRejections.set(entry.call.id, { name: entry.call.name, args: requiredArgs, message: rejection });
|
|
2211
2232
|
}
|
|
2212
2233
|
if (requiredDispatchRejections.size > 0) {
|
|
2213
2234
|
streamCallbackBuffer?.discard();
|
|
@@ -5648,8 +5648,24 @@ async function executeProviderRefresh(command, deps) {
|
|
|
5648
5648
|
deps.writeStdout(message);
|
|
5649
5649
|
return message;
|
|
5650
5650
|
}
|
|
5651
|
+
/** Reads an API key from a file (or /dev/stdin) so it never appears in argv. */
|
|
5652
|
+
function readApiKeyFile(filePath) {
|
|
5653
|
+
let raw;
|
|
5654
|
+
try {
|
|
5655
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
5656
|
+
}
|
|
5657
|
+
catch (error) {
|
|
5658
|
+
/* v8 ignore next -- fs.readFileSync only throws Error objects @preserve */
|
|
5659
|
+
throw new Error(`could not read --api-key-file ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
5660
|
+
}
|
|
5661
|
+
const key = raw.trim();
|
|
5662
|
+
if (!key)
|
|
5663
|
+
throw new Error(`--api-key-file ${filePath} is empty.`);
|
|
5664
|
+
return key;
|
|
5665
|
+
}
|
|
5651
5666
|
async function executeAuthRun(command, deps) {
|
|
5652
5667
|
const provider = command.provider ?? (0, auth_flow_1.readAgentConfigForAgent)(command.agent, deps.bundlesRoot).config.humanFacing.provider;
|
|
5668
|
+
const apiKey = command.apiKeyFile ? readApiKeyFile(command.apiKeyFile) : undefined;
|
|
5653
5669
|
/* v8 ignore next -- tests always inject runAuthFlow; default is for production @preserve */
|
|
5654
5670
|
const authRunner = deps.runAuthFlow ?? (await Promise.resolve().then(() => __importStar(require("../auth/auth-flow")))).runRuntimeAuthFlow;
|
|
5655
5671
|
const progress = createHumanCommandProgress(deps, "auth");
|
|
@@ -5662,6 +5678,8 @@ async function executeAuthRun(command, deps) {
|
|
|
5662
5678
|
promptInput: deps.promptInput,
|
|
5663
5679
|
promptSecret: deps.promptSecret,
|
|
5664
5680
|
onProgress: (message) => progress.updateDetail(message),
|
|
5681
|
+
...(apiKey ? { apiKey } : {}),
|
|
5682
|
+
...(command.baseUrl ? { baseUrl: command.baseUrl } : {}),
|
|
5665
5683
|
});
|
|
5666
5684
|
}
|
|
5667
5685
|
catch (error) {
|
|
@@ -8231,6 +8249,13 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
8231
8249
|
if (command.kind === "a2a.card" || command.kind === "a2a.onboard" || command.kind === "a2a.serve" || command.kind === "a2a.escalation" || command.kind === "a2a.delegatedCommands") {
|
|
8232
8250
|
return executeA2ACommand(command, deps);
|
|
8233
8251
|
}
|
|
8252
|
+
if (command.kind === "gateway.serve") {
|
|
8253
|
+
const { runCreditGatewayCommand } = await Promise.resolve().then(() => __importStar(require("../../gateway/server")));
|
|
8254
|
+
return runCreditGatewayCommand(command, {
|
|
8255
|
+
writeStdout: deps.writeStdout,
|
|
8256
|
+
...(deps.waitForShutdownSignal ? { waitForStop: deps.waitForShutdownSignal } : {}),
|
|
8257
|
+
});
|
|
8258
|
+
}
|
|
8234
8259
|
// ── provider commands (local, no daemon socket needed) ──
|
|
8235
8260
|
if (command.kind === "provider.use") {
|
|
8236
8261
|
return executeProviderUse(command, deps);
|