@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,172 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Egress broker tests (secure-obo-design §7.6)
|
|
4
|
+
*
|
|
5
|
+
* Boots the real broker (loopback HTTP + shared TurnRegistry) against a temp workspace whose
|
|
6
|
+
* gateway-outbound handle store is seeded like the bridge would seed it, then drives it exactly
|
|
7
|
+
* as the agent-facing `gateway send` broker mode does: bearer nonce, `{message, to?}` body.
|
|
8
|
+
*
|
|
9
|
+
* The `send` function is a spy — no live OpenClaw gateway — so we assert on WHAT the broker
|
|
10
|
+
* resolved and WHETHER it delivered, not on the platform call itself.
|
|
11
|
+
*--------------------------------------------------------------------------------------------*/
|
|
12
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
15
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
16
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
17
|
+
}
|
|
18
|
+
Object.defineProperty(o, k2, desc);
|
|
19
|
+
}) : (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
o[k2] = m[k];
|
|
22
|
+
}));
|
|
23
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
24
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
25
|
+
}) : function(o, v) {
|
|
26
|
+
o["default"] = v;
|
|
27
|
+
});
|
|
28
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
29
|
+
var ownKeys = function(o) {
|
|
30
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
31
|
+
var ar = [];
|
|
32
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
33
|
+
return ar;
|
|
34
|
+
};
|
|
35
|
+
return ownKeys(o);
|
|
36
|
+
};
|
|
37
|
+
return function (mod) {
|
|
38
|
+
if (mod && mod.__esModule) return mod;
|
|
39
|
+
var result = {};
|
|
40
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
41
|
+
__setModuleDefault(result, mod);
|
|
42
|
+
return result;
|
|
43
|
+
};
|
|
44
|
+
})();
|
|
45
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
+
require("mocha");
|
|
47
|
+
const assert = __importStar(require("assert"));
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const os = __importStar(require("os"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
const data_plane_1 = require("../cli/data-plane");
|
|
52
|
+
const outboundPaths_1 = require("../gateway/outboundPaths");
|
|
53
|
+
const AGENT_ID = 'demo-agent';
|
|
54
|
+
/** Seed a handle store the way the bridge's upsertGatewayOutboundHandle would. */
|
|
55
|
+
function seedStore(workspaceRoot, storeKey, handle) {
|
|
56
|
+
const p = (0, outboundPaths_1.getGatewayOutboundFilePath)(workspaceRoot, (0, outboundPaths_1.safeGatewaySessionDirId)(storeKey));
|
|
57
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
58
|
+
fs.writeFileSync(p, JSON.stringify({
|
|
59
|
+
version: 1,
|
|
60
|
+
lastByAgent: { [AGENT_ID]: handle },
|
|
61
|
+
targetsByAgent: { [AGENT_ID]: { [handle.target]: handle } },
|
|
62
|
+
}), 'utf8');
|
|
63
|
+
}
|
|
64
|
+
function handle(target, overrides = {}) {
|
|
65
|
+
return {
|
|
66
|
+
agentId: AGENT_ID, channel: 'telegram', target,
|
|
67
|
+
firstSeenAt: 1, lastSeenAt: 2, inboundCount: 1, ...overrides,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async function post(url, nonce, body) {
|
|
71
|
+
const res = await fetch(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json', ...(nonce !== undefined ? { Authorization: `Bearer ${nonce}` } : {}) },
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
});
|
|
76
|
+
return { status: res.status, json: await res.json().catch(() => ({})) };
|
|
77
|
+
}
|
|
78
|
+
suite('egress broker: server-side resolution + fail-closed (§7.6)', function () {
|
|
79
|
+
this.timeout(15_000);
|
|
80
|
+
let workspaceRoot;
|
|
81
|
+
let registry;
|
|
82
|
+
let broker;
|
|
83
|
+
let sends;
|
|
84
|
+
suiteSetup(async () => {
|
|
85
|
+
workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'egress-ws-'));
|
|
86
|
+
registry = new data_plane_1.TurnRegistry();
|
|
87
|
+
sends = [];
|
|
88
|
+
broker = await (0, data_plane_1.startEgressBroker)({
|
|
89
|
+
registry,
|
|
90
|
+
workspaceRoot,
|
|
91
|
+
agentId: AGENT_ID,
|
|
92
|
+
send: async (h, message, media) => {
|
|
93
|
+
sends.push({ handle: h, message, media });
|
|
94
|
+
return { ok: true, via: 'spy' };
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
suiteTeardown(async () => {
|
|
99
|
+
await broker?.close();
|
|
100
|
+
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
|
101
|
+
});
|
|
102
|
+
test('binds loopback only', () => {
|
|
103
|
+
assert.ok(broker.url.startsWith('http://127.0.0.1:'), broker.url);
|
|
104
|
+
});
|
|
105
|
+
test('reply-to-sender: a live nonce delivers to the session\'s bound handle', async () => {
|
|
106
|
+
seedStore(workspaceRoot, 'chat-1', handle('telegram:dm:100'));
|
|
107
|
+
const turn = registry.register({ userId: 'u@corp.com', sessionKey: 's', egressStoreKey: 'chat-1' });
|
|
108
|
+
try {
|
|
109
|
+
const { status, json } = await post(broker.url, turn.nonce, { message: 'hello' });
|
|
110
|
+
assert.strictEqual(status, 200);
|
|
111
|
+
assert.strictEqual(json.ok, true);
|
|
112
|
+
assert.strictEqual(sends.at(-1)?.handle.target, 'telegram:dm:100');
|
|
113
|
+
assert.strictEqual(sends.at(-1)?.message, 'hello');
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
turn.release();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
test('`to` may only SELECT among bound handles — a new destination is refused, no send', async () => {
|
|
120
|
+
seedStore(workspaceRoot, 'chat-2', handle('telegram:dm:200'));
|
|
121
|
+
const turn = registry.register({ userId: 'u@corp.com', sessionKey: 's', egressStoreKey: 'chat-2' });
|
|
122
|
+
const before = sends.length;
|
|
123
|
+
try {
|
|
124
|
+
const { status, json } = await post(broker.url, turn.nonce, { message: 'exfil', to: 'telegram:dm:ATTACKER' });
|
|
125
|
+
assert.strictEqual(status, 404, 'a target not bound to this session must not resolve');
|
|
126
|
+
assert.strictEqual(json.ok, false);
|
|
127
|
+
assert.strictEqual(sends.length, before, 'nothing may be delivered when resolution fails');
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
turn.release();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
test('off-turn / released nonce → 401, fail closed, no send', async () => {
|
|
134
|
+
seedStore(workspaceRoot, 'chat-3', handle('telegram:dm:300'));
|
|
135
|
+
const turn = registry.register({ userId: 'u@corp.com', sessionKey: 's', egressStoreKey: 'chat-3' });
|
|
136
|
+
const nonce = turn.nonce;
|
|
137
|
+
turn.release();
|
|
138
|
+
const before = sends.length;
|
|
139
|
+
const { status, json } = await post(broker.url, nonce, { message: 'stale' });
|
|
140
|
+
assert.strictEqual(status, 401);
|
|
141
|
+
assert.strictEqual(json.ok, false);
|
|
142
|
+
assert.strictEqual(sends.length, before);
|
|
143
|
+
});
|
|
144
|
+
test('unknown / missing nonce → 401', async () => {
|
|
145
|
+
assert.strictEqual((await post(broker.url, 'never-issued-nonce', { message: 'x' })).status, 401);
|
|
146
|
+
assert.strictEqual((await post(broker.url, undefined, { message: 'x' })).status, 401);
|
|
147
|
+
});
|
|
148
|
+
test('a turn with no egress store key (bare HTTP data turn) cannot send → 403', async () => {
|
|
149
|
+
const turn = registry.register({ userId: 'u@corp.com', sessionKey: 's' }); // no egressStoreKey
|
|
150
|
+
try {
|
|
151
|
+
const { status } = await post(broker.url, turn.nonce, { message: 'x' });
|
|
152
|
+
assert.strictEqual(status, 403);
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
turn.release();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
test('one user cannot address another session\'s bound handle (server-side scoping)', async () => {
|
|
159
|
+
// Alice's session is bound to her peer; Bob's turn (his own store key) must not reach it.
|
|
160
|
+
seedStore(workspaceRoot, 'alice-chat', handle('telegram:dm:ALICE'));
|
|
161
|
+
const bob = registry.register({ userId: 'bob@corp.com', sessionKey: 'sb', egressStoreKey: 'bob-chat' }); // no store seeded
|
|
162
|
+
const before = sends.length;
|
|
163
|
+
try {
|
|
164
|
+
const { status } = await post(broker.url, bob.nonce, { message: 'to alice?', to: 'telegram:dm:ALICE' });
|
|
165
|
+
assert.strictEqual(status, 404, 'Bob\'s turn resolves only against Bob\'s own session store');
|
|
166
|
+
assert.strictEqual(sends.length, before);
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
bob.release();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -45,6 +45,10 @@ suite('fleetDisplayUtils', () => {
|
|
|
45
45
|
assert.strictEqual((0, fleetDisplayUtils_1.inferFleetEngineId)('agentide-cursor::auto'), 'cursor');
|
|
46
46
|
assert.strictEqual((0, fleetDisplayUtils_1.formatFleetEngineLabel)('agentide-cursor::auto'), 'Cursor');
|
|
47
47
|
});
|
|
48
|
+
test('formats agenthippo auto model label', () => {
|
|
49
|
+
assert.strictEqual((0, fleetDisplayUtils_1.formatFleetModelLabel)('ah-auto', ''), 'Auto');
|
|
50
|
+
assert.strictEqual((0, fleetDisplayUtils_1.formatFleetModelLabel)('agenthippo::ah-auto', ''), 'Auto');
|
|
51
|
+
});
|
|
48
52
|
test('enriches in_progress row from schedule hint', () => {
|
|
49
53
|
const hints = (0, fleetDisplayUtils_1.buildScheduleHintsByChatSessionId)([{
|
|
50
54
|
id: 'sched-1',
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* IdP Init Test Suite (PLAN/deploy.md — "customer 0")
|
|
4
|
+
*
|
|
5
|
+
* Verifies `agenthippo idp init` generates a self-contained stack: no repo dependency in the
|
|
6
|
+
* auth-broker image, identity routes reused verbatim from renderIdentityLayer (no drift risk),
|
|
7
|
+
* and valid YAML/bash throughout.
|
|
8
|
+
*--------------------------------------------------------------------------------------------*/
|
|
9
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
12
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
13
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
14
|
+
}
|
|
15
|
+
Object.defineProperty(o, k2, desc);
|
|
16
|
+
}) : (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
o[k2] = m[k];
|
|
19
|
+
}));
|
|
20
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
21
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
22
|
+
}) : function(o, v) {
|
|
23
|
+
o["default"] = v;
|
|
24
|
+
});
|
|
25
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
26
|
+
var ownKeys = function(o) {
|
|
27
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
28
|
+
var ar = [];
|
|
29
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
30
|
+
return ar;
|
|
31
|
+
};
|
|
32
|
+
return ownKeys(o);
|
|
33
|
+
};
|
|
34
|
+
return function (mod) {
|
|
35
|
+
if (mod && mod.__esModule) return mod;
|
|
36
|
+
var result = {};
|
|
37
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
38
|
+
__setModuleDefault(result, mod);
|
|
39
|
+
return result;
|
|
40
|
+
};
|
|
41
|
+
})();
|
|
42
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
+
require("mocha");
|
|
44
|
+
const assert = __importStar(require("assert"));
|
|
45
|
+
const fs = __importStar(require("fs"));
|
|
46
|
+
const os = __importStar(require("os"));
|
|
47
|
+
const path = __importStar(require("path"));
|
|
48
|
+
const child_process_1 = require("child_process");
|
|
49
|
+
const yaml = __importStar(require("js-yaml"));
|
|
50
|
+
const idp_init_1 = require("../cli/idp-init");
|
|
51
|
+
const idp_1 = require("../cli/commands/idp");
|
|
52
|
+
suite('idp-init: renderIdpArtifacts', () => {
|
|
53
|
+
test('generates a self-contained stack — no monorepo build context', () => {
|
|
54
|
+
const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' });
|
|
55
|
+
assert.ok(files['Dockerfile.auth-broker'].includes('FROM ghcr.io/agenthippoai/runtime'), 'auth-broker image must come from the published runtime image, not repo build artifacts');
|
|
56
|
+
assert.ok(!files['Dockerfile.auth-broker'].includes('extensions/agentide/dist'), 'must not reference this repo\'s local build output — a customer never has it');
|
|
57
|
+
assert.ok(files['docker-compose.yml'].includes('dockerfile: Dockerfile.auth-broker')
|
|
58
|
+
&& files['docker-compose.yml'].includes('context: .'), 'auth-broker builds from the generated dir itself, not a monorepo path');
|
|
59
|
+
});
|
|
60
|
+
test('docker-compose.yml is valid YAML with the expected services and networks', () => {
|
|
61
|
+
const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' });
|
|
62
|
+
const compose = yaml.load(files['docker-compose.yml']);
|
|
63
|
+
assert.deepStrictEqual(Object.keys(compose.services), ['oauth2-proxy', 'auth-broker', 'caddy', 'cloudflared']);
|
|
64
|
+
assert.ok(!('agent' in compose.services), 'the IdP runs no agent');
|
|
65
|
+
assert.deepStrictEqual(Object.keys(compose.networks), ['default', 'colocate']);
|
|
66
|
+
assert.ok(files['docker-compose.yml'].includes('name: agenthippo-idp-colocate'));
|
|
67
|
+
});
|
|
68
|
+
test('Caddyfile reuses renderIdentityLayer verbatim — self-issued, no agent backend', () => {
|
|
69
|
+
const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' });
|
|
70
|
+
const caddy = files['Caddyfile'];
|
|
71
|
+
assert.ok(caddy.includes('import agents/*.caddy'), 'colocated agents import in');
|
|
72
|
+
assert.ok(caddy.includes('handle /oauth/authorize {') && caddy.includes('handle /oauth/browser-login {'));
|
|
73
|
+
assert.ok(caddy.includes('forward_auth oauth2-proxy:4180'), 'self-issued: gates via oauth2-proxy');
|
|
74
|
+
assert.ok(caddy.includes('reverse_proxy auth-broker:9780'), 'health checks the broker, not an agent');
|
|
75
|
+
assert.ok(caddy.includes('respond "not_found" 404'), 'no /v1/* API here — Bearer requests 404, not silently passed through');
|
|
76
|
+
assert.ok(caddy.includes('respond "AgentHippo IdP" 200'), 'browser catch-all, past the SSO gate');
|
|
77
|
+
assert.ok(caddy.includes('http://{$AGENTHIPPO_PUBLIC_HOSTNAME} {') && !caddy.includes('https://{$AGENTHIPPO_PUBLIC_HOSTNAME}'), 'plain HTTP — auto_https is off, TLS terminates in front');
|
|
78
|
+
});
|
|
79
|
+
test('.env.auth.example carries the shared JWT secret + Google OAuth client, no fed vars', () => {
|
|
80
|
+
const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' });
|
|
81
|
+
const auth = files['.env.auth.example'];
|
|
82
|
+
assert.ok(auth.includes('AGENTHIPPO_AUTH_JWT_SECRET') && auth.includes('OAUTH2_PROXY_CLIENT_ID'));
|
|
83
|
+
assert.ok(!auth.includes('AGENTHIPPO_AUTH_IDP_URL'), 'the IdP is not itself federated against anything');
|
|
84
|
+
});
|
|
85
|
+
test('cookie domain defaults to the hostname\'s own parent domain when not given explicitly', () => {
|
|
86
|
+
const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' });
|
|
87
|
+
assert.ok(files['.env.auth.example'].includes('AGENTHIPPO_AUTH_COOKIE_DOMAIN=.corp.example'));
|
|
88
|
+
});
|
|
89
|
+
test('explicit cookieDomain overrides the derived default', () => {
|
|
90
|
+
const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.eu.corp.example', cookieDomain: '.corp.example' });
|
|
91
|
+
assert.ok(files['.env.auth.example'].includes('AGENTHIPPO_AUTH_COOKIE_DOMAIN=.corp.example'));
|
|
92
|
+
});
|
|
93
|
+
test('run.sh is valid bash and reflects the hostname', () => {
|
|
94
|
+
const files = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' });
|
|
95
|
+
assert.doesNotThrow(() => (0, child_process_1.execFileSync)('bash', ['-n', '-c', files['run.sh']]));
|
|
96
|
+
assert.ok(files['run.sh'].includes('auth.corp.example'));
|
|
97
|
+
});
|
|
98
|
+
// Bare metal / BYO nginx or ALB (e.g. Hetzner): run.sh must not hard-require a Cloudflare
|
|
99
|
+
// Tunnel, and Caddy's host port must be reconfigurable when 80/443 are already taken by the
|
|
100
|
+
// operator's own reverse proxy.
|
|
101
|
+
test('run.sh branches on an explicit tunnel-credentials path argument, not a fixed file location', () => {
|
|
102
|
+
const sh = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' })['run.sh'];
|
|
103
|
+
assert.ok(sh.includes('if [[ $# -ge 1 ]]; then'), 'branches on whether an argument was passed');
|
|
104
|
+
assert.ok(!sh.includes('-f "$HOME/.agent-hippo/tunnel-credentials.json"'), 'must not infer tunnel mode from a fixed default file path');
|
|
105
|
+
assert.ok(sh.includes('AGENTHIPPO_TUNNEL_CREDENTIALS="$1"'), 'the argument becomes the credentials path');
|
|
106
|
+
assert.ok(sh.includes('docker compose --profile edge --profile tunnel up -d --build'));
|
|
107
|
+
assert.ok(sh.includes('docker compose --profile edge up -d --build'), 'no-argument branch: --profile edge only, no --profile tunnel');
|
|
108
|
+
});
|
|
109
|
+
test('run.sh fails loudly if a given tunnel-credentials path doesn\'t exist', () => {
|
|
110
|
+
const sh = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' })['run.sh'];
|
|
111
|
+
assert.ok(sh.includes('[[ -f "$AGENTHIPPO_TUNNEL_CREDENTIALS" ]] || {'));
|
|
112
|
+
assert.ok(sh.includes('exit 1'));
|
|
113
|
+
});
|
|
114
|
+
test('docker-compose.yml wires the cloudflared credentials mount to AGENTHIPPO_TUNNEL_CREDENTIALS', () => {
|
|
115
|
+
const compose = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' })['docker-compose.yml'];
|
|
116
|
+
assert.ok(compose.includes('${AGENTHIPPO_TUNNEL_CREDENTIALS:-${HOME}/.agent-hippo/tunnel-credentials.json}'));
|
|
117
|
+
});
|
|
118
|
+
test('Caddy host ports are configurable via AGENTHIPPO_CADDY_HTTP_PORT/_HTTPS_PORT (Hetzner: 80/443 may already be taken)', () => {
|
|
119
|
+
const compose = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' })['docker-compose.yml'];
|
|
120
|
+
assert.ok(compose.includes('${AGENTHIPPO_CADDY_HTTP_PORT:-80}:80'));
|
|
121
|
+
assert.ok(compose.includes('${AGENTHIPPO_CADDY_HTTPS_PORT:-443}:443'));
|
|
122
|
+
});
|
|
123
|
+
test('README documents both the Cloudflare Tunnel path and the bare-metal/BYO-nginx path', () => {
|
|
124
|
+
const readme = (0, idp_init_1.renderIdpArtifacts)({ hostname: 'auth.corp.example' })['README.md'];
|
|
125
|
+
assert.ok(readme.includes('Cloudflare Tunnel'));
|
|
126
|
+
assert.ok(readme.includes('Bare metal') || readme.includes('BYO nginx'));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
suite('idp-init: idpCommand writes files to disk', () => {
|
|
130
|
+
let outDir;
|
|
131
|
+
setup(() => {
|
|
132
|
+
outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-idp-'));
|
|
133
|
+
});
|
|
134
|
+
teardown(() => {
|
|
135
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
136
|
+
});
|
|
137
|
+
test('writes every generated file under --out', async () => {
|
|
138
|
+
await (0, idp_1.idpCommand)('init', { hostname: 'auth.corp.example', out: outDir });
|
|
139
|
+
for (const rel of ['docker-compose.yml', 'Dockerfile.auth-broker', 'Caddyfile', '.env.example', '.env.auth.example', 'run.sh', 'agents/README.md', 'README.md']) {
|
|
140
|
+
assert.ok(fs.existsSync(path.join(outDir, rel)), `${rel} should be written`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
test('refuses to overwrite existing files without --force', async () => {
|
|
144
|
+
await (0, idp_1.idpCommand)('init', { hostname: 'auth.corp.example', out: outDir });
|
|
145
|
+
fs.writeFileSync(path.join(outDir, 'README.md'), 'hand-edited\n');
|
|
146
|
+
await (0, idp_1.idpCommand)('init', { hostname: 'auth.corp.example', out: outDir });
|
|
147
|
+
assert.strictEqual(fs.readFileSync(path.join(outDir, 'README.md'), 'utf8'), 'hand-edited\n');
|
|
148
|
+
await (0, idp_1.idpCommand)('init', { hostname: 'auth.corp.example', out: outDir, force: true });
|
|
149
|
+
assert.notStrictEqual(fs.readFileSync(path.join(outDir, 'README.md'), 'utf8'), 'hand-edited\n');
|
|
150
|
+
});
|
|
151
|
+
});
|
package/out/test/mcp-obo.test.js
CHANGED
|
@@ -34,49 +34,74 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
const assert = __importStar(require("assert"));
|
|
37
|
-
const node_fs_1 = require("node:fs");
|
|
38
|
-
const node_os_1 = require("node:os");
|
|
39
|
-
const node_path_1 = require("node:path");
|
|
40
37
|
const credential_context_1 = require("../cli/credential-context");
|
|
41
38
|
const mcp_1 = require("../mcp");
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The per-turn OBO credential must never reach MCP child env implicitly: the supported
|
|
41
|
+
* transport is the loopback data-plane gateway + turn nonce (secure-obo-design §2/§3).
|
|
42
|
+
* Inline resolution exists only behind the explicit local-dev opt-in.
|
|
43
|
+
*/
|
|
44
|
+
suite('mcp: OBO token in MCP env (secure-obo-design §3.4 — inline is opt-in only)', () => {
|
|
45
|
+
const ENV_KEYS = ['DATABRICKS_TOKEN', 'AGENTHIPPO_DATA_TOKEN_ENV_VARS', mcp_1.ALLOW_INLINE_DATA_TOKEN_ENV];
|
|
46
|
+
const saved = {};
|
|
45
47
|
setup(() => {
|
|
46
|
-
|
|
48
|
+
for (const k of ENV_KEYS) {
|
|
49
|
+
saved[k] = process.env[k];
|
|
50
|
+
delete process.env[k];
|
|
51
|
+
}
|
|
47
52
|
});
|
|
48
53
|
teardown(() => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
for (const k of ENV_KEYS) {
|
|
55
|
+
if (saved[k] === undefined) {
|
|
56
|
+
delete process.env[k];
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
process.env[k] = saved[k];
|
|
60
|
+
}
|
|
54
61
|
}
|
|
55
|
-
(0, node_fs_1.rmSync)(tokenDir, { recursive: true, force: true });
|
|
56
62
|
});
|
|
57
|
-
test('static PAT when no credential scope', () => {
|
|
63
|
+
test('static PAT resolves from plain env when no credential scope', () => {
|
|
58
64
|
process.env.DATABRICKS_TOKEN = 'pat-static';
|
|
59
65
|
assert.strictEqual((0, mcp_1.resolveMcpEnvVar)('DATABRICKS_TOKEN'), 'pat-static');
|
|
60
66
|
});
|
|
61
|
-
test('OBO token
|
|
62
|
-
process.env.
|
|
67
|
+
test('WITHOUT the opt-in, a live OBO token never bakes into MCP child env', async () => {
|
|
68
|
+
process.env.AGENTHIPPO_DATA_TOKEN_ENV_VARS = 'DATABRICKS_TOKEN';
|
|
63
69
|
await (0, credential_context_1.runWithCredential)({ userId: 'u@x.com', dataToken: 'obo-live' }, async () => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
await (0, credential_context_1.runWithCredential)({ userId: 'u@x.com', dataToken: 'obo-file-token' }, async () => {
|
|
70
|
+
// alias reference falls back to ambient env (unset here → empty), never the credential
|
|
71
|
+
assert.strictEqual((0, mcp_1.resolveMcpEnvVar)('DATABRICKS_TOKEN'), '');
|
|
72
|
+
// explicit placeholder resolves to nothing → the connector fails closed
|
|
73
|
+
assert.strictEqual((0, mcp_1.resolveMcpEnvVar)('dataToken'), '');
|
|
69
74
|
const resolved = (0, mcp_1.resolveMcpServerEnv)({
|
|
70
75
|
DATABRICKS_HOST: 'dbc.example.com',
|
|
71
76
|
DATABRICKS_TOKEN: '${credential:dataToken}',
|
|
72
77
|
});
|
|
73
78
|
assert.ok(resolved);
|
|
74
79
|
assert.strictEqual(resolved.DATABRICKS_HOST, 'dbc.example.com');
|
|
75
|
-
assert.strictEqual(resolved.DATABRICKS_TOKEN, undefined);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
assert.strictEqual(resolved.DATABRICKS_TOKEN, undefined, 'credential must not appear in spawn env without the opt-in');
|
|
81
|
+
assert.ok(!JSON.stringify(resolved).includes('obo-live'), 'the raw token must not leak anywhere in the resolved env');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
test('WITH the explicit local-dev opt-in, inline resolution works (alias + placeholder)', async () => {
|
|
85
|
+
process.env[mcp_1.ALLOW_INLINE_DATA_TOKEN_ENV] = '1';
|
|
86
|
+
process.env.AGENTHIPPO_DATA_TOKEN_ENV_VARS = 'DATABRICKS_TOKEN';
|
|
87
|
+
process.env.DATABRICKS_TOKEN = 'pat-static';
|
|
88
|
+
await (0, credential_context_1.runWithCredential)({ userId: 'u@x.com', dataToken: 'obo-live' }, async () => {
|
|
89
|
+
assert.strictEqual((0, mcp_1.resolveMcpEnvVar)('DATABRICKS_TOKEN'), 'obo-live', 'OBO token overrides static PAT when opted in');
|
|
90
|
+
const resolved = (0, mcp_1.resolveMcpServerEnv)({ DATABRICKS_TOKEN: '${credential:dataToken}' });
|
|
91
|
+
assert.strictEqual(resolved.DATABRICKS_TOKEN, 'obo-live');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
test('alias list is empty by default — DATABRICKS_TOKEN is no longer an implicit credential alias', async () => {
|
|
95
|
+
process.env[mcp_1.ALLOW_INLINE_DATA_TOKEN_ENV] = '1';
|
|
96
|
+
process.env.DATABRICKS_TOKEN = 'pat-static';
|
|
97
|
+
await (0, credential_context_1.runWithCredential)({ userId: 'u@x.com', dataToken: 'obo-live' }, async () => {
|
|
98
|
+
// No AGENTHIPPO_DATA_TOKEN_ENV_VARS declared → the plain env var wins even opted-in.
|
|
99
|
+
assert.strictEqual((0, mcp_1.resolveMcpEnvVar)('DATABRICKS_TOKEN'), 'pat-static');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
test('no credential scope + placeholder → falls back to ambient env for local dev', () => {
|
|
103
|
+
process.env.DATABRICKS_TOKEN = 'pat-static';
|
|
104
|
+
const resolved = (0, mcp_1.resolveMcpServerEnv)({ DATABRICKS_TOKEN: '${credential:dataToken}' });
|
|
105
|
+
assert.strictEqual(resolved.DATABRICKS_TOKEN, 'pat-static');
|
|
81
106
|
});
|
|
82
107
|
});
|
package/out/test/mcp.test.js
CHANGED
|
@@ -43,6 +43,7 @@ const os = __importStar(require("os"));
|
|
|
43
43
|
const fs_1 = require("fs");
|
|
44
44
|
const mcp_1 = require("../mcp");
|
|
45
45
|
const branding_1 = require("../branding");
|
|
46
|
+
const credential_context_1 = require("../cli/credential-context");
|
|
46
47
|
suite('MCP Server Loading', () => {
|
|
47
48
|
let testDir;
|
|
48
49
|
const getAgentMcpDir = (agentId) => path.join(testDir, branding_1.AGENTIDE_DIR_NAME, 'agents', agentId, 'mcp');
|
|
@@ -245,16 +246,19 @@ suite('MCP Server Loading', () => {
|
|
|
245
246
|
// A bare (unquoted) dotted key would be misparsed as a nested table.
|
|
246
247
|
assert.ok(!result.includes('my.token = '));
|
|
247
248
|
});
|
|
248
|
-
test('autoApprove emits default_tools_approval_mode = "
|
|
249
|
+
test('autoApprove emits default_tools_approval_mode = "approve" per server', () => {
|
|
249
250
|
const servers = [
|
|
250
251
|
{ name: 'stdio-srv', label: 's', command: 'node', args: ['s.js'] },
|
|
251
252
|
{ name: 'http-srv', label: 'h', command: '', args: [], url: 'https://mcp.example.com/sse' },
|
|
252
253
|
];
|
|
253
254
|
const result = (0, mcp_1.generateCodexMcpConfig)(servers, { autoApprove: true });
|
|
254
|
-
assert.ok(result.includes('default_tools_approval_mode = "
|
|
255
|
+
assert.ok(result.includes('default_tools_approval_mode = "approve"'));
|
|
255
256
|
assert.ok(result.includes('url = "https://mcp.example.com/sse"'));
|
|
256
|
-
// Invalid legacy key/value must never be emitted.
|
|
257
|
+
// Invalid legacy key/value must never be emitted. "auto" is not a valid enum value
|
|
258
|
+
// either — Codex ≥0.131 ignores it, falls back to `prompt`, and headless exec then
|
|
259
|
+
// cancels every MCP tool call ("user cancelled MCP tool call").
|
|
257
260
|
assert.ok(!result.includes('approval_mode = "never"'));
|
|
261
|
+
assert.ok(!result.includes('"auto"'));
|
|
258
262
|
});
|
|
259
263
|
test('generateCodexMcpConfig emits http_headers for URL servers', () => {
|
|
260
264
|
const servers = [
|
|
@@ -271,6 +275,27 @@ suite('MCP Server Loading', () => {
|
|
|
271
275
|
assert.ok(result.includes('url = "https://mcp.example.com/sse"'));
|
|
272
276
|
assert.ok(result.includes('http_headers = { "Authorization" = "Bearer abc", "X-Custom" = "val" }'));
|
|
273
277
|
});
|
|
278
|
+
test('bearerTokenEnv emits bearer_token_env_var and NEVER a per-turn value — config.toml stays turn-invariant (secure-obo-design §3.2)', async () => {
|
|
279
|
+
const servers = [
|
|
280
|
+
{
|
|
281
|
+
name: 'data',
|
|
282
|
+
label: 'data',
|
|
283
|
+
command: '',
|
|
284
|
+
args: [],
|
|
285
|
+
url: 'http://127.0.0.1:39999/mcp',
|
|
286
|
+
bearerTokenEnv: 'AGENTHIPPO_TURN_NONCE',
|
|
287
|
+
},
|
|
288
|
+
];
|
|
289
|
+
const outsideScope = (0, mcp_1.generateCodexMcpConfig)(servers);
|
|
290
|
+
assert.ok(outsideScope.includes('bearer_token_env_var = "AGENTHIPPO_TURN_NONCE"'));
|
|
291
|
+
assert.ok(!outsideScope.includes('http_headers'), 'no resolved header may carry the nonce into the file');
|
|
292
|
+
// Byte-identical no matter which user's credential scope is ambient — this is the
|
|
293
|
+
// exact property that removes the cross-user config.toml race (defect 1).
|
|
294
|
+
const inAlice = await (0, credential_context_1.runWithCredential)({ userId: 'alice@corp.com', dataToken: 'tok-a' }, async () => (0, mcp_1.generateCodexMcpConfig)(servers));
|
|
295
|
+
const inBob = await (0, credential_context_1.runWithCredential)({ userId: 'bob@corp.com', dataToken: 'tok-b' }, async () => (0, mcp_1.generateCodexMcpConfig)(servers));
|
|
296
|
+
assert.strictEqual(inAlice, outsideScope);
|
|
297
|
+
assert.strictEqual(inBob, outsideScope);
|
|
298
|
+
});
|
|
274
299
|
});
|
|
275
300
|
suite('inline MCP helpers', () => {
|
|
276
301
|
test('formatInlineMcpServers maps resolved servers by name', () => {
|
|
@@ -306,6 +331,21 @@ suite('MCP Server Loading', () => {
|
|
|
306
331
|
remote: { type: 'http', url: 'https://mcp.example.com/sse', headers: { Authorization: 'Bearer abc' } },
|
|
307
332
|
});
|
|
308
333
|
});
|
|
334
|
+
test('bearerTokenEnv resolves against the PER-TURN env into an Authorization header (claude/custom path)', () => {
|
|
335
|
+
const server = {
|
|
336
|
+
name: 'data', label: 'data', command: '', args: [],
|
|
337
|
+
url: 'http://127.0.0.1:39999/mcp', bearerTokenEnv: 'AGENTHIPPO_TURN_NONCE',
|
|
338
|
+
};
|
|
339
|
+
const inline = (0, mcp_1.formatInlineMcpServers)([server], { AGENTHIPPO_TURN_NONCE: 'nonce-123' });
|
|
340
|
+
assert.deepStrictEqual(inline, {
|
|
341
|
+
data: { type: 'http', url: 'http://127.0.0.1:39999/mcp', headers: { Authorization: 'Bearer nonce-123' } },
|
|
342
|
+
});
|
|
343
|
+
// No nonce in the turn env (e.g. IDE) → no Authorization header, entry still valid.
|
|
344
|
+
const withoutNonce = (0, mcp_1.formatInlineMcpServers)([server], {});
|
|
345
|
+
assert.deepStrictEqual(withoutNonce, {
|
|
346
|
+
data: { type: 'http', url: 'http://127.0.0.1:39999/mcp' },
|
|
347
|
+
});
|
|
348
|
+
});
|
|
309
349
|
test('serialize and parse round-trip inline MCP', () => {
|
|
310
350
|
const inline = (0, mcp_1.formatInlineMcpServers)([
|
|
311
351
|
{ name: 'echo', label: 'echo', command: 'echo', args: ['hi'] },
|