@agenthippo-ai/agenthippo 0.1.0 → 0.1.1
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/analytics/sessionEvents.js +5 -0
- package/out/auth-broker/pkce.js +3 -1
- package/out/auth-broker/server.js +7 -1
- package/out/auth-broker/store.js +6 -5
- package/out/cli/commands/serve.js +131 -39
- package/out/cli/credential-context.js +16 -0
- package/out/cli/deploy-init.js +110 -9
- package/out/cli/index.js +9 -1
- package/out/cli/serve-hardening.js +158 -12
- package/out/cli/state-sync.js +317 -29
- package/out/headless/index.js +5 -2
- package/out/mcp.js +12 -3
- package/out/remote/handleRemoteChat.js +7 -1
- package/out/remote/serveClient.js +27 -13
- package/out/shared/providers.js +3 -0
- package/out/test/analytics.test.js +68 -0
- package/out/test/auth-broker.test.js +6 -2
- package/out/test/credential-context.test.js +60 -0
- package/out/test/deploy-init.test.js +32 -1
- package/out/test/mcp.test.js +17 -1
- package/out/test/serve-hardening.test.js +107 -0
- package/out/test/serveClient.test.js +5 -2
- package/out/test/state-sync-peer.test.js +70 -1
- package/package.json +3 -4
|
@@ -43,6 +43,7 @@ const os = __importStar(require("os"));
|
|
|
43
43
|
const fs_1 = require("fs");
|
|
44
44
|
const base_1 = require("../engines/base");
|
|
45
45
|
const sessionEvents_1 = require("../analytics/sessionEvents");
|
|
46
|
+
const credential_context_1 = require("../cli/credential-context");
|
|
46
47
|
async function createUniqueTempDir(prefix) {
|
|
47
48
|
return fs_1.promises.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
48
49
|
}
|
|
@@ -522,3 +523,70 @@ suite('Analytics - ObservableStreamEmitter', () => {
|
|
|
522
523
|
assert.notStrictEqual(reqId1, reqId2, 'Request IDs should be different');
|
|
523
524
|
});
|
|
524
525
|
});
|
|
526
|
+
// =============================================================================
|
|
527
|
+
// ObservableStreamEmitter + credential context (the "warrant" audit trail)
|
|
528
|
+
// =============================================================================
|
|
529
|
+
suite('Analytics - ObservableStreamEmitter records the acting user (the warrant)', () => {
|
|
530
|
+
let testDir;
|
|
531
|
+
let writer;
|
|
532
|
+
let innerEmitter;
|
|
533
|
+
setup(async () => {
|
|
534
|
+
testDir = await createUniqueTempDir('agentide-warrant-test-');
|
|
535
|
+
writer = new sessionEvents_1.SessionEventWriter(testDir);
|
|
536
|
+
innerEmitter = {
|
|
537
|
+
async thinking() { },
|
|
538
|
+
async text() { },
|
|
539
|
+
async toolStart() { },
|
|
540
|
+
async toolEnd() { },
|
|
541
|
+
async progress() { },
|
|
542
|
+
async question() { return { selected: [], freeText: null }; },
|
|
543
|
+
async error() { },
|
|
544
|
+
async done() { },
|
|
545
|
+
};
|
|
546
|
+
});
|
|
547
|
+
teardown(async () => {
|
|
548
|
+
try {
|
|
549
|
+
await removeDirWithRetries(testDir);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
// ignore
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
async function readEvents(sessionId) {
|
|
556
|
+
const content = await fs_1.promises.readFile(path.join(testDir, `${sessionId}.jsonl`), 'utf8');
|
|
557
|
+
return content.trim().split('\n').map((l) => JSON.parse(l));
|
|
558
|
+
}
|
|
559
|
+
test('toolStart/toolEnd record userId when inside a runWithCredential scope', async () => {
|
|
560
|
+
const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-1', 'req-1');
|
|
561
|
+
await (0, credential_context_1.runWithCredential)({ userId: 'priya@meridian.com' }, async () => {
|
|
562
|
+
await emitter.toolStart('Bash', 'echo hi', 'c1');
|
|
563
|
+
await emitter.toolEnd('Bash', 'c1', 'hi', false);
|
|
564
|
+
});
|
|
565
|
+
const events = await readEvents('sess-warrant-1');
|
|
566
|
+
assert.strictEqual(events.length, 2);
|
|
567
|
+
assert.strictEqual(events[0].payload.userId, 'priya@meridian.com');
|
|
568
|
+
assert.strictEqual(events[1].payload.userId, 'priya@meridian.com');
|
|
569
|
+
});
|
|
570
|
+
test('toolStart records no userId outside any credential scope (IDE-local usage)', async () => {
|
|
571
|
+
const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-2', 'req-1');
|
|
572
|
+
await emitter.toolStart('Bash', 'echo hi', 'c1');
|
|
573
|
+
const events = await readEvents('sess-warrant-2');
|
|
574
|
+
assert.strictEqual(events[0].payload.userId, undefined);
|
|
575
|
+
});
|
|
576
|
+
test('two interleaved turns never attribute a tool call to the wrong user', async () => {
|
|
577
|
+
const emitter = (0, sessionEvents_1.wrapEmitterWithObservability)(innerEmitter, writer, 'sess-warrant-3', 'req-1');
|
|
578
|
+
await Promise.all([
|
|
579
|
+
(0, credential_context_1.runWithCredential)({ userId: 'alice@acme.com' }, async () => {
|
|
580
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
581
|
+
await emitter.toolStart('Bash', 'a', 'a1');
|
|
582
|
+
}),
|
|
583
|
+
(0, credential_context_1.runWithCredential)({ userId: 'bob@acme.com' }, async () => {
|
|
584
|
+
await emitter.toolStart('Bash', 'b', 'b1');
|
|
585
|
+
}),
|
|
586
|
+
]);
|
|
587
|
+
const events = await readEvents('sess-warrant-3');
|
|
588
|
+
const byCallId = Object.fromEntries(events.map((e) => [e.payload.callId, e.payload.userId]));
|
|
589
|
+
assert.strictEqual(byCallId['a1'], 'alice@acme.com');
|
|
590
|
+
assert.strictEqual(byCallId['b1'], 'bob@acme.com');
|
|
591
|
+
});
|
|
592
|
+
});
|
|
@@ -154,7 +154,10 @@ suite('AuthBroker', () => {
|
|
|
154
154
|
code_challenge: (0, pkce_js_1.codeChallengeFromVerifier)((0, pkce_js_1.generateCodeVerifier)()),
|
|
155
155
|
}),
|
|
156
156
|
});
|
|
157
|
-
assert.strictEqual(res.status,
|
|
157
|
+
assert.strictEqual(res.status, 403);
|
|
158
|
+
const body = await res.json();
|
|
159
|
+
assert.strictEqual(body.error, 'forbidden');
|
|
160
|
+
assert.ok(body.error_description?.includes('domain'));
|
|
158
161
|
}
|
|
159
162
|
finally {
|
|
160
163
|
await broker.close();
|
|
@@ -198,7 +201,8 @@ suite('AuthBroker', () => {
|
|
|
198
201
|
assert.strictEqual(refreshRes.status, 200);
|
|
199
202
|
const body = await refreshRes.json();
|
|
200
203
|
assert.strictEqual(body.routing.litellmApiKey, 'ahk_test_key');
|
|
201
|
-
|
|
204
|
+
// Static team key — the broker must not fabricate an expiry it can't enforce.
|
|
205
|
+
assert.strictEqual(body.routing.litellmApiKeyExpiresAt, undefined);
|
|
202
206
|
}
|
|
203
207
|
finally {
|
|
204
208
|
await broker.close();
|
|
@@ -41,6 +41,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
41
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
42
|
require("mocha");
|
|
43
43
|
const assert = __importStar(require("assert"));
|
|
44
|
+
const fs = __importStar(require("fs"));
|
|
45
|
+
const os = __importStar(require("os"));
|
|
46
|
+
const path = __importStar(require("path"));
|
|
44
47
|
const credential_context_1 = require("../cli/credential-context");
|
|
45
48
|
const serve_hardening_1 = require("../cli/serve-hardening");
|
|
46
49
|
suite('credential-context: presence + fail-closed', () => {
|
|
@@ -74,6 +77,63 @@ suite('credential-context: presence + fail-closed', () => {
|
|
|
74
77
|
});
|
|
75
78
|
});
|
|
76
79
|
});
|
|
80
|
+
suite('credential-context: unresolved identity (userId is optional, never a placeholder)', () => {
|
|
81
|
+
test('no userId, no data token: runs fine, requireUserId throws inside the scope', async () => {
|
|
82
|
+
const seen = await (0, credential_context_1.runWithCredential)({ userId: undefined }, async () => {
|
|
83
|
+
assert.throws(() => (0, credential_context_1.requireUserId)(), /fail-closed/);
|
|
84
|
+
return 'ran';
|
|
85
|
+
});
|
|
86
|
+
assert.strictEqual(seen, 'ran');
|
|
87
|
+
});
|
|
88
|
+
test('a data token without a verified userId fails closed before the turn runs', async () => {
|
|
89
|
+
let ran = false;
|
|
90
|
+
await assert.rejects(() => (0, credential_context_1.runWithCredential)({ userId: undefined, dataToken: 'obo-secret' }, async () => {
|
|
91
|
+
ran = true;
|
|
92
|
+
}), /OBO data token present without a verified user/);
|
|
93
|
+
assert.strictEqual(ran, false, 'fn must never run when the credential fails closed');
|
|
94
|
+
});
|
|
95
|
+
test('a data token without userId fails closed even when dataTokenDir would write a file', async () => {
|
|
96
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-cred-'));
|
|
97
|
+
try {
|
|
98
|
+
await assert.rejects(() => (0, credential_context_1.runWithCredential)({ userId: undefined, dataToken: 'obo-secret' }, async () => 'ran', { dataTokenDir: dir }), /fail-closed/);
|
|
99
|
+
assert.deepStrictEqual(fs.readdirSync(dir), [], 'no token file must be written when fail-closed');
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
test('onDenied fires with the stable reason code exactly when the credential fails closed', async () => {
|
|
106
|
+
const denials = [];
|
|
107
|
+
await assert.rejects(() => (0, credential_context_1.runWithCredential)({ userId: undefined, dataToken: 'obo-secret' }, async () => 'ran', { onDenied: (reason) => { denials.push(reason); } }));
|
|
108
|
+
assert.deepStrictEqual(denials, [credential_context_1.WARRANT_DENIED_REASON]);
|
|
109
|
+
});
|
|
110
|
+
test('onDenied does not fire on any success path (verified user, or no token at all)', async () => {
|
|
111
|
+
const denials = [];
|
|
112
|
+
const onDenied = (reason) => { denials.push(reason); };
|
|
113
|
+
await (0, credential_context_1.runWithCredential)({ userId: 'priya@meridian.com', dataToken: 'tok' }, async () => 'ran', { onDenied });
|
|
114
|
+
await (0, credential_context_1.runWithCredential)({ userId: undefined }, async () => 'ran', { onDenied });
|
|
115
|
+
assert.deepStrictEqual(denials, []);
|
|
116
|
+
});
|
|
117
|
+
test('a throwing onDenied callback is swallowed — the fail-closed rejection still wins', async () => {
|
|
118
|
+
await assert.rejects(() => (0, credential_context_1.runWithCredential)({ userId: undefined, dataToken: 'obo-secret' }, async () => 'ran', { onDenied: () => { throw new Error('logging backend is down'); } }), /fail-closed/);
|
|
119
|
+
});
|
|
120
|
+
test('a verified userId with a data token still writes the per-turn token file normally', async () => {
|
|
121
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-cred-'));
|
|
122
|
+
try {
|
|
123
|
+
let fileDuringRun;
|
|
124
|
+
await (0, credential_context_1.runWithCredential)({ userId: 'priya@meridian.com', dataToken: 'obo-secret' }, async () => {
|
|
125
|
+
fileDuringRun = (0, credential_context_1.currentCredential)()?.dataTokenFile;
|
|
126
|
+
assert.ok(fileDuringRun, 'dataTokenFile must be set inside the scope');
|
|
127
|
+
assert.strictEqual(fs.readFileSync(fileDuringRun, 'utf8'), 'obo-secret');
|
|
128
|
+
}, { dataTokenDir: dir });
|
|
129
|
+
assert.ok(fileDuringRun);
|
|
130
|
+
assert.strictEqual(fs.existsSync(fileDuringRun), false, 'token file must be cleaned up after the turn');
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
77
137
|
suite('credential-context: concurrent isolation (the load-bearing property)', () => {
|
|
78
138
|
test('two interleaved turns never see each other\'s user', async () => {
|
|
79
139
|
// Interleave alice and bob with deliberate awaits; each must keep its own identity.
|
|
@@ -115,6 +115,7 @@ suite('deploy-init: renderEnvExample', () => {
|
|
|
115
115
|
assert.ok(out.includes('OAUTH2_PROXY_REDIRECT_URL='));
|
|
116
116
|
assert.ok(out.includes('OAUTH2_PROXY_REVERSE_PROXY=true'));
|
|
117
117
|
assert.ok(out.includes('AGENTHIPPO_AUTH_JWT_SECRET='));
|
|
118
|
+
assert.ok(out.includes('AGENTHIPPO_AUTH_DEV=0'), 'AUTH_DEV must default off in generated auth env');
|
|
118
119
|
});
|
|
119
120
|
test('renderAuthEnvExample honors custom hostname', () => {
|
|
120
121
|
const out = (0, deploy_init_1.renderAuthEnvExample)({ hostname: 'agents.example.com' });
|
|
@@ -220,7 +221,7 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
220
221
|
assert.deepStrictEqual(app.user_authorization?.scopes, ['sql']);
|
|
221
222
|
const pkg = JSON.parse(files['package.json']);
|
|
222
223
|
assert.ok(pkg.dependencies['@anthropic-ai/claude-code']);
|
|
223
|
-
assert.ok(pkg.dependencies['@agenthippo-ai/
|
|
224
|
+
assert.ok(pkg.dependencies['@agenthippo-ai/agenthippo']);
|
|
224
225
|
});
|
|
225
226
|
test('YAML artifacts parse', () => {
|
|
226
227
|
assert.doesNotThrow(() => yaml.load((0, deploy_init_1.renderTarget)('vm', CTX)['docker-compose.yml']));
|
|
@@ -286,6 +287,11 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
286
287
|
assert.ok(!caddy.includes('oauth2-proxy:4180') && !caddy.includes('auth-broker:9780') && !caddy.includes('agent:8080'), 'edge Caddyfile must use localhost upstreams, not docker-DNS names');
|
|
287
288
|
assert.ok(caddy.includes('localhost:4180') && caddy.includes('localhost:9780') && caddy.includes('localhost:8090'), 'edge Caddyfile routes to oauth2-proxy / auth-broker / agent on localhost');
|
|
288
289
|
assert.ok(caddy.includes(':{$PORT}') && caddy.includes('auto_https off'), 'Caddy listens on HTTP :$PORT (Cloud Run terminates TLS)');
|
|
290
|
+
// IDE PKCE authorize must be SSO-gated before the catch-all /oauth/* → broker route.
|
|
291
|
+
assert.ok(caddy.includes('handle /oauth/authorize {'), 'edge Caddyfile gates /oauth/authorize');
|
|
292
|
+
assert.ok(/handle \/oauth\/authorize \{[\s\S]*?forward_auth localhost:4180[\s\S]*?copy_headers X-Auth-Request-Email/.test(caddy), '/oauth/authorize forward_auth copies X-Auth-Request-Email via localhost oauth2-proxy');
|
|
293
|
+
assert.ok(caddy.indexOf('handle /oauth/authorize {') < caddy.indexOf('handle /oauth/* {'), '/oauth/authorize must precede the catch-all /oauth/* handle');
|
|
294
|
+
assert.ok(caddy.includes('handle /oauth/browser-login {'), 'edge Caddyfile gates /oauth/browser-login');
|
|
289
295
|
// Caddyfile is mounted from a Secret (the deploy script upserts it).
|
|
290
296
|
assert.ok(svc.includes('${SERVICE}-caddyfile'), 'Caddyfile mounted from a secret');
|
|
291
297
|
// auth-broker reuses the agent image (CLI ships `auth-broker`).
|
|
@@ -393,6 +399,24 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
393
399
|
const env = (0, deploy_init_1.renderTarget)('cloudrun', ctx)['.env.example'];
|
|
394
400
|
assert.ok(env.includes('CURSOR_API_KEY='));
|
|
395
401
|
});
|
|
402
|
+
test('cloudrun IAP paths pin AGENTHIPPO_IDENTITY_HEADER so X-Agent-User cannot spoof identity', () => {
|
|
403
|
+
// Claude/codex → Spotlight multi-container path (renderCloudrunServiceYaml).
|
|
404
|
+
const spotlight = (0, deploy_init_1.renderTarget)('cloudrun', CTX);
|
|
405
|
+
assert.ok(spotlight['service.yaml'].includes('AGENTHIPPO_IDENTITY_HEADER'), 'Spotlight service.yaml must declare AGENTHIPPO_IDENTITY_HEADER');
|
|
406
|
+
assert.ok(spotlight['cloudrun-deploy.sh'].includes('x-goog-authenticated-user-email'), 'Spotlight deploy script must fill the pin with the IAP header when ENABLE_IAP=1');
|
|
407
|
+
// Cursor → T1-generic single-container path.
|
|
408
|
+
const cursorCtx = { ...CTX, engine: 'cursor', model: 'auto', image: 'ghcr.io/agenthippo/runtime:latest-cursor' };
|
|
409
|
+
const generic = (0, deploy_init_1.renderTarget)('cloudrun', cursorCtx);
|
|
410
|
+
assert.ok(generic['cloudrun-deploy.sh'].includes('AGENTHIPPO_IDENTITY_HEADER=x-goog-authenticated-user-email'), 'T1-generic deploy script must pin AGENTHIPPO_IDENTITY_HEADER to the IAP header');
|
|
411
|
+
// Both must skip the pin when ENABLE_IAP=0 (IAP never sets the header then — pinning
|
|
412
|
+
// unconditionally would make every request resolve to anonymous during bootstrap).
|
|
413
|
+
assert.ok(spotlight['cloudrun-deploy.sh'].includes('ENABLE_IAP'), 'Spotlight pin must be ENABLE_IAP-conditional');
|
|
414
|
+
assert.ok(generic['cloudrun-deploy.sh'].includes('IDENTITY_ENV='), 'T1-generic pin must be ENABLE_IAP-conditional');
|
|
415
|
+
});
|
|
416
|
+
test('databricks-app pins AGENTHIPPO_IDENTITY_HEADER to x-forwarded-email', () => {
|
|
417
|
+
const start = (0, deploy_init_1.renderTarget)('databricks-app', CTX)['start.js'];
|
|
418
|
+
assert.ok(start.includes("AGENTHIPPO_IDENTITY_HEADER: 'x-forwarded-email'"), 'Databricks App start.js must pin identity to the workspace SSO header');
|
|
419
|
+
});
|
|
396
420
|
test('spcs egress is limited to the model API via one EAI', () => {
|
|
397
421
|
const sql = (0, deploy_init_1.renderTarget)('spcs', CTX)['deploy.sql'];
|
|
398
422
|
assert.ok(sql.includes('api.anthropic.com:443'));
|
|
@@ -411,10 +435,17 @@ suite('deploy-init: renderTarget produces well-formed artifacts', () => {
|
|
|
411
435
|
assert.ok(compose.includes('./www:/srv/www'));
|
|
412
436
|
assert.ok(files['run.sh'].includes('127.0.0.1:8080'));
|
|
413
437
|
assert.ok(files['run-edge-tunnel.sh'].includes('--profile edge --profile tunnel'));
|
|
438
|
+
assert.ok(files['Caddyfile'].includes('lb_policy header X-Agent-User'));
|
|
414
439
|
assert.ok(files['Caddyfile'].includes('reverse_proxy agent:8080'));
|
|
415
440
|
assert.ok(files['Caddyfile'].includes('{$AGENTHIPPO_PUBLIC_HOSTNAME}'));
|
|
416
441
|
assert.ok(files['Caddyfile'].includes('@health path /health /ready'));
|
|
417
442
|
assert.ok(!files['Caddyfile'].includes('@api path /v1/* /health /ready'));
|
|
443
|
+
// IDE PKCE authorize must be SSO-gated (oauth2-proxy → X-Auth-Request-Email), not dumped via /oauth/*.
|
|
444
|
+
const caddy = files['Caddyfile'];
|
|
445
|
+
assert.ok(caddy.includes('handle /oauth/authorize {'), 'Caddyfile gates /oauth/authorize');
|
|
446
|
+
assert.ok(/handle \/oauth\/authorize \{[\s\S]*?forward_auth oauth2-proxy:4180[\s\S]*?copy_headers X-Auth-Request-Email/.test(caddy), '/oauth/authorize forward_auth copies X-Auth-Request-Email');
|
|
447
|
+
assert.ok(caddy.indexOf('handle /oauth/authorize {') < caddy.indexOf('handle /oauth/* {'), '/oauth/authorize must precede the catch-all /oauth/* handle');
|
|
448
|
+
assert.ok(caddy.includes('handle /oauth/browser-login {'), 'Caddyfile gates /oauth/browser-login');
|
|
418
449
|
assert.ok(files['www/index.html'].includes('<form id="chat">'));
|
|
419
450
|
// In-compose config-file tunnel (the proven-live path): cloudflared container → caddy:80.
|
|
420
451
|
assert.ok(files['cloudflared.yml'].includes('http://caddy:80'), 'in-compose cloudflared reaches Caddy on the docker network');
|
package/out/test/mcp.test.js
CHANGED
|
@@ -225,10 +225,26 @@ suite('MCP Server Loading', () => {
|
|
|
225
225
|
assert.ok(result.includes('[mcp_servers.test-server]'));
|
|
226
226
|
assert.ok(result.includes('command = "node"'));
|
|
227
227
|
assert.ok(result.includes('args = ["/path/to/server.js"]'));
|
|
228
|
-
assert.ok(result.includes('API_KEY = "secret"'));
|
|
228
|
+
assert.ok(result.includes('"API_KEY" = "secret"'));
|
|
229
229
|
// No approval line by default — interactive IDE keeps Codex's normal prompts.
|
|
230
230
|
assert.ok(!result.includes('default_tools_approval_mode'));
|
|
231
231
|
});
|
|
232
|
+
test('env keys are quoted so names with dots/spaces stay valid TOML', () => {
|
|
233
|
+
const servers = [
|
|
234
|
+
{
|
|
235
|
+
name: 'test-server',
|
|
236
|
+
label: 'Test Server',
|
|
237
|
+
command: 'node',
|
|
238
|
+
args: ['s.js'],
|
|
239
|
+
env: { 'my.token': 'a', 'NPM CONFIG': 'b' },
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
const result = (0, mcp_1.generateCodexMcpConfig)(servers);
|
|
243
|
+
assert.ok(result.includes('"my.token" = "a"'));
|
|
244
|
+
assert.ok(result.includes('"NPM CONFIG" = "b"'));
|
|
245
|
+
// A bare (unquoted) dotted key would be misparsed as a nested table.
|
|
246
|
+
assert.ok(!result.includes('my.token = '));
|
|
247
|
+
});
|
|
232
248
|
test('autoApprove emits default_tools_approval_mode = "auto" per server', () => {
|
|
233
249
|
const servers = [
|
|
234
250
|
{ name: 'stdio-srv', label: 's', command: 'node', args: ['s.js'] },
|
|
@@ -83,6 +83,18 @@ suite('serve-hardening: clientIp', () => {
|
|
|
83
83
|
test('uses the right-most XFF entry (the IP the trusted proxy observed)', () => {
|
|
84
84
|
assert.strictEqual((0, serve_hardening_1.clientIp)(reqWith({ 'x-forwarded-for': 'evil-spoof, 198.51.100.7' })), '198.51.100.7');
|
|
85
85
|
});
|
|
86
|
+
test('AGENTHIPPO_XFF_TRUSTED_HOPS=2 skips the tunnel hop (cloudflared → Caddy)', () => {
|
|
87
|
+
const req = reqWith({ 'x-forwarded-for': '198.51.100.7, 172.18.0.5' });
|
|
88
|
+
assert.strictEqual((0, serve_hardening_1.clientIp)(req, { AGENTHIPPO_XFF_TRUSTED_HOPS: '2' }), '198.51.100.7');
|
|
89
|
+
});
|
|
90
|
+
test('trusted hops beyond the XFF length clamps to the left-most entry', () => {
|
|
91
|
+
const req = reqWith({ 'x-forwarded-for': '198.51.100.7' });
|
|
92
|
+
assert.strictEqual((0, serve_hardening_1.clientIp)(req, { AGENTHIPPO_XFF_TRUSTED_HOPS: '5' }), '198.51.100.7');
|
|
93
|
+
});
|
|
94
|
+
test('invalid trusted hops falls back to 1', () => {
|
|
95
|
+
const req = reqWith({ 'x-forwarded-for': 'evil-spoof, 198.51.100.7' });
|
|
96
|
+
assert.strictEqual((0, serve_hardening_1.clientIp)(req, { AGENTHIPPO_XFF_TRUSTED_HOPS: 'nope' }), '198.51.100.7');
|
|
97
|
+
});
|
|
86
98
|
});
|
|
87
99
|
suite('serve-hardening: RateLimiter', () => {
|
|
88
100
|
test('allows up to the cap within a window then rejects', () => {
|
|
@@ -133,6 +145,16 @@ suite('serve-hardening: resolveUserId', () => {
|
|
|
133
145
|
assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, undefined), undefined);
|
|
134
146
|
assert.strictEqual((0, serve_hardening_1.resolveUserId)({}, { user_id: ' ' }), undefined);
|
|
135
147
|
});
|
|
148
|
+
test('AGENTHIPPO_IDENTITY_HEADER pins trust to one header — spoofed aliases are ignored', () => {
|
|
149
|
+
const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-goog-authenticated-user-email' };
|
|
150
|
+
// IAP strips X-Goog-* but forwards x-agent-user; pinning must ignore the spoof.
|
|
151
|
+
const id = (0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'spoofed@evil.com', 'x-goog-authenticated-user-email': 'accounts.google.com:marco@meridian.com' }, undefined, env);
|
|
152
|
+
assert.strictEqual(id, 'marco@meridian.com');
|
|
153
|
+
});
|
|
154
|
+
test('pinned header absent → fails closed (never the alias list, never the body)', () => {
|
|
155
|
+
const env = { AGENTHIPPO_IDENTITY_HEADER: 'x-goog-authenticated-user-email' };
|
|
156
|
+
assert.strictEqual((0, serve_hardening_1.resolveUserId)({ 'x-agent-user': 'spoofed@evil.com' }, { user_id: 'bob' }, env), undefined);
|
|
157
|
+
});
|
|
136
158
|
});
|
|
137
159
|
suite('serve-hardening: corsHeaders', () => {
|
|
138
160
|
test('always wide-open (serve is always behind a proxy)', () => {
|
|
@@ -169,6 +191,91 @@ suite('serve-hardening: TurnLimiter', () => {
|
|
|
169
191
|
assert.strictEqual(lim.tryConsume('s1'), true);
|
|
170
192
|
});
|
|
171
193
|
});
|
|
194
|
+
suite('serve-hardening: TurnGate', () => {
|
|
195
|
+
test('runs immediately under the inflight cap', async () => {
|
|
196
|
+
const gate = new serve_hardening_1.TurnGate(2, 4, 1_000);
|
|
197
|
+
const a = await gate.acquire();
|
|
198
|
+
const b = await gate.acquire();
|
|
199
|
+
assert.ok(a);
|
|
200
|
+
assert.ok(b);
|
|
201
|
+
assert.strictEqual(gate.inflight, 2);
|
|
202
|
+
assert.strictEqual(gate.queued, 0);
|
|
203
|
+
a();
|
|
204
|
+
b();
|
|
205
|
+
assert.strictEqual(gate.inflight, 0);
|
|
206
|
+
});
|
|
207
|
+
test('queues when at capacity and wakes FIFO on release', async () => {
|
|
208
|
+
const gate = new serve_hardening_1.TurnGate(1, 4, 5_000);
|
|
209
|
+
const first = await gate.acquire();
|
|
210
|
+
assert.ok(first);
|
|
211
|
+
assert.strictEqual(gate.inflight, 1);
|
|
212
|
+
let secondResolved = false;
|
|
213
|
+
const secondPromise = gate.acquire().then((release) => {
|
|
214
|
+
secondResolved = true;
|
|
215
|
+
return release;
|
|
216
|
+
});
|
|
217
|
+
// Let the microtask queue settle so the waiter is enqueued.
|
|
218
|
+
await new Promise((r) => setImmediate(r));
|
|
219
|
+
assert.strictEqual(secondResolved, false);
|
|
220
|
+
assert.strictEqual(gate.queued, 1);
|
|
221
|
+
first();
|
|
222
|
+
const second = await secondPromise;
|
|
223
|
+
assert.strictEqual(secondResolved, true);
|
|
224
|
+
assert.ok(second);
|
|
225
|
+
assert.strictEqual(gate.inflight, 1);
|
|
226
|
+
assert.strictEqual(gate.queued, 0);
|
|
227
|
+
second();
|
|
228
|
+
assert.strictEqual(gate.inflight, 0);
|
|
229
|
+
});
|
|
230
|
+
test('rejects immediately when the queue is full', async () => {
|
|
231
|
+
const gate = new serve_hardening_1.TurnGate(1, 1, 5_000);
|
|
232
|
+
const held = await gate.acquire();
|
|
233
|
+
assert.ok(held);
|
|
234
|
+
const waiting = gate.acquire(); // fills the single queue slot
|
|
235
|
+
await new Promise((r) => setImmediate(r));
|
|
236
|
+
assert.strictEqual(gate.queued, 1);
|
|
237
|
+
const rejected = await gate.acquire();
|
|
238
|
+
assert.strictEqual(rejected, null);
|
|
239
|
+
held();
|
|
240
|
+
const released = await waiting;
|
|
241
|
+
assert.ok(released);
|
|
242
|
+
released();
|
|
243
|
+
});
|
|
244
|
+
test('times out a waiter and returns null', async () => {
|
|
245
|
+
const gate = new serve_hardening_1.TurnGate(1, 4, 30);
|
|
246
|
+
const held = await gate.acquire();
|
|
247
|
+
assert.ok(held);
|
|
248
|
+
const timedOut = await gate.acquire();
|
|
249
|
+
assert.strictEqual(timedOut, null);
|
|
250
|
+
assert.strictEqual(gate.queued, 0);
|
|
251
|
+
held();
|
|
252
|
+
assert.strictEqual(gate.inflight, 0);
|
|
253
|
+
});
|
|
254
|
+
test('zero maxInflight disables the gate', async () => {
|
|
255
|
+
const gate = new serve_hardening_1.TurnGate(0, 0, 1_000);
|
|
256
|
+
const releases = [];
|
|
257
|
+
for (let i = 0; i < 50; i++) {
|
|
258
|
+
const r = await gate.acquire();
|
|
259
|
+
assert.ok(r);
|
|
260
|
+
releases.push(r);
|
|
261
|
+
}
|
|
262
|
+
assert.strictEqual(gate.inflight, 0);
|
|
263
|
+
for (const r of releases) {
|
|
264
|
+
r();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
test('createTurnGateFromEnv honors env defaults and overrides', () => {
|
|
268
|
+
const def = (0, serve_hardening_1.createTurnGateFromEnv)({});
|
|
269
|
+
assert.strictEqual(def.max, 10);
|
|
270
|
+
const custom = (0, serve_hardening_1.createTurnGateFromEnv)({ AGENTHIPPO_MAX_CONCURRENT_TURNS: '3', AGENTHIPPO_TURN_QUEUE: '7' });
|
|
271
|
+
assert.strictEqual(custom.max, 3);
|
|
272
|
+
const disabled = (0, serve_hardening_1.createTurnGateFromEnv)({ AGENTHIPPO_MAX_CONCURRENT_TURNS: '0' });
|
|
273
|
+
assert.strictEqual(disabled.max, 0);
|
|
274
|
+
});
|
|
275
|
+
test('Retry-After constant matches the default queue timeout', () => {
|
|
276
|
+
assert.strictEqual(serve_hardening_1.TURN_GATE_RETRY_AFTER_SECONDS, 15);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
172
279
|
function fakeReq(body) {
|
|
173
280
|
const stream = new stream_1.PassThrough();
|
|
174
281
|
stream.end(Buffer.from(body, 'utf8'));
|
|
@@ -131,13 +131,14 @@ suite('ServeClient', () => {
|
|
|
131
131
|
]);
|
|
132
132
|
try {
|
|
133
133
|
const emitter = createCaptureEmitter();
|
|
134
|
-
await (0, serveClient_js_1.streamRemoteServeChat)({
|
|
134
|
+
const result = await (0, serveClient_js_1.streamRemoteServeChat)({
|
|
135
135
|
baseUrl: mock.url,
|
|
136
136
|
message: 'ping',
|
|
137
137
|
sessionId: 'sess-1',
|
|
138
138
|
}, emitter);
|
|
139
139
|
assert.deepStrictEqual(emitter.events.map((e) => e.type), ['thinking', 'text', 'toolStart', 'toolEnd', 'done']);
|
|
140
140
|
assert.strictEqual(emitter.events[1].payload, 'hello');
|
|
141
|
+
assert.deepStrictEqual(result, { ok: true });
|
|
141
142
|
}
|
|
142
143
|
finally {
|
|
143
144
|
await mock.close();
|
|
@@ -156,9 +157,11 @@ suite('ServeClient', () => {
|
|
|
156
157
|
throw new Error('bad address');
|
|
157
158
|
}
|
|
158
159
|
const emitter = createCaptureEmitter();
|
|
159
|
-
await (0, serveClient_js_1.streamRemoteServeChat)({ baseUrl: `http://127.0.0.1:${address.port}`, message: 'ping' }, emitter);
|
|
160
|
+
const result = await (0, serveClient_js_1.streamRemoteServeChat)({ baseUrl: `http://127.0.0.1:${address.port}`, message: 'ping' }, emitter);
|
|
160
161
|
assert.deepStrictEqual(emitter.events.map((e) => e.type), ['error', 'done']);
|
|
161
162
|
assert.strictEqual(emitter.events[0].payload, 'Unauthorized');
|
|
163
|
+
// The turn failed: the caller must be able to see it (not record success).
|
|
164
|
+
assert.deepStrictEqual(result, { ok: false, error: 'Unauthorized' });
|
|
162
165
|
resolve();
|
|
163
166
|
}
|
|
164
167
|
catch (err) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/*---------------------------------------------------------------------------------------------
|
|
3
|
-
* Tests for serve-v3 #2 (S3 state config)
|
|
3
|
+
* Tests for serve-v3 #2 (S3 state config), #4 (PeerInfo → user_id), and serve-v4 P1.2.
|
|
4
4
|
*--------------------------------------------------------------------------------------------*/
|
|
5
5
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
6
|
if (k2 === undefined) k2 = k;
|
|
@@ -38,6 +38,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
require("mocha");
|
|
40
40
|
const assert = __importStar(require("assert"));
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const os = __importStar(require("os"));
|
|
43
|
+
const path = __importStar(require("path"));
|
|
41
44
|
const state_sync_1 = require("../cli/state-sync");
|
|
42
45
|
const serve_hardening_1 = require("../cli/serve-hardening");
|
|
43
46
|
suite('state-sync: resolveS3StateConfig', () => {
|
|
@@ -53,6 +56,72 @@ suite('state-sync: resolveS3StateConfig', () => {
|
|
|
53
56
|
assert.strictEqual(c?.endpoint, 'http://minio:9000');
|
|
54
57
|
});
|
|
55
58
|
});
|
|
59
|
+
suite('state-sync: restore boot mode', () => {
|
|
60
|
+
test('defaults to all', () => {
|
|
61
|
+
assert.strictEqual((0, state_sync_1.resolveRestoreBootMode)({}), 'all');
|
|
62
|
+
});
|
|
63
|
+
test('none when set', () => {
|
|
64
|
+
assert.strictEqual((0, state_sync_1.resolveRestoreBootMode)({ AGENTHIPPO_STATE_RESTORE_BOOT: 'none' }), 'none');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
suite('state-sync: session id helpers', () => {
|
|
68
|
+
test('safeSessionId strips path chars', () => {
|
|
69
|
+
assert.strictEqual((0, state_sync_1.safeSessionId)('a/b:c'), 'a_b_c');
|
|
70
|
+
assert.strictEqual((0, state_sync_1.safeSessionId)(''), 'session');
|
|
71
|
+
});
|
|
72
|
+
test('sessionRemoteUri nests under sessions/', () => {
|
|
73
|
+
assert.strictEqual((0, state_sync_1.sessionRemoteUri)({ uri: 's3://bkt/p' }, 's1'), 's3://bkt/p/sessions/s1');
|
|
74
|
+
});
|
|
75
|
+
test('shardBelongsToSession matches key and value', () => {
|
|
76
|
+
assert.ok((0, state_sync_1.shardBelongsToSession)('claude:s1:pack:abc', 's1'));
|
|
77
|
+
assert.ok((0, state_sync_1.shardBelongsToSession)('x:other:pack:abc', 's1', { chatSessionId: 's1' }));
|
|
78
|
+
assert.ok(!(0, state_sync_1.shardBelongsToSession)('claude:s2:pack:abc', 's1'));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
suite('state-sync: stage + merge session dirs', () => {
|
|
82
|
+
let tmp;
|
|
83
|
+
setup(() => {
|
|
84
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'state-sync-'));
|
|
85
|
+
(0, state_sync_1.clearRestoreAttemptCache)();
|
|
86
|
+
});
|
|
87
|
+
teardown(() => {
|
|
88
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
89
|
+
(0, state_sync_1.clearRestoreAttemptCache)();
|
|
90
|
+
});
|
|
91
|
+
test('stages only matching provider-session shards + agents', async () => {
|
|
92
|
+
const ps = path.join(tmp, 'provider-sessions');
|
|
93
|
+
fs.mkdirSync(ps, { recursive: true });
|
|
94
|
+
const key1 = 'claude:sess-a:pack:sig';
|
|
95
|
+
const key2 = 'claude:sess-b:pack:sig';
|
|
96
|
+
const f1 = (0, state_sync_1.providerSessionShardName)(key1);
|
|
97
|
+
const f2 = (0, state_sync_1.providerSessionShardName)(key2);
|
|
98
|
+
fs.writeFileSync(path.join(ps, f1), JSON.stringify({ key: key1, value: { chatSessionId: 'sess-a', id: 'n1' } }));
|
|
99
|
+
fs.writeFileSync(path.join(ps, f2), JSON.stringify({ key: key2, value: { chatSessionId: 'sess-b', id: 'n2' } }));
|
|
100
|
+
fs.mkdirSync(path.join(tmp, 'agents', 'pack', 'claude'), { recursive: true });
|
|
101
|
+
fs.writeFileSync(path.join(tmp, 'agents', 'pack', 'claude', 'marker'), 'x');
|
|
102
|
+
const staging = path.join(tmp, 'staging');
|
|
103
|
+
const ok = await (0, state_sync_1.stageSessionPushDir)(tmp, 'sess-a', staging);
|
|
104
|
+
assert.strictEqual(ok, true);
|
|
105
|
+
assert.ok(fs.existsSync(path.join(staging, 'provider-sessions', f1)));
|
|
106
|
+
assert.ok(!fs.existsSync(path.join(staging, 'provider-sessions', f2)));
|
|
107
|
+
assert.ok(fs.existsSync(path.join(staging, 'agents', 'pack', 'claude', 'marker')));
|
|
108
|
+
const shards = await (0, state_sync_1.listSessionShardFiles)(tmp, 'sess-a');
|
|
109
|
+
assert.deepStrictEqual(shards, [f1]);
|
|
110
|
+
assert.strictEqual(await (0, state_sync_1.hasLocalSessionState)(tmp, 'sess-a'), true);
|
|
111
|
+
assert.strictEqual(await (0, state_sync_1.hasLocalSessionState)(tmp, 'sess-missing'), false);
|
|
112
|
+
});
|
|
113
|
+
test('mergeSessionStagingIntoState copies shards and agents', async () => {
|
|
114
|
+
const staging = path.join(tmp, 'from');
|
|
115
|
+
const dest = path.join(tmp, 'to');
|
|
116
|
+
fs.mkdirSync(path.join(staging, 'provider-sessions'), { recursive: true });
|
|
117
|
+
fs.writeFileSync(path.join(staging, 'provider-sessions', 'abc.json'), JSON.stringify({ key: 'claude:s1:p:s', value: { chatSessionId: 's1' } }));
|
|
118
|
+
fs.mkdirSync(path.join(staging, 'agents', 'p'), { recursive: true });
|
|
119
|
+
fs.writeFileSync(path.join(staging, 'agents', 'p', 'f'), '1');
|
|
120
|
+
await (0, state_sync_1.mergeSessionStagingIntoState)(staging, dest);
|
|
121
|
+
assert.ok(fs.existsSync(path.join(dest, 'provider-sessions', 'abc.json')));
|
|
122
|
+
assert.ok(fs.existsSync(path.join(dest, 'agents', 'p', 'f')));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
56
125
|
suite('peerToUserId (#4 — channel sender → per-user id)', () => {
|
|
57
126
|
test('undefined for no peer', () => {
|
|
58
127
|
assert.strictEqual((0, serve_hardening_1.peerToUserId)(undefined), undefined);
|
package/package.json
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agenthippo-ai/agenthippo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "AgentHippo CLI — serve, chat, and deploy agent packs",
|
|
5
5
|
"author": "AgentHippo <dev@agenthippo.ai>",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/agenthippoai/agenthippo-
|
|
10
|
-
"directory": "extensions/agentide"
|
|
9
|
+
"url": "https://github.com/agenthippoai/agenthippo-store.git"
|
|
11
10
|
},
|
|
12
11
|
"homepage": "https://agenthippo.ai",
|
|
13
|
-
"bugs": "https://github.com/agenthippoai/agenthippo-
|
|
12
|
+
"bugs": "https://github.com/agenthippoai/agenthippo-store/issues",
|
|
14
13
|
"engines": {
|
|
15
14
|
"node": ">=20"
|
|
16
15
|
},
|