@agenthippo-ai/agenthippo 1.110.9 → 1.110.11
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/out/agentpack/index.js +3 -1
- package/out/agentpack/paths.js +25 -2
- package/out/analytics/dashboard/localFleetAdapter.js +82 -2
- package/out/analytics/service.js +6 -0
- package/out/analytics/sessionEvents.js +4 -3
- package/out/auth-broker/server.js +23 -19
- package/out/cli/commands/data-plane.js +84 -0
- package/out/cli/commands/sandbox.js +28 -9
- package/out/cli/commands/serve.js +289 -97
- package/out/cli/credential-context.js +17 -0
- package/out/cli/data-plane/index.js +2 -1
- package/out/cli/data-plane/sidecar.js +66 -2
- package/out/cli/deploy/layers/identity.js +5 -2
- package/out/cli/deploy-init.js +4 -4
- package/out/cli/index.js +16 -1
- package/out/cli/serve-hardening.js +181 -12
- package/out/engines/claude.js +1 -1
- package/out/engines/codex.js +1 -1
- package/out/engines/custom.js +7 -1
- package/out/evidence/sessionShip.js +50 -17
- package/out/headless/index.js +8 -1
- package/out/pack/governor.js +32 -15
- package/out/pack/lease-schema.js +4 -1
- package/out/pack/lease.js +42 -0
- package/out/runner/host.js +24 -14
- package/out/runner/protocol.js +24 -4
- package/out/runner/remoteEngine.js +53 -17
- package/out/sandbox/containerDriver.js +4 -2
- package/out/sandbox/processDriver.js +4 -1
- package/out/sandbox/sandboxRunner.js +148 -36
- package/out/scripts/agentide_litellm_hooks_v1.py +85 -1
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +4 -0
- package/out/scripts/analytics/setup-analytics.ps1 +8 -1
- package/out/scripts/analytics/setup-analytics.sh +13 -2
- package/out/test/caps-governor.test.js +6 -1
- package/out/test/data-plane-engine-wiring.test.js +32 -0
- package/out/test/data-plane.test.js +313 -0
- package/out/test/deploy-init.test.js +7 -1
- package/out/test/governor-hardening.test.js +6 -1
- package/out/test/lease-validation.test.js +55 -0
- package/out/test/localFleetAdapter.test.js +91 -0
- package/out/test/sandbox-driver.test.js +2 -2
- package/out/test/sandbox-hardening.test.js +12 -1
- package/out/test/sandbox-runner.test.js +1 -1
- package/out/test/sandbox-scope.test.js +449 -0
- package/out/test/serve-cron-acceptance.test.js +370 -0
- package/out/test/serve-gateway-turn.test.js +122 -0
- package/out/test/serve-hardening.test.js +182 -9
- package/out/test/sessionShip.test.js +58 -0
- package/package.json +1 -1
|
@@ -47,6 +47,8 @@ const path = __importStar(require("path"));
|
|
|
47
47
|
const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
|
|
48
48
|
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
|
49
49
|
const data_plane_1 = require("../cli/data-plane");
|
|
50
|
+
const data_plane_2 = require("../cli/commands/data-plane");
|
|
51
|
+
const identity_1 = require("../analytics/identity");
|
|
50
52
|
// The fixture connector must live INSIDE the extension tree so its `import` of
|
|
51
53
|
// @modelcontextprotocol/sdk resolves by walking up to extensions/agentide/node_modules.
|
|
52
54
|
const FIXTURE_DIR = path.join(__dirname, 'data-plane-fixture');
|
|
@@ -297,6 +299,14 @@ suite('data-plane: sidecar EDGE registration (P1 fix — no forging authority in
|
|
|
297
299
|
await turn.release();
|
|
298
300
|
await assert.rejects(() => callWhoami(sidecar.gatewayUrl, nonce), /401|Unauthorized/i);
|
|
299
301
|
});
|
|
302
|
+
/**
|
|
303
|
+
* PR-F1 / agent-identity.md §8.5 G1.
|
|
304
|
+
*
|
|
305
|
+
* The edge forwarded `X-Agent-User` and nothing else, and `/edge/turn` registered with no
|
|
306
|
+
* inboundToken — so edge mode silently supported the `identity` provider ALONE. A
|
|
307
|
+
* `passthrough` deployment (Databricks-style OBO) booted clean and then failed closed on
|
|
308
|
+
* every single tool call, and an RFC 8693 `exchange` provider had no subject_token at all.
|
|
309
|
+
*/
|
|
300
310
|
test('release with a bogus nonce is a harmless no-op (204) and touches no other turn', async () => {
|
|
301
311
|
const res = await edgeRegister('carol@corp.com');
|
|
302
312
|
const nonce = res.headers.get(data_plane_1.TURN_NONCE_HEADER);
|
|
@@ -309,3 +319,306 @@ suite('data-plane: sidecar EDGE registration (P1 fix — no forging authority in
|
|
|
309
319
|
assert.strictEqual(who.userId, 'carol@corp.com', 'an unrelated release must not end a live turn');
|
|
310
320
|
});
|
|
311
321
|
});
|
|
322
|
+
/**
|
|
323
|
+
* PLAN/agent-identity.md §8.5 G1 (build record §10.5).
|
|
324
|
+
*
|
|
325
|
+
* The edge forwarded `X-Agent-User` (an identifier) and nothing else, and `/edge/turn` registered
|
|
326
|
+
* with no inboundToken. So edge mode silently supported the `identity` provider ALONE: a
|
|
327
|
+
* `passthrough` deployment (Databricks-style OBO) booted clean and then failed closed on every
|
|
328
|
+
* single tool call, and an RFC 8693 `exchange` provider had no `subject_token` to exchange at all.
|
|
329
|
+
*
|
|
330
|
+
* This suite runs a PASSTHROUGH sidecar behind the edge — the combination that could not work.
|
|
331
|
+
*/
|
|
332
|
+
suite('data-plane: sidecar EDGE + passthrough (PR-F1 — the edge must forward the assertion)', function () {
|
|
333
|
+
this.timeout(30_000);
|
|
334
|
+
const EDGE_TOKEN = 'edge-assertion-token';
|
|
335
|
+
let sidecar;
|
|
336
|
+
let controlBase;
|
|
337
|
+
suiteSetup(async () => {
|
|
338
|
+
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
339
|
+
fs.writeFileSync(CONNECTOR_PATH, CONNECTOR_SOURCE, 'utf8');
|
|
340
|
+
sidecar = await (0, data_plane_1.startDataPlaneSidecar)({
|
|
341
|
+
config: { connectorPath: CONNECTOR_PATH, provider: 'passthrough' },
|
|
342
|
+
registerToken: EDGE_TOKEN,
|
|
343
|
+
gatewayPort: 0,
|
|
344
|
+
controlPort: 0,
|
|
345
|
+
bindHost: '127.0.0.1',
|
|
346
|
+
});
|
|
347
|
+
controlBase = `http://127.0.0.1:${sidecar.controlPort}`;
|
|
348
|
+
});
|
|
349
|
+
suiteTeardown(async () => {
|
|
350
|
+
await sidecar?.close();
|
|
351
|
+
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
352
|
+
});
|
|
353
|
+
const register = (user, assertion) => fetch(`${controlBase}/edge/turn`, {
|
|
354
|
+
headers: {
|
|
355
|
+
Authorization: `Bearer ${EDGE_TOKEN}`,
|
|
356
|
+
'X-Agent-User': user,
|
|
357
|
+
...(assertion ? { [data_plane_1.ASSERTION_HEADER]: assertion } : {}),
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
test('with the assertion forwarded, a passthrough turn reaches the backend as that user', async () => {
|
|
361
|
+
const res = await register('alice@corp.com', 'Bearer alice-platform-token');
|
|
362
|
+
assert.strictEqual(res.status, 200);
|
|
363
|
+
const who = await callWhoami(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
|
|
364
|
+
assert.strictEqual(who.userId, 'alice@corp.com');
|
|
365
|
+
assert.strictEqual(who.credential, 'alice-platform-token', 'the assertion becomes the turn inboundToken, and passthrough hands it to the connector');
|
|
366
|
+
});
|
|
367
|
+
test('a bare "Bearer " prefix is stripped — the connector gets the token, not the scheme', async () => {
|
|
368
|
+
const res = await register('bob@corp.com', 'Bearer bob-token');
|
|
369
|
+
const who = await callWhoami(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
|
|
370
|
+
assert.strictEqual(who.credential, 'bob-token');
|
|
371
|
+
});
|
|
372
|
+
test('an assertion sent WITHOUT the scheme prefix is taken verbatim', async () => {
|
|
373
|
+
const res = await register('carol@corp.com', 'carol-raw-token');
|
|
374
|
+
const who = await callWhoami(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
|
|
375
|
+
assert.strictEqual(who.credential, 'carol-raw-token');
|
|
376
|
+
});
|
|
377
|
+
test('WITHOUT the assertion the turn still registers, but every call fails closed — the pre-PR-F1 behaviour', async () => {
|
|
378
|
+
const res = await register('dave@corp.com');
|
|
379
|
+
assert.strictEqual(res.status, 200, 'registration itself must not depend on the assertion');
|
|
380
|
+
const { client, transport } = mcpClient(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
|
|
381
|
+
await client.connect(transport);
|
|
382
|
+
const out = await client.callTool({ name: 'whoami', arguments: {} });
|
|
383
|
+
await client.close();
|
|
384
|
+
assert.strictEqual(out.isError, true);
|
|
385
|
+
assert.match(out.content[0].text, /fail-closed/, 'no inbound credential must never fall back to a shared one');
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
/**
|
|
389
|
+
* PLAN/agent-identity.md §10.1 — the audit writer.
|
|
390
|
+
*
|
|
391
|
+
* The sidecar accepted an optional `audit` and `dataPlaneSidecarCommand` never passed one. Because
|
|
392
|
+
* every emit site is `opts.audit?.(…)`, the entire data-plane trust-boundary record was silently
|
|
393
|
+
* discarded in sidecar and edge mode — the two shapes the blueprints deploy. The compliance claim
|
|
394
|
+
* ("which user touched which rows") had no writer exactly where it was being made.
|
|
395
|
+
*
|
|
396
|
+
* These tests pin both halves: the sink the command builds actually writes, and the sidecar
|
|
397
|
+
* actually emits through it on the authorize / deny / reject paths.
|
|
398
|
+
*/
|
|
399
|
+
suite('data-plane: sidecar audit sink (PR-0 — the record must have a writer)', function () {
|
|
400
|
+
this.timeout(30_000);
|
|
401
|
+
const TOKEN = 'audit-suite-token';
|
|
402
|
+
const AUDIT_ROOT = path.join(FIXTURE_DIR, 'audit-state');
|
|
403
|
+
let sidecar;
|
|
404
|
+
let controlBase;
|
|
405
|
+
let auditDir;
|
|
406
|
+
const rows = () => {
|
|
407
|
+
if (!fs.existsSync(auditDir)) {
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
return fs.readdirSync(auditDir)
|
|
411
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
412
|
+
.flatMap((f) => fs.readFileSync(path.join(auditDir, f), 'utf8')
|
|
413
|
+
.split('\n').filter(Boolean).map((l) => JSON.parse(l)));
|
|
414
|
+
};
|
|
415
|
+
// The writer is fire-and-forget (`void writer.append(...)`) so the audit write must never
|
|
416
|
+
// delay a tool call. Poll briefly rather than sleeping a fixed amount.
|
|
417
|
+
const waitForEvent = async (eventType,
|
|
418
|
+
// Without a predicate this matches the FIRST row of the type, which may be one an earlier
|
|
419
|
+
// test wrote — and then the row under test may not have landed yet. Pass a predicate
|
|
420
|
+
// whenever a specific row is meant.
|
|
421
|
+
where = () => true, timeoutMs = 5_000) => {
|
|
422
|
+
const deadline = Date.now() + timeoutMs;
|
|
423
|
+
for (;;) {
|
|
424
|
+
const hit = rows().find((r) => r.event_type === eventType && where(r.payload));
|
|
425
|
+
if (hit) {
|
|
426
|
+
return hit;
|
|
427
|
+
}
|
|
428
|
+
if (Date.now() > deadline) {
|
|
429
|
+
assert.fail(`no matching "${eventType}" event within ${timeoutMs}ms; saw: ${JSON.stringify(rows().map((r) => r.event_type))}`);
|
|
430
|
+
}
|
|
431
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
suiteSetup(async () => {
|
|
435
|
+
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
436
|
+
fs.writeFileSync(CONNECTOR_PATH, CONNECTOR_SOURCE, 'utf8');
|
|
437
|
+
const sink = await (0, data_plane_2.createDataPlaneAuditSink)(AUDIT_ROOT);
|
|
438
|
+
assert.strictEqual(sink.ok, true, sink.error);
|
|
439
|
+
assert.strictEqual(sink.ephemeral, false, 'an explicit state dir is durable');
|
|
440
|
+
auditDir = sink.auditDir;
|
|
441
|
+
sidecar = await (0, data_plane_1.startDataPlaneSidecar)({
|
|
442
|
+
config: { connectorPath: CONNECTOR_PATH, provider: 'identity' },
|
|
443
|
+
registerToken: TOKEN,
|
|
444
|
+
gatewayPort: 0,
|
|
445
|
+
controlPort: 0,
|
|
446
|
+
bindHost: '127.0.0.1',
|
|
447
|
+
audit: sink.audit,
|
|
448
|
+
});
|
|
449
|
+
controlBase = `http://127.0.0.1:${sidecar.controlPort}`;
|
|
450
|
+
});
|
|
451
|
+
suiteTeardown(async () => {
|
|
452
|
+
await sidecar?.close();
|
|
453
|
+
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
454
|
+
});
|
|
455
|
+
test('an authorized tool call is recorded with the tool and the verified principal', async () => {
|
|
456
|
+
const res = await fetch(`${controlBase}/edge/turn`, {
|
|
457
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'X-Agent-User': 'dana@corp.com' },
|
|
458
|
+
});
|
|
459
|
+
const nonce = res.headers.get(data_plane_1.TURN_NONCE_HEADER);
|
|
460
|
+
const who = await callWhoami(sidecar.gatewayUrl, nonce);
|
|
461
|
+
assert.strictEqual(who.userId, 'dana@corp.com');
|
|
462
|
+
const ev = await waitForEvent('tool_authorize');
|
|
463
|
+
const payload = ev.payload;
|
|
464
|
+
assert.strictEqual(payload.tool, 'whoami');
|
|
465
|
+
assert.strictEqual(payload.userId, (0, identity_1.hashIdentity)('dana@corp.com'));
|
|
466
|
+
assert.strictEqual(payload.provider, 'identity');
|
|
467
|
+
});
|
|
468
|
+
test('an edge registration is recorded', async () => {
|
|
469
|
+
const ev = await waitForEvent('sidecar_edge_register');
|
|
470
|
+
assert.strictEqual(ev.payload.userId, (0, identity_1.hashIdentity)('dana@corp.com'));
|
|
471
|
+
});
|
|
472
|
+
/**
|
|
473
|
+
* These records ship wholesale to the evidence server, so whatever lands here leaves the
|
|
474
|
+
* machine. The tool-event lane already pseudonymises (ObservableStreamEmitter → hashIdentity);
|
|
475
|
+
* the data-plane lane must not be the one that exports raw addresses. In EDGE mode the sidecar
|
|
476
|
+
* registers with `sessionKey: userId`, so without this the evidence FILE is `alice@corp.com.jsonl`.
|
|
477
|
+
*/
|
|
478
|
+
test('no raw address reaches the record — not in a payload, not in a filename', () => {
|
|
479
|
+
const files = fs.readdirSync(auditDir);
|
|
480
|
+
assert.ok(files.length > 0, 'the suite must have produced evidence by now');
|
|
481
|
+
for (const f of files) {
|
|
482
|
+
assert.ok(!f.includes('@'), `evidence filename must not carry an address: ${f}`);
|
|
483
|
+
}
|
|
484
|
+
const serialized = JSON.stringify(rows());
|
|
485
|
+
const anon = (0, identity_1.hashIdentity)('dana@corp.com');
|
|
486
|
+
assert.ok(anon, 'hashIdentity must yield a pseudonym for a non-empty address');
|
|
487
|
+
assert.ok(!serialized.includes('dana@corp.com'), 'a raw address must never reach the record');
|
|
488
|
+
assert.ok(serialized.includes(anon), 'the salted hash is the join key and must be present');
|
|
489
|
+
});
|
|
490
|
+
test('a rejected registration is recorded — the reject path is the one an auditor asks about', async () => {
|
|
491
|
+
const res = await fetch(`${controlBase}/edge/turn`, {
|
|
492
|
+
headers: { Authorization: 'Bearer wrong-token', 'X-Agent-User': 'mallory@corp.com' },
|
|
493
|
+
});
|
|
494
|
+
assert.strictEqual(res.status, 401);
|
|
495
|
+
const ev = await waitForEvent('sidecar_register_reject');
|
|
496
|
+
assert.strictEqual(ev.payload.reason, 'bad_token');
|
|
497
|
+
});
|
|
498
|
+
test('an unknown nonce at the gateway is recorded, and never logs the raw nonce', async () => {
|
|
499
|
+
await assert.rejects(() => callWhoami(sidecar.gatewayUrl, 'forged-nonce-value'), /401|Unauthorized/i);
|
|
500
|
+
const ev = await waitForEvent('nonce_reject');
|
|
501
|
+
const payload = ev.payload;
|
|
502
|
+
assert.strictEqual(payload.reason, 'unknown_or_expired');
|
|
503
|
+
assert.ok(payload.nonce_sha256_prefix, 'a hash prefix must be present');
|
|
504
|
+
const serialized = JSON.stringify(rows());
|
|
505
|
+
assert.ok(!serialized.includes('forged-nonce-value'), 'a raw nonce must never reach the audit record');
|
|
506
|
+
});
|
|
507
|
+
test('the assertion is fingerprinted in the record, never carried raw', async () => {
|
|
508
|
+
const secret = 'super-secret-user-assertion-value';
|
|
509
|
+
await fetch(`${controlBase}/edge/turn`, {
|
|
510
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'X-Agent-User': 'erin@corp.com', [data_plane_1.ASSERTION_HEADER]: `Bearer ${secret}` },
|
|
511
|
+
});
|
|
512
|
+
const ev = await waitForEvent('sidecar_edge_register', (p) => p.userId === (0, identity_1.hashIdentity)('erin@corp.com'));
|
|
513
|
+
assert.match(String(ev.payload.assertion), /^sha256:[0-9a-f]{12}$/, 'a hash prefix must prove which assertion the turn used');
|
|
514
|
+
assert.ok(!JSON.stringify(rows()).includes(secret), 'a user assertion is a bearer credential and must never reach the record');
|
|
515
|
+
});
|
|
516
|
+
test('an unwritable audit root reports ok:false rather than throwing, so the caller sets the policy', async () => {
|
|
517
|
+
// A path whose parent is a FILE — mkdir -p cannot succeed.
|
|
518
|
+
const blocker = path.join(FIXTURE_DIR, 'not-a-dir');
|
|
519
|
+
fs.writeFileSync(blocker, 'x', 'utf8');
|
|
520
|
+
const sink = await (0, data_plane_2.createDataPlaneAuditSink)(path.join(blocker, 'nested'));
|
|
521
|
+
assert.strictEqual(sink.ok, false);
|
|
522
|
+
assert.ok(sink.error, 'the failure reason must be reportable to the operator');
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
/**
|
|
526
|
+
* PLAN/agent-identity.md §10.6 — the audit stamp and the agent field.
|
|
527
|
+
*
|
|
528
|
+
* Design §1's third failure was "the auditor's second question is unanswerable" — *why could the
|
|
529
|
+
* invoice agent reach the HR schema at all*. PR-0 gave the record a writer, which made "which user
|
|
530
|
+
* touched which rows" answerable. It did not make "via which agent" answerable: rows carried
|
|
531
|
+
* `{tool, userId, provider}` and nothing about the pack. The agent was only ever IMPLIED, by which
|
|
532
|
+
* deployment happened to write the file — and that implication is exactly what breaks when two
|
|
533
|
+
* serves share one connector (§4.4).
|
|
534
|
+
*/
|
|
535
|
+
suite('data-plane: every audit row names the AGENT, not just the user', function () {
|
|
536
|
+
this.timeout(30_000);
|
|
537
|
+
const TOKEN = 'agent-stamp-token';
|
|
538
|
+
const AUDIT_ROOT = path.join(FIXTURE_DIR, 'agent-stamp-state');
|
|
539
|
+
const LOADED = { pack: 'invoice-agent', version: '1.4.0' };
|
|
540
|
+
let sidecar;
|
|
541
|
+
let controlBase;
|
|
542
|
+
let auditDir;
|
|
543
|
+
const rows = () => (fs.existsSync(auditDir) ? fs.readdirSync(auditDir) : [])
|
|
544
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
545
|
+
.flatMap((f) => fs.readFileSync(path.join(auditDir, f), 'utf8')
|
|
546
|
+
.split('\n').filter(Boolean).map((l) => JSON.parse(l)));
|
|
547
|
+
const waitFor = async (pred) => {
|
|
548
|
+
const deadline = Date.now() + 5_000;
|
|
549
|
+
for (;;) {
|
|
550
|
+
const hit = rows().find(pred);
|
|
551
|
+
if (hit) {
|
|
552
|
+
return hit;
|
|
553
|
+
}
|
|
554
|
+
if (Date.now() > deadline) {
|
|
555
|
+
assert.fail(`no matching row; saw ${JSON.stringify(rows().map((r) => r.event_type))}`);
|
|
556
|
+
}
|
|
557
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
suiteSetup(async () => {
|
|
561
|
+
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
562
|
+
fs.writeFileSync(CONNECTOR_PATH, CONNECTOR_SOURCE, 'utf8');
|
|
563
|
+
const sink = await (0, data_plane_2.createDataPlaneAuditSink)(AUDIT_ROOT, LOADED);
|
|
564
|
+
auditDir = sink.auditDir;
|
|
565
|
+
sidecar = await (0, data_plane_1.startDataPlaneSidecar)({
|
|
566
|
+
config: { connectorPath: CONNECTOR_PATH, provider: 'identity' },
|
|
567
|
+
registerToken: TOKEN, gatewayPort: 0, controlPort: 0, bindHost: '127.0.0.1',
|
|
568
|
+
audit: sink.audit, agent: LOADED,
|
|
569
|
+
});
|
|
570
|
+
controlBase = `http://127.0.0.1:${sidecar.controlPort}`;
|
|
571
|
+
});
|
|
572
|
+
suiteTeardown(async () => {
|
|
573
|
+
await sidecar?.close();
|
|
574
|
+
fs.rmSync(FIXTURE_DIR, { recursive: true, force: true });
|
|
575
|
+
});
|
|
576
|
+
test('a tool call records WHICH AGENT served it — the auditor\'s second question', async () => {
|
|
577
|
+
const res = await fetch(`${controlBase}/edge/turn`, {
|
|
578
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'X-Agent-User': 'alice@corp.com' },
|
|
579
|
+
});
|
|
580
|
+
await callWhoami(sidecar.gatewayUrl, res.headers.get(data_plane_1.TURN_NONCE_HEADER));
|
|
581
|
+
const ev = await waitFor((r) => r.event_type === 'tool_authorize');
|
|
582
|
+
assert.deepStrictEqual(ev.payload.agent, { ...LOADED, digest: null }, 'without this the row says which USER touched the rows and nothing about the agent');
|
|
583
|
+
});
|
|
584
|
+
test('the sidecar claims no digest it has not verified', async () => {
|
|
585
|
+
const ev = await waitFor((r) => r.event_type === 'tool_authorize');
|
|
586
|
+
assert.strictEqual(ev.payload.agent.digest, null, 'pack-loader does not verify signatures in the sidecar; a digest here would dress an '
|
|
587
|
+
+ 'unverified read as an attestation');
|
|
588
|
+
});
|
|
589
|
+
test('PR-8: a registration records the agent it CLAIMS, marked as asserted', async () => {
|
|
590
|
+
await fetch(`${controlBase}/turns`, {
|
|
591
|
+
method: 'POST',
|
|
592
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
593
|
+
body: JSON.stringify({ userId: 'bob@corp.com', sessionKey: 's-claim', agent: { pack: 'invoice-agent' } }),
|
|
594
|
+
});
|
|
595
|
+
const ev = await waitFor((r) => r.event_type === 'sidecar_register' && r.payload.claimedAgent === 'invoice-agent');
|
|
596
|
+
assert.strictEqual(ev.payload.binding, 'asserted', 'the register token is shared, so the claim is only as good as whoever holds it — '
|
|
597
|
+
+ 'evidence must never present it as verified');
|
|
598
|
+
assert.strictEqual(ev.payload.agentMismatch, undefined);
|
|
599
|
+
});
|
|
600
|
+
test('PR-8 / §4.4: a DIFFERENT agent on the same connector is flagged, not silently accepted', async () => {
|
|
601
|
+
// The invariant E2 rests on: one connector, one agent. It is unenforceable over a shared
|
|
602
|
+
// bearer, so the deliverable is that it becomes VISIBLE the moment it is violated.
|
|
603
|
+
const res = await fetch(`${controlBase}/turns`, {
|
|
604
|
+
method: 'POST',
|
|
605
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
606
|
+
body: JSON.stringify({ userId: 'mallory@corp.com', sessionKey: 's-other', agent: { pack: 'hr-agent' } }),
|
|
607
|
+
});
|
|
608
|
+
assert.strictEqual(res.status, 200, 'it must still register — refusing on an unverifiable claim would let anyone with the '
|
|
609
|
+
+ 'token deny service by lying about the pack');
|
|
610
|
+
const ev = await waitFor((r) => r.event_type === 'sidecar_register' && r.payload.claimedAgent === 'hr-agent');
|
|
611
|
+
assert.strictEqual(ev.payload.agentMismatch, true);
|
|
612
|
+
assert.strictEqual(ev.payload.loadedAgent, 'invoice-agent');
|
|
613
|
+
});
|
|
614
|
+
test('a registration with no agent claim still works — the field is optional', async () => {
|
|
615
|
+
const res = await fetch(`${controlBase}/turns`, {
|
|
616
|
+
method: 'POST',
|
|
617
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
618
|
+
body: JSON.stringify({ userId: 'carol@corp.com', sessionKey: 's-none' }),
|
|
619
|
+
});
|
|
620
|
+
assert.strictEqual(res.status, 200);
|
|
621
|
+
const ev = await waitFor((r) => r.event_type === 'sidecar_register' && r.payload.claimedAgent === 'none');
|
|
622
|
+
assert.strictEqual(ev.payload.agentMismatch, undefined, 'absent is not a mismatch');
|
|
623
|
+
});
|
|
624
|
+
});
|
|
@@ -424,7 +424,7 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
424
424
|
const rendered = svc
|
|
425
425
|
.replace(/\$\{SERVICE\}/g, 'customer-service-edge')
|
|
426
426
|
.replace(/\$\{IMAGE\}/g, 'us-docker.pkg.dev/x/y/z:latest')
|
|
427
|
-
.replace(/\$\{LITELLM_IMAGE\}/g, 'ghcr.io/agenthippo
|
|
427
|
+
.replace(/\$\{LITELLM_IMAGE\}/g, 'ghcr.io/agenthippoai/agenthippo-litellm:2026.06')
|
|
428
428
|
.replace(/\$\{PUBLIC_HOSTNAME\}/g, 'cs.agenthippo.ai')
|
|
429
429
|
.replace(/\$\{ALLOWED_DOMAINS\}/g, 'agenthippo.ai');
|
|
430
430
|
assert.doesNotThrow(() => yaml.load(rendered), 'rendered service.yaml is valid YAML');
|
|
@@ -684,6 +684,12 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
684
684
|
const snippet = (0, deploy_init_1.renderTarget)('vm', ctx)['agent-route.caddy'];
|
|
685
685
|
// Hygiene: a client-supplied nonce header must never pass through.
|
|
686
686
|
assert.ok(snippet.includes('request_header -X-Agent-Turn-Nonce'), 'inbound X-Agent-Turn-Nonce is stripped at the edge');
|
|
687
|
+
// PR-F1 (agent-identity.md §8.5 G1): the edge must forward the user ASSERTION, not just
|
|
688
|
+
// the identifier, or the turn carries no inboundToken — `passthrough` then fails closed on
|
|
689
|
+
// every call and an `exchange` provider has no subject_token. Forged copies are stripped
|
|
690
|
+
// for the same reason as the nonce: this one becomes the turn's inboundToken.
|
|
691
|
+
assert.ok(snippet.includes('request_header -X-Agent-User-Assertion'), 'inbound X-Agent-User-Assertion is stripped at the edge');
|
|
692
|
+
assert.ok(snippet.includes('copy_headers X-Agent-User X-Agent-User-Assertion'), 'the verified assertion is copied from /api/ide/me alongside the identity');
|
|
687
693
|
// Only POST chat turns register.
|
|
688
694
|
assert.ok(snippet.includes('@turnStart'), 'chat-turn matcher present');
|
|
689
695
|
assert.ok(/@turnStart\s*\{\s*\n\s*method POST\s*\n\s*path \/v1\/chat \/v1\/chat\/stream/m.test(snippet), '@turnStart matches POST /v1/chat and /v1/chat/stream only');
|
|
@@ -47,7 +47,12 @@ function fakeSandboxes(initial = [], supportsInFlightTermination = true) {
|
|
|
47
47
|
destroyed: [],
|
|
48
48
|
revoked: [],
|
|
49
49
|
supportsInFlightTermination,
|
|
50
|
-
|
|
50
|
+
// Session scope: one sandbox per session, so its key IS the session key.
|
|
51
|
+
sandboxes() {
|
|
52
|
+
return state.live.map((sessionKey) => ({
|
|
53
|
+
sandboxKey: sessionKey, sandboxId: `sandbox-${sessionKey}`, sessionKeys: [sessionKey],
|
|
54
|
+
}));
|
|
55
|
+
},
|
|
51
56
|
async destroySession(sessionKey) {
|
|
52
57
|
state.destroyed.push(sessionKey);
|
|
53
58
|
state.live = state.live.filter((s) => s !== sessionKey);
|
|
@@ -47,6 +47,7 @@ const assert = __importStar(require("assert"));
|
|
|
47
47
|
const caps_1 = require("../pack/caps");
|
|
48
48
|
const caps_2 = require("../pack/caps");
|
|
49
49
|
const lease_schema_1 = require("../pack/lease-schema");
|
|
50
|
+
const lease_1 = require("../pack/lease");
|
|
50
51
|
const base = { pack: 'analyst', enabled: true, expiresAt: 1_800_003_600 };
|
|
51
52
|
suite('lease validation — structural junk is refused, not trusted', () => {
|
|
52
53
|
const rejected = [
|
|
@@ -63,6 +64,7 @@ suite('lease validation — structural junk is refused, not trusted', () => {
|
|
|
63
64
|
['an unknown deny scope', { ...base, deny: [{ scope: 'tenant', match: 'x', mode: 'kill' }] }],
|
|
64
65
|
['a deny rule with no match', { ...base, deny: [{ scope: 'pack', match: '', mode: 'kill' }] }],
|
|
65
66
|
['a non-boolean requireSignedPack', { ...base, requireSignedPack: 'yes' }],
|
|
67
|
+
['a non-boolean allowServiceTurns', { ...base, allowServiceTurns: 'true' }],
|
|
66
68
|
['not an object at all', 'nope'],
|
|
67
69
|
];
|
|
68
70
|
for (const [name, doc] of rejected) {
|
|
@@ -167,3 +169,56 @@ suite('deprecated cell vocabulary is aliased, loudly', () => {
|
|
|
167
169
|
assert.strictEqual(caps.maxConcurrentSandboxes, 7);
|
|
168
170
|
});
|
|
169
171
|
});
|
|
172
|
+
/**
|
|
173
|
+
* PLAN/agent-identity.md §10.3 — the lease flag that governs unattended runs.
|
|
174
|
+
*
|
|
175
|
+
* Its polarity is the INVERSE of every other flag on the lease. `requireSignedPack` is
|
|
176
|
+
* tighten-only, so absent safely means "nobody asked for the restriction". `allowServiceTurns`
|
|
177
|
+
* GRANTS a standing unattended identity, so absent has to mean deny — otherwise a control plane
|
|
178
|
+
* that has not been taught the field silently authorises unsupervised runs across a fleet.
|
|
179
|
+
*/
|
|
180
|
+
suite('lease: allowServiceTurns (PR-2 — a grant, so absent means deny)', () => {
|
|
181
|
+
test('parseLease carries a real boolean through', () => {
|
|
182
|
+
const res = (0, lease_schema_1.parseLease)({ ...base, allowServiceTurns: true });
|
|
183
|
+
assert.ok(res.ok);
|
|
184
|
+
assert.strictEqual(res.lease.allowServiceTurns, true);
|
|
185
|
+
});
|
|
186
|
+
test('the string "false" is refused, not coerced — it would flip deny into allow', () => {
|
|
187
|
+
// `Boolean("false") === true`. This is the single most dangerous coercion on the document,
|
|
188
|
+
// which is why the flag joined the existing strict-boolean loop rather than getting its own.
|
|
189
|
+
assert.strictEqual((0, lease_schema_1.parseLease)({ ...base, allowServiceTurns: 'false' }).ok, false);
|
|
190
|
+
});
|
|
191
|
+
test('an unknown field is ignored, so an OLDER serve simply never runs service turns', () => {
|
|
192
|
+
// Forward compatibility with no lockstep hazard: field-by-field parsing means a serve that
|
|
193
|
+
// predates the flag reads a lease containing it without erroring, and — because it never
|
|
194
|
+
// consults the flag — denies. Fail-safe in the skew direction that matters.
|
|
195
|
+
const res = (0, lease_schema_1.parseLease)({ ...base, someFutureField: { nested: true } });
|
|
196
|
+
assert.ok(res.ok);
|
|
197
|
+
assert.strictEqual(res.lease.someFutureField, undefined);
|
|
198
|
+
});
|
|
199
|
+
test('managed + lease says true ⇒ allowed', () => {
|
|
200
|
+
assert.deepStrictEqual((0, lease_1.resolveAllowServiceTurns)({ lease: true, managed: true }), { allowed: true, gate: 'lease_allows' });
|
|
201
|
+
});
|
|
202
|
+
test('managed + field absent ⇒ DENIED, and the reason distinguishes it from an explicit false', () => {
|
|
203
|
+
const absent = (0, lease_1.resolveAllowServiceTurns)({ lease: undefined, managed: true });
|
|
204
|
+
assert.strictEqual(absent.allowed, false);
|
|
205
|
+
assert.strictEqual(absent.gate, 'lease_absent_field');
|
|
206
|
+
const explicit = (0, lease_1.resolveAllowServiceTurns)({ lease: false, managed: true });
|
|
207
|
+
assert.strictEqual(explicit.allowed, false);
|
|
208
|
+
assert.strictEqual(explicit.gate, 'lease_denies');
|
|
209
|
+
// Same outcome, different explanation: an operator reading the audit row must be able to
|
|
210
|
+
// tell "nobody enabled it" from "somebody disabled it".
|
|
211
|
+
assert.notStrictEqual((0, lease_1.describeServiceTurnGate)(absent.gate), (0, lease_1.describeServiceTurnGate)(explicit.gate));
|
|
212
|
+
});
|
|
213
|
+
test('unmanaged (no control plane) ⇒ permitted, local env is the only gate', () => {
|
|
214
|
+
const r = (0, lease_1.resolveAllowServiceTurns)({ lease: undefined, managed: false });
|
|
215
|
+
assert.strictEqual(r.allowed, true);
|
|
216
|
+
assert.strictEqual(r.gate, 'unmanaged_no_lease');
|
|
217
|
+
assert.match((0, lease_1.describeServiceTurnGate)(r.gate), /AGENTHIPPO_SERVICE_USER/);
|
|
218
|
+
});
|
|
219
|
+
test('every gate has an operator-facing explanation naming what to change', () => {
|
|
220
|
+
for (const gate of ['lease_allows', 'unmanaged_no_lease', 'lease_absent_field', 'lease_denies']) {
|
|
221
|
+
assert.ok((0, lease_1.describeServiceTurnGate)(gate).length > 20, `${gate} needs a real explanation`);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Copyright (c) AgentHippo.ai. All rights reserved.
|
|
4
|
+
*--------------------------------------------------------------------------------------------*/
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
+
}) : function(o, v) {
|
|
19
|
+
o["default"] = v;
|
|
20
|
+
});
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
const assert = __importStar(require("assert"));
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const os = __importStar(require("os"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const fleetRowDefaults_1 = require("../analytics/dashboard/fleetRowDefaults");
|
|
44
|
+
const localFleetAdapter_1 = require("../analytics/dashboard/localFleetAdapter");
|
|
45
|
+
suite('localFleetAdapter', () => {
|
|
46
|
+
test('mergeSessionJsonlIntoFleetRows adds completed one-shot session to Done', () => {
|
|
47
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fleet-jsonl-merge-'));
|
|
48
|
+
const sessionId = '2e156640-96ed-4106-8e75-44018d221758';
|
|
49
|
+
const scheduleId = 'sched-then-why';
|
|
50
|
+
const started = Date.now() - 2 * 60 * 60_000;
|
|
51
|
+
const ended = started + 60_000;
|
|
52
|
+
fs.writeFileSync(path.join(dir, `${sessionId}.jsonl`), [
|
|
53
|
+
JSON.stringify({ session_id: sessionId, request_id: 'req-1', timestamp: started, event_type: 'turn_start', payload: {} }),
|
|
54
|
+
JSON.stringify({ session_id: sessionId, request_id: 'req-1', timestamp: ended, event_type: 'turn_end', payload: { success: true } }),
|
|
55
|
+
].join('\n') + '\n', 'utf8');
|
|
56
|
+
fs.utimesSync(path.join(dir, `${sessionId}.jsonl`), ended / 1000, ended / 1000);
|
|
57
|
+
const sessionsById = new Map();
|
|
58
|
+
sessionsById.set(`schedule:${scheduleId}`, (0, fleetRowDefaults_1.buildFleetSessionRow)('local', {
|
|
59
|
+
sessionId: `schedule:${scheduleId}`,
|
|
60
|
+
status: 'scheduled',
|
|
61
|
+
startedAt: new Date(started - 60_000).toISOString(),
|
|
62
|
+
endedAt: '',
|
|
63
|
+
taskTitle: 'then why',
|
|
64
|
+
model: 'agentide-cursor::auto',
|
|
65
|
+
engine: 'cursor',
|
|
66
|
+
channelType: 'none',
|
|
67
|
+
channelTarget: sessionId,
|
|
68
|
+
turns: 0,
|
|
69
|
+
totalTokens: 0,
|
|
70
|
+
disconnectCapable: false,
|
|
71
|
+
}));
|
|
72
|
+
const scheduleByChatSessionId = new Map([
|
|
73
|
+
[sessionId, {
|
|
74
|
+
id: scheduleId,
|
|
75
|
+
sessionId,
|
|
76
|
+
sessionResource: `vscode-chat-session://local/${sessionId}`,
|
|
77
|
+
messageText: 'then why',
|
|
78
|
+
runAt: started - 60_000,
|
|
79
|
+
model: 'agentide-cursor::auto',
|
|
80
|
+
engine: 'cursor',
|
|
81
|
+
}],
|
|
82
|
+
]);
|
|
83
|
+
(0, localFleetAdapter_1.mergeSessionJsonlIntoFleetRows)(sessionsById, scheduleByChatSessionId, { scope: 'global' }, dir);
|
|
84
|
+
assert.strictEqual(sessionsById.has(`schedule:${scheduleId}`), false, 'schedule card should be replaced');
|
|
85
|
+
const row = sessionsById.get(sessionId);
|
|
86
|
+
assert.ok(row, 'expected chat session row');
|
|
87
|
+
assert.strictEqual(row.status, 'done');
|
|
88
|
+
assert.strictEqual(row.turns, 1);
|
|
89
|
+
assert.strictEqual(row.taskTitle, 'then why');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -62,7 +62,7 @@ const IMAGE = 'ghcr.io/agenthippoai/pack@sha256:' + 'a'.repeat(64);
|
|
|
62
62
|
const TEST_SOCK_DIR = path.join(os.tmpdir(), 'ah-sandbox-tests');
|
|
63
63
|
function spec(over = {}) {
|
|
64
64
|
return {
|
|
65
|
-
|
|
65
|
+
sandboxKey: 'sess-1',
|
|
66
66
|
image: IMAGE,
|
|
67
67
|
tier: 2,
|
|
68
68
|
routes: [
|
|
@@ -222,7 +222,7 @@ suite('container driver — the boundary it actually delivers', () => {
|
|
|
222
222
|
const driver = new containerDriver_1.ContainerSandboxDriver({ exec, socketDir: TEST_SOCK_DIR, connectFn: () => stubChannel(), socketWaitMs: 0 });
|
|
223
223
|
const h1 = await driver.create(spec());
|
|
224
224
|
await driver.revoke(h1, 'all');
|
|
225
|
-
const h2 = await driver.create(spec({
|
|
225
|
+
const h2 = await driver.create(spec({ sandboxKey: 'sess-2' }));
|
|
226
226
|
await driver.destroy(h2);
|
|
227
227
|
assert.strictEqual(calls.filter((c) => c[0] === 'rm').length, 2);
|
|
228
228
|
});
|
|
@@ -56,7 +56,7 @@ function tmpdir() {
|
|
|
56
56
|
}
|
|
57
57
|
function spec(over = {}) {
|
|
58
58
|
return {
|
|
59
|
-
|
|
59
|
+
sandboxKey: 'sess-1',
|
|
60
60
|
image: IMAGE,
|
|
61
61
|
tier: 2,
|
|
62
62
|
routes: [{ kind: 'inference', target: 'litellm:4000' }],
|
|
@@ -297,6 +297,17 @@ suite('provider key passthrough into a sandbox', () => {
|
|
|
297
297
|
});
|
|
298
298
|
assert.deepStrictEqual(Object.keys(env).sort(), ['PATH']);
|
|
299
299
|
});
|
|
300
|
+
test('boot-stable route URLs cross into a tier-0 sandbox (MCP + inference proxy)', () => {
|
|
301
|
+
const env = (0, processDriver_1.baseSandboxEnv)({}, {
|
|
302
|
+
PATH: '/usr/bin',
|
|
303
|
+
AGENTHIPPO_DATA_MCP_URL: 'http://connector:9090/mcp',
|
|
304
|
+
AGENTHIPPO_LITELLM_BASE_URL: 'http://gateway:3456/v1',
|
|
305
|
+
ANTHROPIC_API_KEY: 'sk-ant',
|
|
306
|
+
});
|
|
307
|
+
assert.strictEqual(env.AGENTHIPPO_DATA_MCP_URL, 'http://connector:9090/mcp');
|
|
308
|
+
assert.strictEqual(env.AGENTHIPPO_LITELLM_BASE_URL, 'http://gateway:3456/v1');
|
|
309
|
+
assert.strictEqual(env.ANTHROPIC_API_KEY, undefined);
|
|
310
|
+
});
|
|
300
311
|
test('a sandbox holding a provider key is flagged in its own environment', async () => {
|
|
301
312
|
const spawned = [];
|
|
302
313
|
const driver = new processDriver_1.ProcessSandboxDriver({
|
|
@@ -65,7 +65,7 @@ function fakeDriver(engineFor) {
|
|
|
65
65
|
kind: 'process', tier: 0,
|
|
66
66
|
async create(spec) {
|
|
67
67
|
const id = `sandbox-${++seq}`;
|
|
68
|
-
created.push(spec.
|
|
68
|
+
created.push(spec.sandboxKey);
|
|
69
69
|
specs.push(spec);
|
|
70
70
|
const toRunner = new node_stream_1.PassThrough();
|
|
71
71
|
const toServe = new node_stream_1.PassThrough();
|