agentgate-runtime-control 2.13.14 → 2.13.15
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 +0 -75
- package/package.json +1 -1
- package/src/control-plane.js +51 -7
- package/src/mcp-gateway.js +13 -2
- package/src/persistent-store.js +98 -8
package/README.md
CHANGED
|
@@ -24,18 +24,6 @@ Useful control-plane endpoints include `/api/observability`, `/api/trace?runId=.
|
|
|
24
24
|
|
|
25
25
|
**Observe → Attack → Enforce → Replay → Report → Govern**
|
|
26
26
|
|
|
27
|
-
### Quickstart
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
npm install agentgate-runtime-control
|
|
31
|
-
npx agentgate init # writes agentgate.config.mjs — tells you to run doctor next
|
|
32
|
-
npx agentgate doctor # checks your config, warns loudly if you're still in observe mode,
|
|
33
|
-
# then tells you to open examples/protect-first-tool.mjs next
|
|
34
|
-
node examples/protect-first-tool.mjs # see a real tool protected end-to-end
|
|
35
|
-
npx agentgate attack --config ./agentgate.config.mjs # attack-test YOUR policy, not the defaults
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
Each command prints what to run next, so you don't have to remember this sequence.
|
|
39
27
|
|
|
40
28
|
## Design Partner Edition
|
|
41
29
|
|
|
@@ -82,10 +70,6 @@ The config file must export an `agentgate` object created with `createAgentGate(
|
|
|
82
70
|
|
|
83
71
|
See [`docs/production-readiness.md`](docs/production-readiness.md), [`docs/production-deployment.md`](docs/production-deployment.md), and [`docs/release-checklist.md`](docs/release-checklist.md) for deployment, operational, performance, and release gates.
|
|
84
72
|
|
|
85
|
-
### About `npm test` on the installed package
|
|
86
|
-
|
|
87
|
-
Running `npm test` inside an **installed** copy of `agentgate-runtime-control` (i.e. from `node_modules`) reports `0 tests` — that's expected, not a bug: the `test/` directory is intentionally not published to npm (see `files` in `package.json`), the same way most published packages don't ship their own test suite to consumers. The real suite (150+ cases, covering policy decisions, the approval lifecycle — including concurrent approve/deny and TTL expiry — attack-lab scenarios, egress guarding, multi-tenant isolation, and more) lives in and runs from the [source repository](https://github.com/walid-agentgate/agentgate) via `node --test`.
|
|
88
|
-
|
|
89
73
|
## Security
|
|
90
74
|
|
|
91
75
|
See [`SECURITY.md`](SECURITY.md) for the security model and vulnerability-reporting guidance. AgentGate provides a deterministic control layer; it does not replace application-level identity, secret management, network isolation, or threat-model testing.
|
|
@@ -175,30 +159,11 @@ console.table(runAttackLab({ productionBlock: true }));
|
|
|
175
159
|
|
|
176
160
|
The built-in lab covers prompt injection, privilege escalation, destructive actions, high-value refunds, and unsafe tool chaining. It is a testing aid, not a guarantee of security.
|
|
177
161
|
|
|
178
|
-
### Deep Attack Lab — the unrecognized-action-name gap
|
|
179
|
-
|
|
180
|
-
The 5 built-in cases above all use action names the policy engine already classifies (`export_all`, `update_production`, `delete`, `refund`, `publish`). A second, larger set specifically attacks action names it does **not** classify — the `unknownActionPolicy` gap described above — plus two "name evasion" cases (the same dangerous action called under a name that isn't in your `blockActions`/`approvalActions`):
|
|
181
|
-
|
|
182
|
-
```bash
|
|
183
|
-
agentgate attack --deep # against built-in default policies
|
|
184
|
-
agentgate attack --deep --config ./agentgate.config.mjs # against YOUR policy
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
or programmatically:
|
|
188
|
-
|
|
189
|
-
```js
|
|
190
|
-
import { runDeepAttackLab, DEEP_ATTACK_CASES } from 'agentgate-runtime-control';
|
|
191
|
-
const results = await runDeepAttackLab(gateway);
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
Under the historical default (`unknownActionPolicy: 'allow'`), most of these legitimately ALLOW — that's the point, and CI should treat that as a finding rather than a passing baseline for anything reachable in production. Set `unknownActionPolicy: 'ask'` or `'block'` and re-run to confirm the gap is closed for your own policy.
|
|
195
|
-
|
|
196
162
|
## CLI
|
|
197
163
|
|
|
198
164
|
```bash
|
|
199
165
|
agentgate test refund 1200
|
|
200
166
|
agentgate attack
|
|
201
|
-
agentgate attack --deep
|
|
202
167
|
```
|
|
203
168
|
|
|
204
169
|
## MCP Gateway
|
|
@@ -262,26 +227,6 @@ const nextPolicy = mergePolicies(currentPolicy, generated.policy);
|
|
|
262
227
|
|
|
263
228
|
Policy generation is deterministic and reviewable. Generated suggestions do not automatically authorize or block traffic until the resulting policy is explicitly applied to a gateway.
|
|
264
229
|
|
|
265
|
-
### `unknownActionPolicy` — what happens to action names AgentGate doesn't recognize
|
|
266
|
-
|
|
267
|
-
The policy engine only classifies a small built-in set of action names as `destructive` (`delete`, `refund`, `publish`, `deploy`, `export_all`, `update_production`) or `readOnly` (`read`, `search`, `list`, `get`, `fetch`). **Any other action name — a typo, a new tool, a third-party integration using its own naming, or something that sounds obviously dangerous like `grant_admin` or `drop_database` — does not match any rule and falls through to `ALLOW` by default.** This is a real gap, not a corner case: it means adding a new tool with an unrecognized action name silently gets no protection at all unless you've explicitly listed it in `approvalActions`/`blockActions`.
|
|
268
|
-
|
|
269
|
-
`policies.unknownActionPolicy` controls that fallback:
|
|
270
|
-
|
|
271
|
-
```js
|
|
272
|
-
policies: {
|
|
273
|
-
// 'allow' (default, kept for backward compatibility): unrecognized actions
|
|
274
|
-
// pass through untouched, exactly as AgentGate has always done.
|
|
275
|
-
// 'ask': unrecognized actions require human approval — the recommended
|
|
276
|
-
// starting point; `agentgate init` sets this for new projects.
|
|
277
|
-
// 'block': unrecognized actions are refused outright — the strictest,
|
|
278
|
-
// deny-by-default option, once every legitimate action name in
|
|
279
|
-
// your system has been classified.
|
|
280
|
-
unknownActionPolicy: 'ask'
|
|
281
|
-
}
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
`agentgate doctor` warns loudly whenever the effective setting is `'allow'`, so this is never a silent gap in a project that runs `doctor` as part of its setup. `examples/protect-first-tool.mjs` demonstrates the gap and the fix side by side with a `grant_admin` call.
|
|
285
230
|
|
|
286
231
|
## Approval Flow
|
|
287
232
|
|
|
@@ -313,26 +258,6 @@ Approval state is queryable through `gateway.approvals()` and JSON-RPC methods:
|
|
|
313
258
|
|
|
314
259
|
The approval layer is intentionally separate from policy evaluation: policy decides `ALLOW`, `ASK`, or `BLOCK`; approval resolves only the `ASK` path.
|
|
315
260
|
|
|
316
|
-
### Approval lifecycle — who, when, expiry, single-use, revocation
|
|
317
|
-
|
|
318
|
-
- **Who approved / denied, and when**: every approval record carries `createdAt`, `resolvedAt`, and (for a deny) a `resolutionReason`. The run record (`gateway.replay(runId)`) links back to the approval via `approvalId` and stores the same `approval` block for audit export (`gateway.replay()` / `/api/audit/export`). AgentGate itself doesn't have a user identity system, so "who" is whatever identity your own auth layer attaches to the request that calls `approve()`/`deny()` — log that at your call site if you need a named approver.
|
|
319
|
-
- **Expiry (TTL)**: a pending approval expires automatically after **15 minutes** by default (`DEFAULT_APPROVAL_TTL_MS` in `src/approval.js`). Pass `approvalTTLMs` to `createRuntime`/`createAgentGate`/`createMCPGateway` to change it, or `ttlMs: null` on a specific request to disable expiry. Once `expiresAt` passes, the approval flips to `status: 'expired'` the next time it's looked at (list/get/approve/deny), and the original tool call can never be executed late.
|
|
320
|
-
- **Single-use guarantee**: `approve()`/`deny()` are synchronous up to the point where they flip `status` away from `pending` — there is no `await` in between the status check and the status write. Because Node runs JS on a single thread, two calls racing to resolve the same approval (concurrent HTTP requests, a double click, a retried request) can never both see `pending`: the second call always sees the already-resolved status and is rejected with `Approval is already <status>`. There is nothing else to configure for this — it's guaranteed by construction, not by a lock.
|
|
321
|
-
- **Revocation**: there's no separate "revoke" verb — deny a still-pending approval with `gateway.deny(approvalId, reason)` (or `agentgate approval deny <id> <reason>` from the CLI) to take it off the table before anyone acts on it.
|
|
322
|
-
- **Duplicate requests**: each call to a protected tool creates its own approval with its own id — AgentGate does not de-duplicate identical-looking requests. If your agent might retry the same call, treat that as your integration's concern (e.g. an idempotency key on your own tool handler).
|
|
323
|
-
|
|
324
|
-
### Approval CLI
|
|
325
|
-
|
|
326
|
-
Once a gateway or control plane is running (for example via `agentgate dev`), you can list and resolve approvals from the command line instead of writing HTTP calls by hand:
|
|
327
|
-
|
|
328
|
-
```
|
|
329
|
-
agentgate approval list [--status pending|approved|denied|expired] [--url <url>]
|
|
330
|
-
agentgate approval approve <approvalId> [--url <url>] [--key <apiKey>]
|
|
331
|
-
agentgate approval deny <approvalId> [reason] [--url <url>] [--key <apiKey>]
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
By default it talks to `http://localhost:8787` (what `agentgate dev` uses) and, if no `--key`/`AGENTGATE_API_KEY` is given, it automatically picks up the local dev session the same way opening the dashboard in a browser would — no extra setup needed for local testing. Point `--url` at a different host/port for a control plane running elsewhere, and pass `--key` (or set `AGENTGATE_API_KEY`) when auth is required outside local dev.
|
|
335
|
-
|
|
336
261
|
## v1.1 — Developer Integration
|
|
337
262
|
|
|
338
263
|
AgentGate now exposes a single developer-facing runtime:
|
package/package.json
CHANGED
package/src/control-plane.js
CHANGED
|
@@ -18,10 +18,42 @@ import { createRequire } from 'node:module';
|
|
|
18
18
|
const require = createRequire(import.meta.url);
|
|
19
19
|
const PACKAGE_VERSION = require('../package.json').version;
|
|
20
20
|
|
|
21
|
+
// Reads and parses a POST body with a hard size cap. Earlier versions bailed
|
|
22
|
+
// out of the read loop as soon as the cap was exceeded without consuming the
|
|
23
|
+
// rest of the incoming request — on a keep-alive connection, those unread
|
|
24
|
+
// bytes are still in flight from the client and get misinterpreted as the
|
|
25
|
+
// start of the next request, which is what caused an unrelated follow-up
|
|
26
|
+
// request on the same socket to hang until timeout instead of failing fast.
|
|
27
|
+
// Fix: once oversized, keep draining every chunk (so the socket/HTTP parser
|
|
28
|
+
// ends this request cleanly) but stop buffering it into memory, then raise a
|
|
29
|
+
// typed error with the right status for the caller to respond with.
|
|
30
|
+
async function readJsonBody(req, maxBodySize) {
|
|
31
|
+
let raw = '';
|
|
32
|
+
let bytes = 0;
|
|
33
|
+
let oversized = false;
|
|
34
|
+
for await (const chunk of req) {
|
|
35
|
+
bytes += chunk.length;
|
|
36
|
+
if (bytes > maxBodySize) { oversized = true; continue; }
|
|
37
|
+
raw += chunk;
|
|
38
|
+
}
|
|
39
|
+
if (oversized) {
|
|
40
|
+
const error = new Error('Payload too large');
|
|
41
|
+
error.status = 413;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return raw ? JSON.parse(raw) : {};
|
|
46
|
+
} catch {
|
|
47
|
+
const error = new Error('Invalid JSON');
|
|
48
|
+
error.status = 400;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
21
53
|
export function createControlPlane(options = {}) {
|
|
22
54
|
const gateway = options.gateway || createMCPGateway(options);
|
|
23
55
|
const staticHtml = options.html;
|
|
24
|
-
const agentStore = options.agentStore || (options.persistence ? createPersistentAgentStore({ filePath: options.agentPersistence || `${options.persistence}/agents.json
|
|
56
|
+
const agentStore = options.agentStore || (options.persistence ? createPersistentAgentStore({ filePath: options.agentPersistence || `${options.persistence}/agents.json`, recoverFromCorruption: options.recoverFromCorruption, onCorruption: options.onPersistenceCorruption }) : null);
|
|
25
57
|
const tenantRegistry = options.tenantRegistry || new TenantRegistry({ filePath: `${options.persistence || '.agentgate'}/tenants.json`, keyPath: `${options.persistence || '.agentgate'}/api-keys.json` });
|
|
26
58
|
const webhookRegistry = options.webhookRegistry || new WebhookRegistry({ filePath: `${options.persistence || '.agentgate'}/webhooks.json`, deliveryPath: `${options.persistence || '.agentgate'}/webhook-deliveries.json` });
|
|
27
59
|
const authRequired = options.authRequired !== false;
|
|
@@ -69,7 +101,11 @@ export function createControlPlane(options = {}) {
|
|
|
69
101
|
const scopedRuns = () => gateway.runs(tenantId);
|
|
70
102
|
const scopedApprovals = (status) => gateway.approvals(status, tenantId);
|
|
71
103
|
if (path === '/api/health') return { ok: true, mode: gateway.mode, version: gateway?.serverInfo?.version || PACKAGE_VERSION };
|
|
72
|
-
if (path === '/api/ready')
|
|
104
|
+
if (path === '/api/ready') {
|
|
105
|
+
const persistence = gateway.persistenceHealth?.() || { persistent: false, degraded: false, corruptions: [] };
|
|
106
|
+
const ready = !persistence.degraded;
|
|
107
|
+
return { ok: ready, ready, mode: gateway.mode, uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000), persistence };
|
|
108
|
+
}
|
|
73
109
|
if (path === '/api/kill-switch' && method === 'GET') return gateway.killStatus?.() || {killed:false};
|
|
74
110
|
if (path === '/api/kill-switch' && method === 'POST') { if (body.enabled === false) return gateway.unkill?.(); return gateway.kill?.(body.reason); }
|
|
75
111
|
if (path === '/api/metrics') return gateway.telemetry?.snapshot?.() || { uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000), counters: {}, latency: {} };
|
|
@@ -171,11 +207,18 @@ export function createControlPlane(options = {}) {
|
|
|
171
207
|
if (url.pathname.startsWith('/api/')) {
|
|
172
208
|
let body = {};
|
|
173
209
|
if (req.method === 'POST') {
|
|
174
|
-
let raw = '';
|
|
175
|
-
let bodyBytes = 0;
|
|
176
210
|
const maxBodySize = Number(options.maxBodySize || 1024 * 1024);
|
|
177
|
-
|
|
178
|
-
|
|
211
|
+
try {
|
|
212
|
+
body = await readJsonBody(req, maxBodySize);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
// 'connection: close' on top of fully draining the body (above) is
|
|
215
|
+
// belt-and-suspenders: it tells the client itself not to reuse this
|
|
216
|
+
// socket, so even a client we haven't fully drained yet won't have
|
|
217
|
+
// its next request misread on this connection.
|
|
218
|
+
res.writeHead(error.status || 400, { 'content-type': 'application/json', 'cache-control': 'no-store', 'connection': 'close' });
|
|
219
|
+
res.end(JSON.stringify({ error: error.message }));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
179
222
|
}
|
|
180
223
|
const limitKey = req.headers['x-agentgate-key'] || req.socket.remoteAddress || 'anonymous';
|
|
181
224
|
const rate = limiter.check(String(limitKey));
|
|
@@ -204,7 +247,8 @@ export function createControlPlane(options = {}) {
|
|
|
204
247
|
}
|
|
205
248
|
const result = await api(url.pathname, req.method, body, Object.fromEntries(url.searchParams.entries()), authContext);
|
|
206
249
|
if (result._html) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); res.end(result.html); return; }
|
|
207
|
-
|
|
250
|
+
const status = result.error ? 404 : (url.pathname === '/api/ready' && result.ready === false ? 503 : 200);
|
|
251
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
208
252
|
res.end(JSON.stringify(result));
|
|
209
253
|
return;
|
|
210
254
|
}
|
package/src/mcp-gateway.js
CHANGED
|
@@ -32,9 +32,11 @@ export function createMCPGateway(options = {}) {
|
|
|
32
32
|
const killReason = { value: options.killReason || 'Emergency security lock' };
|
|
33
33
|
const maxRuns = Number(options.maxRuns || 25000);
|
|
34
34
|
let retentionWarned = false;
|
|
35
|
-
const
|
|
35
|
+
const recoverFromCorruption = Boolean(options.recoverFromCorruption);
|
|
36
|
+
const onPersistenceCorruption = options.onPersistenceCorruption;
|
|
37
|
+
const runStore = options.runStore || (options.persistence ? createPersistentRunStore({ filePath: options.runPersistence || `${options.persistence}/runs.json`, limit: maxRuns, recoverFromCorruption, onCorruption: onPersistenceCorruption }) : null);
|
|
36
38
|
const runs = runStore ? null : [];
|
|
37
|
-
const approvals = createApprovalStore({ store: options.approvalStore || (options.persistence ? createPersistentApprovalStore({ filePath: options.approvalPersistence || `${options.persistence}/approvals.json`, limit: options.maxApprovals || maxRuns }) : undefined), limit: options.maxApprovals || maxRuns });
|
|
39
|
+
const approvals = createApprovalStore({ store: options.approvalStore || (options.persistence ? createPersistentApprovalStore({ filePath: options.approvalPersistence || `${options.persistence}/approvals.json`, limit: options.maxApprovals || maxRuns, recoverFromCorruption, onCorruption: onPersistenceCorruption }) : undefined), limit: options.maxApprovals || maxRuns });
|
|
38
40
|
const eventBus = options.eventBus || createEventBus();
|
|
39
41
|
const telemetry = options.telemetry || createTelemetry();
|
|
40
42
|
const egressGuard = options.egressGuard || (options.egress ? createEgressGuard(options.egress === true ? {} : options.egress) : null);
|
|
@@ -73,6 +75,15 @@ export function createMCPGateway(options = {}) {
|
|
|
73
75
|
const count = runStore ? runStore.list().length : runs.length;
|
|
74
76
|
const nearLimit = count >= Math.max(1, Math.ceil(maxRuns * 0.9));
|
|
75
77
|
return { count, limit: maxRuns, nearLimit, persistent: Boolean(runStore), truncated: count >= maxRuns };
|
|
78
|
+
},
|
|
79
|
+
// Reports whether persistence had to recover from corruption at startup
|
|
80
|
+
// (only possible when recoverFromCorruption: true was passed — otherwise
|
|
81
|
+
// corruption makes createMCPGateway() throw instead of starting up in a
|
|
82
|
+
// degraded state). Control planes/readiness probes should surface this
|
|
83
|
+
// rather than reporting healthy while sitting on recovered/lost data.
|
|
84
|
+
persistenceHealth: () => {
|
|
85
|
+
const corruptions = [runStore?.corruption, approvals?.corruption].filter(Boolean);
|
|
86
|
+
return { persistent: Boolean(runStore), degraded: corruptions.length > 0, corruptions };
|
|
76
87
|
}
|
|
77
88
|
};
|
|
78
89
|
|
package/src/persistent-store.js
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
+
// Thrown when a persisted collection file exists but cannot be trusted:
|
|
5
|
+
// invalid JSON, or valid JSON that isn't the array shape this store expects.
|
|
6
|
+
// This is distinct from "file does not exist yet" (ENOENT), which is the
|
|
7
|
+
// normal first-run case and is NOT an error.
|
|
8
|
+
export class PersistenceCorruptionError extends Error {
|
|
9
|
+
constructor(message, options) {
|
|
10
|
+
super(message, options);
|
|
11
|
+
this.name = 'PersistenceCorruptionError';
|
|
12
|
+
this.code = 'AGENTGATE_PERSISTENCE_CORRUPT';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
4
16
|
export class PersistentCollectionStore {
|
|
5
|
-
constructor(filePath, { key = 'id', limit = 25000, seed = [] } = {}) {
|
|
17
|
+
constructor(filePath, { key = 'id', limit = 25000, seed = [], recoverFromCorruption = false, onCorruption } = {}) {
|
|
6
18
|
this.filePath = path.resolve(filePath);
|
|
7
19
|
this.key = key;
|
|
8
20
|
this.limit = limit;
|
|
21
|
+
this.recoverFromCorruption = Boolean(recoverFromCorruption);
|
|
22
|
+
this.onCorruption = typeof onCorruption === 'function' ? onCorruption : null;
|
|
23
|
+
// Set only if a corruption event was handled (requires recoverFromCorruption:
|
|
24
|
+
// true). When this is non-null, the in-memory collection was reset and
|
|
25
|
+
// whatever was in the corrupt file was NOT recovered automatically.
|
|
26
|
+
this.corruption = null;
|
|
9
27
|
this.items = this.#load(seed);
|
|
10
28
|
}
|
|
11
29
|
|
|
@@ -35,22 +53,94 @@ export class PersistentCollectionStore {
|
|
|
35
53
|
fs.writeFileSync(temp, JSON.stringify(this.items, null, 2), 'utf8');
|
|
36
54
|
fs.renameSync(temp, this.filePath);
|
|
37
55
|
}
|
|
56
|
+
|
|
38
57
|
#load(seed) {
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
text = fs.readFileSync(this.filePath, 'utf8');
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// No file yet is the normal first-run case. Any other read failure
|
|
63
|
+
// (permission denied, I/O error, ...) is an operational failure, not
|
|
64
|
+
// "no data yet" — let it fail closed by propagating the error instead
|
|
65
|
+
// of silently returning an empty collection.
|
|
66
|
+
if (err.code === 'ENOENT') return Array.isArray(seed) ? seed.slice(0, this.limit) : [];
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
let parsed;
|
|
39
70
|
try {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return
|
|
43
|
-
|
|
71
|
+
parsed = JSON.parse(text);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return this.#handleCorruption(text, new PersistenceCorruptionError(
|
|
74
|
+
`Corrupt persistence file (invalid JSON): ${this.filePath}`,
|
|
75
|
+
{ cause: err }
|
|
76
|
+
), seed);
|
|
77
|
+
}
|
|
78
|
+
if (!Array.isArray(parsed)) {
|
|
79
|
+
return this.#handleCorruption(text, new PersistenceCorruptionError(
|
|
80
|
+
`Corrupt persistence file (expected an array, got ${parsed === null ? 'null' : typeof parsed}): ${this.filePath}`
|
|
81
|
+
), seed);
|
|
82
|
+
}
|
|
83
|
+
return parsed.slice(0, this.limit);
|
|
44
84
|
}
|
|
85
|
+
|
|
86
|
+
// Corruption means the file exists but its contents cannot be trusted —
|
|
87
|
+
// tampering, a crash mid-write on an older version, disk corruption, etc.
|
|
88
|
+
// The previous behavior here was to swallow the error and silently return
|
|
89
|
+
// an empty array, which quietly erases audit history and, worse, makes
|
|
90
|
+
// pending approvals vanish with no trace. We now fail closed by default:
|
|
91
|
+
// the store refuses to come up at all, so the operator finds out at
|
|
92
|
+
// startup instead of discovering a gap in the audit trail later.
|
|
93
|
+
//
|
|
94
|
+
// Recovery is opt-in only (recoverFromCorruption: true): the corrupt file
|
|
95
|
+
// is quarantined (renamed, never deleted) and the collection starts from
|
|
96
|
+
// `seed` (normally empty) — but this is explicitly NOT the same as
|
|
97
|
+
// recovering the lost data, and is logged loudly as such.
|
|
98
|
+
#handleCorruption(rawText, error, seed) {
|
|
99
|
+
if (!this.recoverFromCorruption) throw error;
|
|
100
|
+
const quarantinePath = `${this.filePath}.corrupt.${Date.now()}`;
|
|
101
|
+
try {
|
|
102
|
+
fs.renameSync(this.filePath, quarantinePath);
|
|
103
|
+
} catch {
|
|
104
|
+
try { fs.writeFileSync(quarantinePath, rawText ?? '', 'utf8'); } catch { /* best effort */ }
|
|
105
|
+
}
|
|
106
|
+
this.corruption = {
|
|
107
|
+
filePath: this.filePath,
|
|
108
|
+
quarantinePath,
|
|
109
|
+
message: error.message,
|
|
110
|
+
recoveredAt: new Date().toISOString(),
|
|
111
|
+
recoveredCount: Array.isArray(seed) ? seed.length : 0
|
|
112
|
+
};
|
|
113
|
+
console.error(
|
|
114
|
+
`\n🚨 AgentGate: persistence corruption recovered for ${this.filePath}\n` +
|
|
115
|
+
` Corrupt file quarantined to: ${quarantinePath}\n` +
|
|
116
|
+
` Started with ${this.corruption.recoveredCount} seed record(s) — the original contents were NOT recovered.\n` +
|
|
117
|
+
` Investigate the quarantined file before trusting this collection again.\n`
|
|
118
|
+
);
|
|
119
|
+
this.onCorruption?.(this.corruption);
|
|
120
|
+
return Array.isArray(seed) ? seed.slice(0, this.limit) : [];
|
|
121
|
+
}
|
|
122
|
+
|
|
45
123
|
#trim() { if (this.items.length > this.limit) this.items.splice(this.limit); }
|
|
46
124
|
}
|
|
47
125
|
|
|
48
126
|
export function createPersistentRunStore(options = {}) {
|
|
49
|
-
return new PersistentCollectionStore(options.filePath || '.agentgate/runs.json', {
|
|
127
|
+
return new PersistentCollectionStore(options.filePath || '.agentgate/runs.json', {
|
|
128
|
+
limit: options.limit || 25000,
|
|
129
|
+
recoverFromCorruption: options.recoverFromCorruption,
|
|
130
|
+
onCorruption: options.onCorruption
|
|
131
|
+
});
|
|
50
132
|
}
|
|
51
133
|
export function createPersistentApprovalStore(options = {}) {
|
|
52
|
-
return new PersistentCollectionStore(options.filePath || '.agentgate/approvals.json', {
|
|
134
|
+
return new PersistentCollectionStore(options.filePath || '.agentgate/approvals.json', {
|
|
135
|
+
limit: options.limit || 25000,
|
|
136
|
+
recoverFromCorruption: options.recoverFromCorruption,
|
|
137
|
+
onCorruption: options.onCorruption
|
|
138
|
+
});
|
|
53
139
|
}
|
|
54
140
|
export function createPersistentAgentStore(options = {}) {
|
|
55
|
-
return new PersistentCollectionStore(options.filePath || '.agentgate/agents.json', {
|
|
141
|
+
return new PersistentCollectionStore(options.filePath || '.agentgate/agents.json', {
|
|
142
|
+
limit: options.limit || 1000,
|
|
143
|
+
recoverFromCorruption: options.recoverFromCorruption,
|
|
144
|
+
onCorruption: options.onCorruption
|
|
145
|
+
});
|
|
56
146
|
}
|