@agenthippo-ai/agenthippo 0.1.1 → 0.2.3-dataplane
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 +2 -1
- package/out/agentpack/paths.js +23 -0
- package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
- package/out/analytics/dashboard/webview/panel.js +290 -80
- package/out/analytics/sessionEvents.js +21 -9
- package/out/auth/brokerClient.js +1 -1
- package/out/auth/onboardingPrompt.js +112 -0
- package/out/auth/provider.js +1 -1
- package/out/auth-broker/config.js +8 -3
- package/out/auth-broker/server.js +36 -0
- package/out/cli/commands/ask.js +3 -0
- package/out/cli/commands/chat.js +3 -0
- package/out/cli/commands/deploy.js +49 -10
- package/out/cli/commands/gateway.js +54 -0
- package/out/cli/commands/idp.js +85 -0
- package/out/cli/commands/serve.js +315 -55
- package/out/cli/credential-context.js +42 -80
- package/out/cli/data-plane/broker.js +78 -0
- package/out/cli/data-plane/connector-host.js +121 -0
- package/out/cli/data-plane/egress.js +189 -0
- package/out/cli/data-plane/gateway.js +195 -0
- package/out/cli/data-plane/index.js +137 -0
- package/out/cli/data-plane/registry.js +80 -0
- package/out/cli/deploy/blueprints/registry.js +84 -0
- package/out/cli/deploy/layers/identity.js +91 -0
- package/out/cli/deploy-init.js +1105 -335
- package/out/cli/idp-init.js +303 -0
- package/out/cli/index.js +37 -7
- package/out/cli/pack-loader.js +37 -2
- package/out/cli/runtime.js +10 -3
- package/out/cli/serve-hardening.js +60 -1
- package/out/cli/state-sync.js +16 -15
- package/out/engines/claude.js +26 -5
- package/out/engines/codex.js +24 -5
- package/out/engines/custom.js +46 -12
- package/out/extension.js +2 -0
- package/out/gateway/outboundSend.js +6 -6
- package/out/headless/index.js +24 -9
- package/out/live-probe/connector.mjs +8 -0
- package/out/live-probe/env-report.mjs +8 -0
- package/out/mcp.js +85 -67
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
- package/out/shared/models.js +1 -1
- package/out/shared/providers.js +2 -2
- package/out/test/analytics.test.js +42 -17
- package/out/test/auth-broker.test.js +53 -0
- package/out/test/blueprint-registry.test.js +105 -0
- package/out/test/credential-context.test.js +23 -28
- package/out/test/custom-env-layering.test.js +151 -0
- package/out/test/data-plane-engine-wiring.test.js +250 -0
- package/out/test/data-plane.test.js +244 -0
- package/out/test/deploy-init.test.js +255 -4
- package/out/test/egress-broker.test.js +172 -0
- package/out/test/fleetDisplayUtils.test.js +4 -0
- package/out/test/idp-init.test.js +151 -0
- package/out/test/mcp-obo.test.js +52 -27
- package/out/test/mcp.test.js +43 -3
- package/out/test/model-routing.test.js +133 -0
- package/out/test/serve-gateway-turn.test.js +267 -0
- package/out/test/serve-hardening.test.js +80 -0
- package/out/test/serveClient.test.js +32 -0
- package/out/test/state-sync-peer.test.js +18 -5
- package/out/utils.js +27 -11
- package/package.json +1 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Model routing / credential selection tests.
|
|
4
|
+
*
|
|
5
|
+
* Guards the fix that replaced the provider-blind precedence
|
|
6
|
+
* (apiKey = litellm || anthropic || openai) with destination-based selection: the credential
|
|
7
|
+
* is chosen by the endpoint the engine dials, so a codex turn never authenticates to OpenAI
|
|
8
|
+
* with an Anthropic key (the Databricks 401), and a claude turn never uses the OpenAI key.
|
|
9
|
+
*--------------------------------------------------------------------------------------------*/
|
|
10
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
13
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
14
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
15
|
+
}
|
|
16
|
+
Object.defineProperty(o, k2, desc);
|
|
17
|
+
}) : (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
o[k2] = m[k];
|
|
20
|
+
}));
|
|
21
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
22
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
23
|
+
}) : function(o, v) {
|
|
24
|
+
o["default"] = v;
|
|
25
|
+
});
|
|
26
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
27
|
+
var ownKeys = function(o) {
|
|
28
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
29
|
+
var ar = [];
|
|
30
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
31
|
+
return ar;
|
|
32
|
+
};
|
|
33
|
+
return ownKeys(o);
|
|
34
|
+
};
|
|
35
|
+
return function (mod) {
|
|
36
|
+
if (mod && mod.__esModule) return mod;
|
|
37
|
+
var result = {};
|
|
38
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
39
|
+
__setModuleDefault(result, mod);
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
42
|
+
})();
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
require("mocha");
|
|
45
|
+
const assert = __importStar(require("assert"));
|
|
46
|
+
const utils_1 = require("../utils");
|
|
47
|
+
const pack_loader_1 = require("../cli/pack-loader");
|
|
48
|
+
const OPENAI = 'sk-openai-xxx';
|
|
49
|
+
const ANTHROPIC = 'sk-ant-yyy';
|
|
50
|
+
const LITELLM = 'sk-litellm-zzz';
|
|
51
|
+
function opts(over = {}) {
|
|
52
|
+
return { workspaceRoot: '/ws', ...over };
|
|
53
|
+
}
|
|
54
|
+
suite('resolveApiRouting: direct path selects the ENGINE\'s provider key (no collapse)', () => {
|
|
55
|
+
// The exact Databricks scenario: both direct keys present, no proxy configured.
|
|
56
|
+
const bothKeys = { openaiApiKey: OPENAI, anthropicApiKey: ANTHROPIC };
|
|
57
|
+
test('codex (openai) picks the OpenAI key even when an Anthropic key is also present', () => {
|
|
58
|
+
const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: false }), bothKeys, 'openai');
|
|
59
|
+
assert.strictEqual(r.apiKey, OPENAI);
|
|
60
|
+
assert.strictEqual(r.useLiteLLM, false);
|
|
61
|
+
});
|
|
62
|
+
test('claude (anthropic) picks the Anthropic key even when an OpenAI key is also present', () => {
|
|
63
|
+
const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: false }), bothKeys, 'anthropic');
|
|
64
|
+
assert.strictEqual(r.apiKey, ANTHROPIC);
|
|
65
|
+
});
|
|
66
|
+
test('the codex→OpenAI-with-Anthropic-key 401 is impossible now', () => {
|
|
67
|
+
const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: false }), bothKeys, 'openai');
|
|
68
|
+
assert.notStrictEqual(r.apiKey, ANTHROPIC, 'codex must never receive the Anthropic key');
|
|
69
|
+
});
|
|
70
|
+
test('missing provider key → empty (fail visibly), never the other provider\'s key', () => {
|
|
71
|
+
const onlyAnthropic = { anthropicApiKey: ANTHROPIC };
|
|
72
|
+
const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: false }), onlyAnthropic, 'openai');
|
|
73
|
+
assert.strictEqual(r.apiKey, '', 'codex with only an Anthropic key gets no key, not the wrong one');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
suite('resolveApiRouting: LiteLLM path uses the proxy key for every engine', () => {
|
|
77
|
+
const creds = { litellmApiKey: LITELLM, litellmBaseUrl: 'http://proxy:4000', openaiApiKey: OPENAI, anthropicApiKey: ANTHROPIC };
|
|
78
|
+
test('codex through the proxy uses the LiteLLM key + base URL', () => {
|
|
79
|
+
const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: true }), creds, 'openai');
|
|
80
|
+
assert.strictEqual(r.apiKey, LITELLM);
|
|
81
|
+
assert.strictEqual(r.baseUrl, 'http://proxy:4000');
|
|
82
|
+
assert.strictEqual(r.useLiteLLM, true);
|
|
83
|
+
});
|
|
84
|
+
test('claude through the proxy also uses the LiteLLM key (one key routes all)', () => {
|
|
85
|
+
const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: true }), creds, 'anthropic');
|
|
86
|
+
assert.strictEqual(r.apiKey, LITELLM);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
suite('resolveApiRouting: explicit options.apiKey (IDE single key) still wins', () => {
|
|
90
|
+
test('IDE explicit key overrides the provider fallback on the direct path', () => {
|
|
91
|
+
const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: false, apiKey: 'sk-ide-explicit' }), { openaiApiKey: OPENAI }, 'openai');
|
|
92
|
+
assert.strictEqual(r.apiKey, 'sk-ide-explicit');
|
|
93
|
+
});
|
|
94
|
+
test('IDE with only an explicit key and no creds still works (back-compat)', () => {
|
|
95
|
+
const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: false, apiKey: 'sk-ide-explicit' }), {}, 'anthropic');
|
|
96
|
+
assert.strictEqual(r.apiKey, 'sk-ide-explicit');
|
|
97
|
+
});
|
|
98
|
+
test('proxy path keeps the sk-dummy fallback when nothing is configured', () => {
|
|
99
|
+
const r = (0, utils_1.resolveApiRouting)(opts({ useLiteLLM: true }), {}, 'openai');
|
|
100
|
+
assert.strictEqual(r.apiKey, 'sk-dummy');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
suite('inferModelProvider: custom engines pick provider from the model id', () => {
|
|
104
|
+
test('claude/anthropic model ids → anthropic', () => {
|
|
105
|
+
assert.strictEqual((0, utils_1.inferModelProvider)('claude-sonnet-4-5'), 'anthropic');
|
|
106
|
+
assert.strictEqual((0, utils_1.inferModelProvider)('litellm/claude-haiku-4-5'), 'anthropic');
|
|
107
|
+
assert.strictEqual((0, utils_1.inferModelProvider)('anthropic/claude-opus'), 'anthropic');
|
|
108
|
+
});
|
|
109
|
+
test('everything else → openai (the safe default for gpt/codex/unknown)', () => {
|
|
110
|
+
assert.strictEqual((0, utils_1.inferModelProvider)('gpt-5.5'), 'openai');
|
|
111
|
+
assert.strictEqual((0, utils_1.inferModelProvider)('litellm/gpt-5.4-nano'), 'openai');
|
|
112
|
+
assert.strictEqual((0, utils_1.inferModelProvider)(undefined), 'openai');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
suite('hasCredentialForEngine: engine-aware boot check', () => {
|
|
116
|
+
const base = { apiKey: undefined, baseUrl: undefined, useLiteLLM: false, litellmApiKey: undefined, openaiApiKey: undefined, anthropicApiKey: undefined };
|
|
117
|
+
test('codex needs an OpenAI key; an Anthropic-only env fails the check', () => {
|
|
118
|
+
assert.strictEqual((0, pack_loader_1.hasCredentialForEngine)('codex', { ...base, anthropicApiKey: ANTHROPIC }), false);
|
|
119
|
+
assert.strictEqual((0, pack_loader_1.hasCredentialForEngine)('codex', { ...base, openaiApiKey: OPENAI }), true);
|
|
120
|
+
});
|
|
121
|
+
test('claude needs an Anthropic key; an OpenAI-only env fails the check', () => {
|
|
122
|
+
assert.strictEqual((0, pack_loader_1.hasCredentialForEngine)('claude', { ...base, openaiApiKey: OPENAI }), false);
|
|
123
|
+
assert.strictEqual((0, pack_loader_1.hasCredentialForEngine)('claude', { ...base, anthropicApiKey: ANTHROPIC }), true);
|
|
124
|
+
});
|
|
125
|
+
test('a configured LiteLLM proxy satisfies every engine', () => {
|
|
126
|
+
const proxy = { ...base, useLiteLLM: true, litellmApiKey: LITELLM, baseUrl: 'http://proxy:4000' };
|
|
127
|
+
assert.strictEqual((0, pack_loader_1.hasCredentialForEngine)('codex', proxy), true);
|
|
128
|
+
assert.strictEqual((0, pack_loader_1.hasCredentialForEngine)('claude', proxy), true);
|
|
129
|
+
});
|
|
130
|
+
test('custom engines are not gated here (they authenticate via their own manifest env var)', () => {
|
|
131
|
+
assert.strictEqual((0, pack_loader_1.hasCredentialForEngine)('openclaw', base), true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
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
|
+
/**
|
|
40
|
+
* runGatewayTurn Test Suite
|
|
41
|
+
*
|
|
42
|
+
* Gateway/channel-originated turns (Slack, etc.) previously bypassed both the
|
|
43
|
+
* identity-keyed RateLimiter and the per-session TurnLimiter entirely — only the
|
|
44
|
+
* capacity gate (TurnGate) was ever checked. This suite exercises the extracted
|
|
45
|
+
* admission sequence directly, without a live gateway connection.
|
|
46
|
+
*/
|
|
47
|
+
require("mocha");
|
|
48
|
+
const assert = __importStar(require("assert"));
|
|
49
|
+
const serve_1 = require("../cli/commands/serve");
|
|
50
|
+
const serve_hardening_1 = require("../cli/serve-hardening");
|
|
51
|
+
function fakeEmitter() {
|
|
52
|
+
const texts = [];
|
|
53
|
+
return {
|
|
54
|
+
texts,
|
|
55
|
+
doneCalled: false,
|
|
56
|
+
async thinking() { },
|
|
57
|
+
async text(delta) { texts.push(delta); },
|
|
58
|
+
async toolStart() { },
|
|
59
|
+
async toolEnd() { },
|
|
60
|
+
async progress() { },
|
|
61
|
+
async question() { return { selected: [], freeText: null }; },
|
|
62
|
+
async error() { },
|
|
63
|
+
async done() { this.doneCalled = true; },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function baseParams(overrides = {}) {
|
|
67
|
+
const noop = () => { };
|
|
68
|
+
return {
|
|
69
|
+
rateLimiter: new serve_hardening_1.RateLimiter(0), // disabled unless overridden
|
|
70
|
+
turnLimiter: new serve_hardening_1.TurnLimiter(0), // disabled unless overridden
|
|
71
|
+
turnGate: new serve_hardening_1.TurnGate(10, 10),
|
|
72
|
+
warnGateReject: noop,
|
|
73
|
+
rlKey: 'user-1',
|
|
74
|
+
sessionKey: 'session-1',
|
|
75
|
+
emitter: undefined,
|
|
76
|
+
run: async () => ({ success: true, error: undefined }),
|
|
77
|
+
...overrides,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
suite('runGatewayTurn: admission order and rejection messaging', () => {
|
|
81
|
+
test('rate limiter rejects: run() never called, channel gets RATE_LIMIT_BUSY_MESSAGE', async () => {
|
|
82
|
+
const rl = new serve_hardening_1.RateLimiter(1);
|
|
83
|
+
rl.tryConsume('user-1'); // pre-exhaust the one slot for this key
|
|
84
|
+
let ran = false;
|
|
85
|
+
const emitter = fakeEmitter();
|
|
86
|
+
const result = await (0, serve_1.runGatewayTurn)(baseParams({
|
|
87
|
+
rateLimiter: rl,
|
|
88
|
+
emitter,
|
|
89
|
+
run: async () => { ran = true; return { success: true }; },
|
|
90
|
+
}));
|
|
91
|
+
assert.strictEqual(result.success, false);
|
|
92
|
+
assert.strictEqual(result.error, 'rate_limited');
|
|
93
|
+
assert.strictEqual(ran, false, 'run() must not be called when the rate limiter rejects');
|
|
94
|
+
assert.deepStrictEqual(emitter.texts, [serve_hardening_1.RATE_LIMIT_BUSY_MESSAGE]);
|
|
95
|
+
assert.strictEqual(emitter.doneCalled, true);
|
|
96
|
+
});
|
|
97
|
+
test('rate limiter allows a fresh key through to run()', async () => {
|
|
98
|
+
let ran = false;
|
|
99
|
+
const result = await (0, serve_1.runGatewayTurn)(baseParams({
|
|
100
|
+
rateLimiter: new serve_hardening_1.RateLimiter(1),
|
|
101
|
+
run: async () => { ran = true; return { success: true }; },
|
|
102
|
+
}));
|
|
103
|
+
assert.strictEqual(result.success, true);
|
|
104
|
+
assert.strictEqual(ran, true);
|
|
105
|
+
});
|
|
106
|
+
test('turn gate at capacity rejects: run() never called, channel gets TURN_GATE_BUSY_MESSAGE', async () => {
|
|
107
|
+
const gate = new serve_hardening_1.TurnGate(1, 0); // one slot, no queue room
|
|
108
|
+
const held = await gate.acquire();
|
|
109
|
+
assert.ok(held, 'setup: hold the only slot');
|
|
110
|
+
let ran = false;
|
|
111
|
+
const emitter = fakeEmitter();
|
|
112
|
+
const result = await (0, serve_1.runGatewayTurn)(baseParams({
|
|
113
|
+
turnGate: gate,
|
|
114
|
+
emitter,
|
|
115
|
+
run: async () => { ran = true; return { success: true }; },
|
|
116
|
+
}));
|
|
117
|
+
assert.strictEqual(result.success, false);
|
|
118
|
+
assert.strictEqual(result.error, 'busy');
|
|
119
|
+
assert.strictEqual(ran, false);
|
|
120
|
+
assert.deepStrictEqual(emitter.texts, [serve_hardening_1.TURN_GATE_BUSY_MESSAGE]);
|
|
121
|
+
held();
|
|
122
|
+
});
|
|
123
|
+
test('per-session turn limiter rejects: run() never called, but the turn-gate slot is released', async () => {
|
|
124
|
+
const limiter = new serve_hardening_1.TurnLimiter(1);
|
|
125
|
+
limiter.tryConsume('session-1'); // exhaust the one slot for this session
|
|
126
|
+
const gate = new serve_hardening_1.TurnGate(1, 1);
|
|
127
|
+
let ran = false;
|
|
128
|
+
const emitter = fakeEmitter();
|
|
129
|
+
const result = await (0, serve_1.runGatewayTurn)(baseParams({
|
|
130
|
+
turnLimiter: limiter,
|
|
131
|
+
turnGate: gate,
|
|
132
|
+
emitter,
|
|
133
|
+
run: async () => { ran = true; return { success: true }; },
|
|
134
|
+
}));
|
|
135
|
+
assert.strictEqual(result.success, false);
|
|
136
|
+
assert.strictEqual(result.error, 'turn_limit');
|
|
137
|
+
assert.strictEqual(ran, false);
|
|
138
|
+
assert.deepStrictEqual(emitter.texts, [serve_hardening_1.TURN_LIMIT_BUSY_MESSAGE]);
|
|
139
|
+
// The turn-gate slot must be released even though the turn never ran.
|
|
140
|
+
assert.strictEqual(gate.inflight, 0);
|
|
141
|
+
});
|
|
142
|
+
test('all checks pass: run() executes and its result is returned; the gate slot releases after', async () => {
|
|
143
|
+
const gate = new serve_hardening_1.TurnGate(1, 1);
|
|
144
|
+
const result = await (0, serve_1.runGatewayTurn)(baseParams({
|
|
145
|
+
rateLimiter: new serve_hardening_1.RateLimiter(5),
|
|
146
|
+
turnLimiter: new serve_hardening_1.TurnLimiter(5),
|
|
147
|
+
turnGate: gate,
|
|
148
|
+
run: async () => ({ success: true, error: undefined }),
|
|
149
|
+
}));
|
|
150
|
+
assert.strictEqual(result.success, true);
|
|
151
|
+
assert.strictEqual(gate.inflight, 0, 'slot must be released after a successful turn');
|
|
152
|
+
});
|
|
153
|
+
test('run() throwing still releases the turn-gate slot', async () => {
|
|
154
|
+
const gate = new serve_hardening_1.TurnGate(1, 1);
|
|
155
|
+
await assert.rejects(() => (0, serve_1.runGatewayTurn)(baseParams({
|
|
156
|
+
rateLimiter: new serve_hardening_1.RateLimiter(5),
|
|
157
|
+
turnLimiter: new serve_hardening_1.TurnLimiter(5),
|
|
158
|
+
turnGate: gate,
|
|
159
|
+
run: async () => { throw new Error('boom'); },
|
|
160
|
+
})));
|
|
161
|
+
assert.strictEqual(gate.inflight, 0);
|
|
162
|
+
});
|
|
163
|
+
test('rate limit key and turn-limiter session key are independent buckets', async () => {
|
|
164
|
+
const rateLimiter = new serve_hardening_1.RateLimiter(1);
|
|
165
|
+
const turnLimiter = new serve_hardening_1.TurnLimiter(5);
|
|
166
|
+
// Exhaust the rate limiter for 'user-A' only.
|
|
167
|
+
rateLimiter.tryConsume('user-A');
|
|
168
|
+
const rejectedForA = await (0, serve_1.runGatewayTurn)(baseParams({
|
|
169
|
+
rateLimiter, turnLimiter, rlKey: 'user-A', sessionKey: 'sess-A',
|
|
170
|
+
run: async () => ({ success: true }),
|
|
171
|
+
}));
|
|
172
|
+
const okForB = await (0, serve_1.runGatewayTurn)(baseParams({
|
|
173
|
+
rateLimiter, turnLimiter, rlKey: 'user-B', sessionKey: 'sess-B',
|
|
174
|
+
run: async () => ({ success: true }),
|
|
175
|
+
}));
|
|
176
|
+
assert.strictEqual(rejectedForA.error, 'rate_limited', 'user-A already consumed its one slot in setup');
|
|
177
|
+
assert.strictEqual(okForB.success, true, 'user-B has an independent rate-limit bucket');
|
|
178
|
+
});
|
|
179
|
+
test('a missing emitter does not throw on rejection (channel-less callers are safe)', async () => {
|
|
180
|
+
const rl = new serve_hardening_1.RateLimiter(1);
|
|
181
|
+
rl.tryConsume('user-1');
|
|
182
|
+
const rejected = await (0, serve_1.runGatewayTurn)(baseParams({ rateLimiter: rl, emitter: undefined }));
|
|
183
|
+
assert.strictEqual(rejected.success, false);
|
|
184
|
+
assert.strictEqual(rejected.error, 'rate_limited');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
suite('resolveGatewaySessionKey: never collapses two unidentified senders onto one key', () => {
|
|
188
|
+
test('prefers an explicit sessionId over everything else — durable, user-namespaced', () => {
|
|
189
|
+
const r = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42', gatewaySessionKey: 'gw-1' }, 'alice');
|
|
190
|
+
assert.strictEqual(r.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice')}--chat-42`);
|
|
191
|
+
assert.strictEqual(r.outboundKey, 'chat-42', 'reply-handle routing must keep the raw gateway key');
|
|
192
|
+
assert.strictEqual(r.durable, true);
|
|
193
|
+
});
|
|
194
|
+
test('falls back to gatewaySessionKey when sessionId is absent — durable, user-namespaced', () => {
|
|
195
|
+
const r = (0, serve_1.resolveGatewaySessionKey)({ gatewaySessionKey: 'gw-1' }, 'alice');
|
|
196
|
+
assert.strictEqual(r.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice')}--gw-1`);
|
|
197
|
+
assert.strictEqual(r.outboundKey, 'gw-1');
|
|
198
|
+
assert.strictEqual(r.durable, true);
|
|
199
|
+
});
|
|
200
|
+
test('the same explicit session id from two different senders never attaches to one engine session', () => {
|
|
201
|
+
const alice = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42' }, 'alice');
|
|
202
|
+
const bob = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42' }, 'bob');
|
|
203
|
+
assert.notStrictEqual(alice.sessionKey, bob.sessionKey);
|
|
204
|
+
});
|
|
205
|
+
test('an explicit session id with no resolved identity stays raw (dev/unidentified path)', () => {
|
|
206
|
+
const r = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42' }, undefined);
|
|
207
|
+
assert.strictEqual(r.sessionKey, 'chat-42');
|
|
208
|
+
assert.strictEqual(r.outboundKey, 'chat-42');
|
|
209
|
+
assert.strictEqual(r.durable, true);
|
|
210
|
+
});
|
|
211
|
+
test('with a resolved identity but no session id, keys off that identity — stable, durable, no collision risk', () => {
|
|
212
|
+
const r1 = (0, serve_1.resolveGatewaySessionKey)({}, 'slack:U0B6');
|
|
213
|
+
const r2 = (0, serve_1.resolveGatewaySessionKey)({}, 'slack:U0B6');
|
|
214
|
+
assert.strictEqual(r1.sessionKey, 'gateway-slack:U0B6');
|
|
215
|
+
assert.strictEqual(r1.sessionKey, r2.sessionKey, 'the same identified sender must get the same key across messages');
|
|
216
|
+
assert.strictEqual(r1.durable, true);
|
|
217
|
+
});
|
|
218
|
+
test('two different identified senders with no session id never collide', () => {
|
|
219
|
+
const alice = (0, serve_1.resolveGatewaySessionKey)({}, 'slack:alice');
|
|
220
|
+
const bob = (0, serve_1.resolveGatewaySessionKey)({}, 'slack:bob');
|
|
221
|
+
assert.notStrictEqual(alice.sessionKey, bob.sessionKey);
|
|
222
|
+
});
|
|
223
|
+
test('two genuinely unidentified senders (no userId, no session id) never collide — no shared "gateway-anon" bucket, and both flagged non-durable', () => {
|
|
224
|
+
const first = (0, serve_1.resolveGatewaySessionKey)({}, undefined);
|
|
225
|
+
const second = (0, serve_1.resolveGatewaySessionKey)({}, undefined);
|
|
226
|
+
assert.notStrictEqual(first.sessionKey, second.sessionKey, 'each unidentified message must get its own key, not a shared bucket');
|
|
227
|
+
assert.ok(first.sessionKey.startsWith('gateway-anon-'));
|
|
228
|
+
assert.ok(second.sessionKey.startsWith('gateway-anon-'));
|
|
229
|
+
assert.strictEqual(first.durable, false);
|
|
230
|
+
assert.strictEqual(second.durable, false);
|
|
231
|
+
});
|
|
232
|
+
test('whitespace-only sessionId/gatewaySessionKey are treated as absent', () => {
|
|
233
|
+
const r = (0, serve_1.resolveGatewaySessionKey)({ sessionId: ' ', gatewaySessionKey: ' ' }, 'alice');
|
|
234
|
+
assert.strictEqual(r.sessionKey, 'gateway-alice');
|
|
235
|
+
assert.strictEqual(r.durable, true);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
suite('runGatewayTurn: trackSession=false skips the TurnLimiter for one-off keys', () => {
|
|
239
|
+
test('a never-repeated key would never hit its own cap anyway — trackSession:false just skips the dead-weight tracking', async () => {
|
|
240
|
+
const limiter = new serve_hardening_1.TurnLimiter(1);
|
|
241
|
+
let ran = false;
|
|
242
|
+
const result = await (0, serve_1.runGatewayTurn)(baseParams({
|
|
243
|
+
turnLimiter: limiter,
|
|
244
|
+
sessionKey: 'gateway-anon-unique-1',
|
|
245
|
+
trackSession: false,
|
|
246
|
+
run: async () => { ran = true; return { success: true }; },
|
|
247
|
+
}));
|
|
248
|
+
assert.strictEqual(result.success, true);
|
|
249
|
+
assert.strictEqual(ran, true);
|
|
250
|
+
assert.strictEqual(limiter.tryConsume('gateway-anon-unique-1'), true, 'trackSession:false must never call tryConsume — the key should still have its full budget');
|
|
251
|
+
});
|
|
252
|
+
test('trackSession:true (default) still enforces the cap as before', async () => {
|
|
253
|
+
const limiter = new serve_hardening_1.TurnLimiter(1);
|
|
254
|
+
limiter.tryConsume('sess-durable'); // exhaust the one slot
|
|
255
|
+
let ran = false;
|
|
256
|
+
const emitter = fakeEmitter();
|
|
257
|
+
const result = await (0, serve_1.runGatewayTurn)(baseParams({
|
|
258
|
+
turnLimiter: limiter,
|
|
259
|
+
sessionKey: 'sess-durable',
|
|
260
|
+
emitter,
|
|
261
|
+
run: async () => { ran = true; return { success: true }; },
|
|
262
|
+
}));
|
|
263
|
+
assert.strictEqual(result.success, false);
|
|
264
|
+
assert.strictEqual(result.error, 'turn_limit');
|
|
265
|
+
assert.strictEqual(ran, false);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -63,6 +63,49 @@ suite('serve-hardening: resolveStateDir', () => {
|
|
|
63
63
|
assert.ok(r.dir.startsWith(os.tmpdir()));
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
|
+
suite('serve-hardening: resolveAnalyticsSessionsDir', () => {
|
|
67
|
+
// getAgentideHome() reads process.env directly (not injectable), so tests that need it to
|
|
68
|
+
// resolve deterministically save/restore the real env vars around themselves.
|
|
69
|
+
const saved = {};
|
|
70
|
+
setup(() => {
|
|
71
|
+
saved.AGENT_HOME_DIR = process.env.AGENT_HOME_DIR;
|
|
72
|
+
saved.AGENTHIPPO_HOME = process.env.AGENTHIPPO_HOME;
|
|
73
|
+
delete process.env.AGENT_HOME_DIR;
|
|
74
|
+
delete process.env.AGENTHIPPO_HOME;
|
|
75
|
+
});
|
|
76
|
+
teardown(() => {
|
|
77
|
+
if (saved.AGENT_HOME_DIR === undefined) {
|
|
78
|
+
delete process.env.AGENT_HOME_DIR;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
process.env.AGENT_HOME_DIR = saved.AGENT_HOME_DIR;
|
|
82
|
+
}
|
|
83
|
+
if (saved.AGENTHIPPO_HOME === undefined) {
|
|
84
|
+
delete process.env.AGENTHIPPO_HOME;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
process.env.AGENTHIPPO_HOME = saved.AGENTHIPPO_HOME;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
test('AGENTHIPPO_HOME set → uses getAgentideHome(), not stateDir (stays discoverable to Spotlight/DuckDB)', () => {
|
|
91
|
+
process.env.AGENTHIPPO_HOME = '/data/.agent-hippo';
|
|
92
|
+
const dir = (0, serve_hardening_1.resolveAnalyticsSessionsDir)('/data/.agent-hippo/serve-state', false, { AGENTHIPPO_HOME: '/data/.agent-hippo' });
|
|
93
|
+
assert.strictEqual(dir, path.join('/data/.agent-hippo', 'analytics', 'sessions'));
|
|
94
|
+
});
|
|
95
|
+
test('AGENT_HOME_DIR set → same precedence as AGENTHIPPO_HOME', () => {
|
|
96
|
+
process.env.AGENT_HOME_DIR = '/data/.agent-hippo';
|
|
97
|
+
const dir = (0, serve_hardening_1.resolveAnalyticsSessionsDir)('/data/.agent-hippo/serve-state', false, { AGENT_HOME_DIR: '/data/.agent-hippo' });
|
|
98
|
+
assert.strictEqual(dir, path.join('/data/.agent-hippo', 'analytics', 'sessions'));
|
|
99
|
+
});
|
|
100
|
+
test('no home-dir env var, explicit non-ephemeral --state-dir → uses stateDir (never regress durability for discoverability)', () => {
|
|
101
|
+
const dir = (0, serve_hardening_1.resolveAnalyticsSessionsDir)('/mnt/durable-vol', false, {});
|
|
102
|
+
assert.strictEqual(dir, path.join('/mnt/durable-vol', 'analytics', 'sessions'));
|
|
103
|
+
});
|
|
104
|
+
test('no home-dir env var, ephemeral state (nothing durable configured) → falls back to getAgentideHome() default', () => {
|
|
105
|
+
const dir = (0, serve_hardening_1.resolveAnalyticsSessionsDir)(path.join(os.tmpdir(), 'agenthippo-server'), true, {});
|
|
106
|
+
assert.strictEqual(dir, path.join(os.homedir(), '.agent-hippo', 'analytics', 'sessions'));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
66
109
|
suite('serve-hardening: resolveBindHost', () => {
|
|
67
110
|
test('defaults to loopback', () => {
|
|
68
111
|
assert.strictEqual((0, serve_hardening_1.resolveBindHost)(undefined, {}), serve_hardening_1.DEFAULT_BIND_HOST);
|
|
@@ -297,3 +340,40 @@ suite('serve-hardening: readBodyWithCap', () => {
|
|
|
297
340
|
assert.strictEqual(serve_hardening_1.DEFAULT_MAX_BODY_BYTES, 1024 * 1024);
|
|
298
341
|
});
|
|
299
342
|
});
|
|
343
|
+
suite('serve-hardening: resolveServeSessionKey (secure-obo-design §3.4 Phase 0)', () => {
|
|
344
|
+
const PINNED = { AGENTHIPPO_IDENTITY_HEADER: 'x-forwarded-email' };
|
|
345
|
+
test('verified user + session_id → user-namespaced key', () => {
|
|
346
|
+
const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', PINNED);
|
|
347
|
+
assert.strictEqual(r.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice@corp.com')}--chat-1`);
|
|
348
|
+
assert.strictEqual(r.rejected, undefined);
|
|
349
|
+
});
|
|
350
|
+
test('two users presenting the SAME session_id can never share a key (the defect this closes)', () => {
|
|
351
|
+
const a = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'alice@corp.com', PINNED);
|
|
352
|
+
const b = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', 'bob@corp.com', PINNED);
|
|
353
|
+
assert.notStrictEqual(a.sessionKey, b.sessionKey);
|
|
354
|
+
});
|
|
355
|
+
test('verified user without session_id → stable per-user default key', () => {
|
|
356
|
+
const r1 = (0, serve_hardening_1.resolveServeSessionKey)(undefined, 'alice@corp.com', PINNED);
|
|
357
|
+
const r2 = (0, serve_hardening_1.resolveServeSessionKey)('', 'alice@corp.com', PINNED);
|
|
358
|
+
assert.strictEqual(r1.sessionKey, r2.sessionKey);
|
|
359
|
+
assert.strictEqual(r1.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice@corp.com')}--default`);
|
|
360
|
+
});
|
|
361
|
+
test('pinned identity + session_id but NO verified user → fail closed', () => {
|
|
362
|
+
const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, PINNED);
|
|
363
|
+
assert.strictEqual(r.rejected, serve_hardening_1.SESSION_WITHOUT_USER_REASON);
|
|
364
|
+
});
|
|
365
|
+
test('unpinned (local dev) keeps raw session_id behavior', () => {
|
|
366
|
+
const r = (0, serve_hardening_1.resolveServeSessionKey)('chat-1', undefined, {});
|
|
367
|
+
assert.strictEqual(r.sessionKey, 'chat-1');
|
|
368
|
+
assert.strictEqual(r.rejected, undefined);
|
|
369
|
+
});
|
|
370
|
+
test('unpinned, no user, no session_id → legacy anon bucket', () => {
|
|
371
|
+
const r = (0, serve_hardening_1.resolveServeSessionKey)(undefined, undefined, {});
|
|
372
|
+
assert.strictEqual(r.sessionKey, 'anon-unknown');
|
|
373
|
+
});
|
|
374
|
+
test('the key never embeds the raw user id (emails are not path segments)', () => {
|
|
375
|
+
const r = (0, serve_hardening_1.resolveServeSessionKey)('s', 'alice@corp.com', PINNED);
|
|
376
|
+
assert.ok(!r.sessionKey.includes('alice@corp.com'));
|
|
377
|
+
assert.ok(!r.sessionKey.includes('@'));
|
|
378
|
+
});
|
|
379
|
+
});
|
|
@@ -173,4 +173,36 @@ suite('ServeClient', () => {
|
|
|
173
173
|
});
|
|
174
174
|
});
|
|
175
175
|
});
|
|
176
|
+
test('streamRemoteServeChat surfaces fail-closed credential rejection as HTTP error (not silent success)', async () => {
|
|
177
|
+
// Mirrors serve.ts after the fix: warrant denial happens BEFORE SSE writeHead(200),
|
|
178
|
+
// so the client sees a real non-2xx JSON error instead of 200 + raw body fallthrough.
|
|
179
|
+
const server = http.createServer((_req, res) => {
|
|
180
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
181
|
+
res.end(JSON.stringify({
|
|
182
|
+
error: 'OBO data token present without a verified user in request scope (fail-closed: token_without_verified_user)',
|
|
183
|
+
}));
|
|
184
|
+
});
|
|
185
|
+
await new Promise((resolve, reject) => {
|
|
186
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
187
|
+
try {
|
|
188
|
+
const address = server.address();
|
|
189
|
+
if (!address || typeof address === 'string') {
|
|
190
|
+
throw new Error('bad address');
|
|
191
|
+
}
|
|
192
|
+
const emitter = createCaptureEmitter();
|
|
193
|
+
const result = await (0, serveClient_js_1.streamRemoteServeChat)({ baseUrl: `http://127.0.0.1:${address.port}`, message: 'ping' }, emitter);
|
|
194
|
+
assert.strictEqual(result.ok, false);
|
|
195
|
+
assert.match(result.error ?? '', /token_without_verified_user/);
|
|
196
|
+
assert.deepStrictEqual(emitter.events.map((e) => e.type), ['error', 'done']);
|
|
197
|
+
resolve();
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
reject(err);
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
server.close();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
176
208
|
});
|
|
@@ -88,7 +88,7 @@ suite('state-sync: stage + merge session dirs', () => {
|
|
|
88
88
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
89
89
|
(0, state_sync_1.clearRestoreAttemptCache)();
|
|
90
90
|
});
|
|
91
|
-
test('stages only matching provider-session shards
|
|
91
|
+
test('stages only matching provider-session shards; the shared agents/ SDK cache is never staged', async () => {
|
|
92
92
|
const ps = path.join(tmp, 'provider-sessions');
|
|
93
93
|
fs.mkdirSync(ps, { recursive: true });
|
|
94
94
|
const key1 = 'claude:sess-a:pack:sig';
|
|
@@ -97,6 +97,8 @@ suite('state-sync: stage + merge session dirs', () => {
|
|
|
97
97
|
const f2 = (0, state_sync_1.providerSessionShardName)(key2);
|
|
98
98
|
fs.writeFileSync(path.join(ps, f1), JSON.stringify({ key: key1, value: { chatSessionId: 'sess-a', id: 'n1' } }));
|
|
99
99
|
fs.writeFileSync(path.join(ps, f2), JSON.stringify({ key: key2, value: { chatSessionId: 'sess-b', id: 'n2' } }));
|
|
100
|
+
// agents/ holds the SDK's own cache, shared across every session/user of this pack
|
|
101
|
+
// on the replica — must never be swept into a single session's staging tree.
|
|
100
102
|
fs.mkdirSync(path.join(tmp, 'agents', 'pack', 'claude'), { recursive: true });
|
|
101
103
|
fs.writeFileSync(path.join(tmp, 'agents', 'pack', 'claude', 'marker'), 'x');
|
|
102
104
|
const staging = path.join(tmp, 'staging');
|
|
@@ -104,22 +106,33 @@ suite('state-sync: stage + merge session dirs', () => {
|
|
|
104
106
|
assert.strictEqual(ok, true);
|
|
105
107
|
assert.ok(fs.existsSync(path.join(staging, 'provider-sessions', f1)));
|
|
106
108
|
assert.ok(!fs.existsSync(path.join(staging, 'provider-sessions', f2)));
|
|
107
|
-
assert.ok(fs.existsSync(path.join(staging, 'agents', '
|
|
109
|
+
assert.ok(!fs.existsSync(path.join(staging, 'agents')), 'agents/ must not be staged into a per-session push');
|
|
108
110
|
const shards = await (0, state_sync_1.listSessionShardFiles)(tmp, 'sess-a');
|
|
109
111
|
assert.deepStrictEqual(shards, [f1]);
|
|
110
112
|
assert.strictEqual(await (0, state_sync_1.hasLocalSessionState)(tmp, 'sess-a'), true);
|
|
111
113
|
assert.strictEqual(await (0, state_sync_1.hasLocalSessionState)(tmp, 'sess-missing'), false);
|
|
112
114
|
});
|
|
113
|
-
test('
|
|
115
|
+
test('a session with no shards but a live agents/ dir has nothing to push', async () => {
|
|
116
|
+
fs.mkdirSync(path.join(tmp, 'agents', 'pack', 'claude'), { recursive: true });
|
|
117
|
+
fs.writeFileSync(path.join(tmp, 'agents', 'pack', 'claude', 'marker'), 'x');
|
|
118
|
+
const staging = path.join(tmp, 'staging');
|
|
119
|
+
const ok = await (0, state_sync_1.stageSessionPushDir)(tmp, 'sess-none', staging);
|
|
120
|
+
assert.strictEqual(ok, false, 'agents/ content alone must not count as this session having content to push');
|
|
121
|
+
});
|
|
122
|
+
test('mergeSessionStagingIntoState copies shards only; never touches the destination agents/ dir', async () => {
|
|
114
123
|
const staging = path.join(tmp, 'from');
|
|
115
124
|
const dest = path.join(tmp, 'to');
|
|
116
125
|
fs.mkdirSync(path.join(staging, 'provider-sessions'), { recursive: true });
|
|
117
126
|
fs.writeFileSync(path.join(staging, 'provider-sessions', 'abc.json'), JSON.stringify({ key: 'claude:s1:p:s', value: { chatSessionId: 's1' } }));
|
|
127
|
+
// Simulate a stale staging tree that (pre-fix) would have carried an old agents/
|
|
128
|
+
// snapshot — restoring it must not force-overwrite another session's live SDK cache.
|
|
118
129
|
fs.mkdirSync(path.join(staging, 'agents', 'p'), { recursive: true });
|
|
119
|
-
fs.writeFileSync(path.join(staging, 'agents', 'p', 'f'), '
|
|
130
|
+
fs.writeFileSync(path.join(staging, 'agents', 'p', 'f'), 'stale');
|
|
131
|
+
fs.mkdirSync(path.join(dest, 'agents', 'p'), { recursive: true });
|
|
132
|
+
fs.writeFileSync(path.join(dest, 'agents', 'p', 'f'), 'live-for-another-session');
|
|
120
133
|
await (0, state_sync_1.mergeSessionStagingIntoState)(staging, dest);
|
|
121
134
|
assert.ok(fs.existsSync(path.join(dest, 'provider-sessions', 'abc.json')));
|
|
122
|
-
assert.
|
|
135
|
+
assert.strictEqual(fs.readFileSync(path.join(dest, 'agents', 'p', 'f'), 'utf8'), 'live-for-another-session', 'the live agents/ dir must survive a restore of an unrelated staging tree untouched');
|
|
123
136
|
});
|
|
124
137
|
});
|
|
125
138
|
suite('peerToUserId (#4 — channel sender → per-user id)', () => {
|
package/out/utils.js
CHANGED
|
@@ -55,6 +55,7 @@ exports.shortSig = shortSig;
|
|
|
55
55
|
exports.isLocalHttps = isLocalHttps;
|
|
56
56
|
exports.applyLocalTlsBypass = applyLocalTlsBypass;
|
|
57
57
|
exports.getOverrideOpenAIBaseUrl = getOverrideOpenAIBaseUrl;
|
|
58
|
+
exports.inferModelProvider = inferModelProvider;
|
|
58
59
|
exports.resolveApiRouting = resolveApiRouting;
|
|
59
60
|
exports.getExplicitConfigValue = getExplicitConfigValue;
|
|
60
61
|
exports.getAgentideEngineSettings = getAgentideEngineSettings;
|
|
@@ -651,20 +652,35 @@ function getOverrideOpenAIBaseUrl(baseUrl, configProvider) {
|
|
|
651
652
|
}
|
|
652
653
|
return undefined;
|
|
653
654
|
}
|
|
655
|
+
/** Infer the direct provider from a model id — used by engines (e.g. custom) that aren't pinned to one vendor. */
|
|
656
|
+
function inferModelProvider(modelId) {
|
|
657
|
+
return modelId && /claude|anthropic/i.test(modelId) ? 'anthropic' : 'openai';
|
|
658
|
+
}
|
|
654
659
|
/**
|
|
655
|
-
* Resolve API key
|
|
656
|
-
*
|
|
657
|
-
*
|
|
660
|
+
* Resolve the API key + base URL for a turn by the DESTINATION it dials, not a global
|
|
661
|
+
* precedence over provider keys.
|
|
662
|
+
*
|
|
663
|
+
* - LiteLLM proxy selected (`useLiteLLM`) → the LiteLLM key.
|
|
664
|
+
* - direct → the key for THIS engine's provider
|
|
665
|
+
* (codex→openai, claude→anthropic; custom infers
|
|
666
|
+
* from its model). Never the "other" provider's key.
|
|
667
|
+
*
|
|
668
|
+
* An explicit per-request `options.apiKey` (the IDE's single configured key) still wins — the
|
|
669
|
+
* per-provider `creds` are the headless/serve fallback that replaces the old
|
|
670
|
+
* `anthropic || openai` collapse (which sent, e.g., a codex turn to OpenAI with an Anthropic key).
|
|
658
671
|
*
|
|
659
|
-
* @param options - Agent request options
|
|
660
|
-
* @param
|
|
661
|
-
* @param
|
|
662
|
-
* @param configProvider - Optional config provider for resolving
|
|
672
|
+
* @param options - Agent request options (carries an optional explicit apiKey / baseUrl / useLiteLLM)
|
|
673
|
+
* @param creds - Per-destination credentials (EngineConfig satisfies this)
|
|
674
|
+
* @param provider - The engine's direct provider, used only when NOT routing through LiteLLM
|
|
675
|
+
* @param configProvider - Optional config provider for resolving overrideOpenAIBaseUrl
|
|
663
676
|
*/
|
|
664
|
-
function resolveApiRouting(options,
|
|
677
|
+
function resolveApiRouting(options, creds, provider, configProvider) {
|
|
665
678
|
const useLiteLLM = options.useLiteLLM === true;
|
|
666
|
-
const baseUrl = options.baseUrl || (useLiteLLM ? litellmBaseUrl : undefined);
|
|
667
|
-
const
|
|
679
|
+
const baseUrl = options.baseUrl || (useLiteLLM ? creds.litellmBaseUrl : undefined);
|
|
680
|
+
const directKey = provider === 'anthropic' ? creds.anthropicApiKey : creds.openaiApiKey;
|
|
681
|
+
const resolvedApiKey = useLiteLLM
|
|
682
|
+
? (options.apiKey || creds.litellmApiKey || 'sk-dummy')
|
|
683
|
+
: (options.apiKey || directKey || '');
|
|
668
684
|
// For OpenAI-compatible endpoints, check for per-provider overrideOpenAIBaseUrl
|
|
669
685
|
let openaiBaseUrl = baseUrl;
|
|
670
686
|
if (baseUrl) {
|
|
@@ -674,7 +690,7 @@ function resolveApiRouting(options, litellmApiKey, litellmBaseUrl, configProvide
|
|
|
674
690
|
}
|
|
675
691
|
}
|
|
676
692
|
return {
|
|
677
|
-
apiKey:
|
|
693
|
+
apiKey: resolvedApiKey,
|
|
678
694
|
baseUrl,
|
|
679
695
|
openaiBaseUrl,
|
|
680
696
|
useLiteLLM,
|