@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
|
@@ -41,9 +41,6 @@ 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"));
|
|
47
44
|
const credential_context_1 = require("../cli/credential-context");
|
|
48
45
|
const serve_hardening_1 = require("../cli/serve-hardening");
|
|
49
46
|
suite('credential-context: presence + fail-closed', () => {
|
|
@@ -92,16 +89,6 @@ suite('credential-context: unresolved identity (userId is optional, never a plac
|
|
|
92
89
|
}), /OBO data token present without a verified user/);
|
|
93
90
|
assert.strictEqual(ran, false, 'fn must never run when the credential fails closed');
|
|
94
91
|
});
|
|
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
92
|
test('onDenied fires with the stable reason code exactly when the credential fails closed', async () => {
|
|
106
93
|
const denials = [];
|
|
107
94
|
await assert.rejects(() => (0, credential_context_1.runWithCredential)({ userId: undefined, dataToken: 'obo-secret' }, async () => 'ran', { onDenied: (reason) => { denials.push(reason); } }));
|
|
@@ -117,21 +104,29 @@ suite('credential-context: unresolved identity (userId is optional, never a plac
|
|
|
117
104
|
test('a throwing onDenied callback is swallowed — the fail-closed rejection still wins', async () => {
|
|
118
105
|
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
106
|
});
|
|
120
|
-
test('a
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
107
|
+
test('a hung onDenied callback cannot stall fail-closed forever', async () => {
|
|
108
|
+
const started = Date.now();
|
|
109
|
+
let resolved = false;
|
|
110
|
+
await assert.rejects(() => (0, credential_context_1.runWithCredential)({ userId: undefined, dataToken: 'obo-secret' }, async () => 'ran', {
|
|
111
|
+
onDeniedTimeoutMs: 50,
|
|
112
|
+
onDenied: () => new Promise(() => {
|
|
113
|
+
/* never settles */
|
|
114
|
+
}),
|
|
115
|
+
}), /fail-closed/);
|
|
116
|
+
resolved = true;
|
|
117
|
+
const elapsed = Date.now() - started;
|
|
118
|
+
assert.ok(resolved);
|
|
119
|
+
assert.ok(elapsed < 500, `fail-closed should win after onDenied timeout (elapsed=${elapsed}ms)`);
|
|
120
|
+
});
|
|
121
|
+
test('the credential never touches disk — it exists only in the async scope (secure-obo-design §2)', async () => {
|
|
122
|
+
await (0, credential_context_1.runWithCredential)({ userId: 'priya@meridian.com', dataToken: 'obo-secret' }, async () => {
|
|
123
|
+
const cred = (0, credential_context_1.currentCredential)();
|
|
124
|
+
assert.strictEqual(cred?.dataToken, 'obo-secret');
|
|
125
|
+
// The RequestCredential shape has no file-based transport anymore: the token
|
|
126
|
+
// reaches tools only via the loopback data-plane gateway + turn nonce.
|
|
127
|
+
assert.ok(!('dataTokenFile' in cred), 'no dataTokenFile surface may exist');
|
|
128
|
+
});
|
|
129
|
+
assert.strictEqual((0, credential_context_1.currentCredential)(), undefined);
|
|
135
130
|
});
|
|
136
131
|
});
|
|
137
132
|
suite('credential-context: concurrent isolation (the load-bearing property)', () => {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Custom-engine env layering tests.
|
|
4
|
+
*
|
|
5
|
+
* Pins the deliberate precedence contract that replaced two accidents:
|
|
6
|
+
* 1. buildRunnerChildEnv: PER-TURN values win over the host's ambient env (the old
|
|
7
|
+
* `{...turn.env, ...process.env}` order silently reverted per-turn state — including,
|
|
8
|
+
* latently, manifest-delivered credentials and any same-named ambient var).
|
|
9
|
+
* 2. deliverModelRoutingEnv: manifest `model.*EnvVar` delivery is NON-CLOBBERING — a var the
|
|
10
|
+
* operator already set in the host env is never overwritten. This is what keeps a STALE
|
|
11
|
+
* cursor manifest (one that still declares apiKeyEnvVar: CURSOR_API_KEY) from stomping the
|
|
12
|
+
* operator's real crsr_ key now that per-turn values win (the version-pairing case).
|
|
13
|
+
*--------------------------------------------------------------------------------------------*/
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
require("mocha");
|
|
49
|
+
const assert = __importStar(require("assert"));
|
|
50
|
+
const custom_1 = require("../engines/custom");
|
|
51
|
+
function fakeManifest(model) {
|
|
52
|
+
return {
|
|
53
|
+
id: 'fake-engine',
|
|
54
|
+
entryPath: '/tmp/fake/engine.mjs',
|
|
55
|
+
manifestDir: '/tmp/fake',
|
|
56
|
+
model: model ? { defaultModel: 'auto', ...model } : undefined,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function fakeTurn(env) {
|
|
60
|
+
return {
|
|
61
|
+
message: 'hi',
|
|
62
|
+
modelId: 'auto',
|
|
63
|
+
workspaceRoot: '/tmp/ws',
|
|
64
|
+
env,
|
|
65
|
+
session: { key: 'k', chatSessionId: 'c', isEphemeral: false },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Run fn with process.env temporarily patched; always restores. */
|
|
69
|
+
function withProcessEnv(patch, fn) {
|
|
70
|
+
const saved = new Map();
|
|
71
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
72
|
+
saved.set(k, process.env[k]);
|
|
73
|
+
if (v === undefined) {
|
|
74
|
+
delete process.env[k];
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
process.env[k] = v;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
fn();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
for (const [k, v] of saved) {
|
|
85
|
+
if (v === undefined) {
|
|
86
|
+
delete process.env[k];
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
process.env[k] = v;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
suite('buildRunnerChildEnv: per-turn values win over the host ambient env', () => {
|
|
95
|
+
test('a per-turn var beats the same-named ambient var (nonce/session-path class)', () => {
|
|
96
|
+
withProcessEnv({ TEST_LAYERING_VAR: 'ambient-stale' }, () => {
|
|
97
|
+
const child = (0, custom_1.buildRunnerChildEnv)(fakeManifest(), fakeTurn({ TEST_LAYERING_VAR: 'per-turn-fresh' }), false);
|
|
98
|
+
assert.strictEqual(child.TEST_LAYERING_VAR, 'per-turn-fresh');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
test('ambient vars absent from turn.env still flow through (plain inheritance)', () => {
|
|
102
|
+
withProcessEnv({ TEST_AMBIENT_ONLY: 'inherited' }, () => {
|
|
103
|
+
const child = (0, custom_1.buildRunnerChildEnv)(fakeManifest(), fakeTurn({}), false);
|
|
104
|
+
assert.strictEqual(child.TEST_AMBIENT_ONLY, 'inherited');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
test('AGENTHIPPO control vars come from the turn, never ambient', () => {
|
|
108
|
+
withProcessEnv({ AGENTHIPPO_MODEL_ID: 'ambient-wrong' }, () => {
|
|
109
|
+
const child = (0, custom_1.buildRunnerChildEnv)(fakeManifest(), fakeTurn({}), false);
|
|
110
|
+
assert.strictEqual(child.AGENTHIPPO_MODEL_ID, 'auto', 'explicit per-turn block must win');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
suite('deliverModelRoutingEnv: non-clobbering manifest credential delivery', () => {
|
|
115
|
+
test('delivers the routing key when the declared var is NOT in the ambient env (openclaw case)', () => {
|
|
116
|
+
const env = {};
|
|
117
|
+
(0, custom_1.deliverModelRoutingEnv)(env, fakeManifest({ apiKeyEnvVar: 'LITELLM_API_KEY' }), { apiKey: 'sk-proxy-key' }, {});
|
|
118
|
+
assert.strictEqual(env.LITELLM_API_KEY, 'sk-proxy-key');
|
|
119
|
+
});
|
|
120
|
+
test('SKIPS delivery when the operator already set the var — ambient wins (stale-cursor-manifest case)', () => {
|
|
121
|
+
// Version-pairing regression: old manifest still declares apiKeyEnvVar: CURSOR_API_KEY,
|
|
122
|
+
// operator has the real crsr_ key ambient. With per-turn-wins layering, only this
|
|
123
|
+
// non-clobber rule keeps the real key from being stomped by a model key.
|
|
124
|
+
const env = { CURSOR_API_KEY: 'crsr_real_operator_key' };
|
|
125
|
+
(0, custom_1.deliverModelRoutingEnv)(env, fakeManifest({ apiKeyEnvVar: 'CURSOR_API_KEY' }), { apiKey: 'sk-model-routing-key' }, { CURSOR_API_KEY: 'crsr_real_operator_key' });
|
|
126
|
+
assert.strictEqual(env.CURSOR_API_KEY, 'crsr_real_operator_key', 'operator credential must never be overwritten');
|
|
127
|
+
});
|
|
128
|
+
test('stale-manifest end to end: child env keeps the operator key after the spread flip', () => {
|
|
129
|
+
withProcessEnv({ CURSOR_API_KEY: 'crsr_real_operator_key' }, () => {
|
|
130
|
+
// Simulate the turn-env build with an OLD manifest: ambient spread + (non-clobbered) delivery.
|
|
131
|
+
const turnEnv = { ...process.env };
|
|
132
|
+
(0, custom_1.deliverModelRoutingEnv)(turnEnv, fakeManifest({ apiKeyEnvVar: 'CURSOR_API_KEY' }), { apiKey: 'sk-model-key' }, process.env);
|
|
133
|
+
const child = (0, custom_1.buildRunnerChildEnv)(fakeManifest({ apiKeyEnvVar: 'CURSOR_API_KEY' }), fakeTurn(turnEnv), false);
|
|
134
|
+
assert.strictEqual(child.CURSOR_API_KEY, 'crsr_real_operator_key');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
test('base URL vars follow the same rule (deliver when absent, keep ambient when present)', () => {
|
|
138
|
+
const env = {};
|
|
139
|
+
(0, custom_1.deliverModelRoutingEnv)(env, fakeManifest({ baseUrlEnvVar: 'ENGINE_BASE_URL' }), { baseUrl: 'http://proxy:4000' }, {});
|
|
140
|
+
assert.strictEqual(env.ENGINE_BASE_URL, 'http://proxy:4000');
|
|
141
|
+
const env2 = { ENGINE_BASE_URL: 'http://operator-choice' };
|
|
142
|
+
(0, custom_1.deliverModelRoutingEnv)(env2, fakeManifest({ baseUrlEnvVar: 'ENGINE_BASE_URL' }), { baseUrl: 'http://proxy:4000' }, { ENGINE_BASE_URL: 'http://operator-choice' });
|
|
143
|
+
assert.strictEqual(env2.ENGINE_BASE_URL, 'http://operator-choice');
|
|
144
|
+
});
|
|
145
|
+
test('no declaration → no delivery, whatever the routing key is (the fixed cursor manifest)', () => {
|
|
146
|
+
const env = { CURSOR_API_KEY: 'crsr_real_operator_key' };
|
|
147
|
+
(0, custom_1.deliverModelRoutingEnv)(env, fakeManifest(), { apiKey: 'sk-model-key' }, {});
|
|
148
|
+
assert.strictEqual(env.CURSOR_API_KEY, 'crsr_real_operator_key');
|
|
149
|
+
assert.strictEqual(Object.keys(env).length, 1, 'nothing else may be written');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Data-plane engine wiring tests (secure-obo-design §3.2, §6) — NO API KEY NEEDED.
|
|
4
|
+
*
|
|
5
|
+
* These exercise the *real* engine config paths (not just the pure helpers): given a pack
|
|
6
|
+
* that declares the loopback gateway MCP entry + skills, does each engine actually
|
|
7
|
+
* (a) put the turn-invariant gateway entry into its config, with the nonce sourced from
|
|
8
|
+
* the PROCESS env (never a per-turn value written to the file), and
|
|
9
|
+
* (b) sync the pack's skills into the engine's home so the agent can see them,
|
|
10
|
+
* and does the per-user CODEX_HOME derivation keep concurrent users' state apart while
|
|
11
|
+
* keeping the written config byte-identical (the property that kills defect 1)?
|
|
12
|
+
*--------------------------------------------------------------------------------------------*/
|
|
13
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
16
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
17
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
18
|
+
}
|
|
19
|
+
Object.defineProperty(o, k2, desc);
|
|
20
|
+
}) : (function(o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
o[k2] = m[k];
|
|
23
|
+
}));
|
|
24
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
25
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
26
|
+
}) : function(o, v) {
|
|
27
|
+
o["default"] = v;
|
|
28
|
+
});
|
|
29
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
30
|
+
var ownKeys = function(o) {
|
|
31
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
32
|
+
var ar = [];
|
|
33
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
34
|
+
return ar;
|
|
35
|
+
};
|
|
36
|
+
return ownKeys(o);
|
|
37
|
+
};
|
|
38
|
+
return function (mod) {
|
|
39
|
+
if (mod && mod.__esModule) return mod;
|
|
40
|
+
var result = {};
|
|
41
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
42
|
+
__setModuleDefault(result, mod);
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
})();
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
require("mocha");
|
|
48
|
+
const assert = __importStar(require("assert"));
|
|
49
|
+
const fs = __importStar(require("fs"));
|
|
50
|
+
const os = __importStar(require("os"));
|
|
51
|
+
const path = __importStar(require("path"));
|
|
52
|
+
const codex_1 = require("../engines/codex");
|
|
53
|
+
const mcp_1 = require("../mcp");
|
|
54
|
+
const paths_1 = require("../agentpack/paths");
|
|
55
|
+
const credential_context_1 = require("../cli/credential-context");
|
|
56
|
+
const GATEWAY_MCP_JSON = JSON.stringify({
|
|
57
|
+
mcpServers: {
|
|
58
|
+
data: {
|
|
59
|
+
url: '${env:AGENTHIPPO_DATA_MCP_URL}',
|
|
60
|
+
bearerTokenEnv: 'AGENTHIPPO_TURN_NONCE',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
function silentLogger() {
|
|
65
|
+
const lines = [];
|
|
66
|
+
const push = (m) => { lines.push(m); };
|
|
67
|
+
return { lines, info: push, warn: push, error: push, debug: push };
|
|
68
|
+
}
|
|
69
|
+
/** Build a pack on disk with the gateway MCP entry + N skills, return workspace + agentId. */
|
|
70
|
+
function makePack(skillNames) {
|
|
71
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'ah-wire-'));
|
|
72
|
+
const agentId = 'wire-agent';
|
|
73
|
+
const mcpDir = (0, paths_1.getAgentMcpDir)(workspace, agentId);
|
|
74
|
+
fs.mkdirSync(mcpDir, { recursive: true });
|
|
75
|
+
fs.writeFileSync(path.join(mcpDir, '.mcp.json'), GATEWAY_MCP_JSON, 'utf8');
|
|
76
|
+
const agentRoot = path.dirname(mcpDir);
|
|
77
|
+
fs.writeFileSync(path.join(agentRoot, 'AGENTS.md'), '# Wire Agent\n\nAnswer questions.\n', 'utf8');
|
|
78
|
+
const skillsDir = (0, paths_1.getAgentSkillsDir)(workspace, agentId);
|
|
79
|
+
for (const name of skillNames) {
|
|
80
|
+
const dir = path.join(skillsDir, name);
|
|
81
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
82
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: test skill ${name}\n---\n\nDo ${name} things.\n`, 'utf8');
|
|
83
|
+
// a bundled resource, to prove the whole skill dir is copied, not just SKILL.md
|
|
84
|
+
fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true });
|
|
85
|
+
fs.writeFileSync(path.join(dir, 'scripts', 'run.sh'), `echo ${name}\n`, 'utf8');
|
|
86
|
+
}
|
|
87
|
+
return { workspace, agentId };
|
|
88
|
+
}
|
|
89
|
+
async function writeCodexConfig(workspace, agentId, codexHome) {
|
|
90
|
+
const logger = silentLogger();
|
|
91
|
+
// getSkillsSignature must return undefined so the sync is not skipped by the signature cache.
|
|
92
|
+
const engine = new codex_1.CodexEngine({ storagePath: workspace, codexHome: undefined, claudeConfigDir: undefined, litellmApiKey: 'k', litellmBaseUrl: 'http://litellm.local' }, () => undefined, () => undefined);
|
|
93
|
+
await engine.ensureCodexConfig(true, // useLiteLLM
|
|
94
|
+
'http://litellm.local', // customBaseUrl
|
|
95
|
+
'gpt-5.5', // modelId
|
|
96
|
+
workspace, // workspaceRoot
|
|
97
|
+
[], // additionalDirectories
|
|
98
|
+
logger, // logger
|
|
99
|
+
codexHome, // explicit codexHome
|
|
100
|
+
agentId, // agentId
|
|
101
|
+
true);
|
|
102
|
+
return fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
|
103
|
+
}
|
|
104
|
+
/** The `[mcp_servers.data]` block only — the part that must be turn- AND user-invariant. */
|
|
105
|
+
function mcpDataBlock(toml) {
|
|
106
|
+
const start = toml.indexOf('[mcp_servers.data]');
|
|
107
|
+
assert.ok(start >= 0, `config.toml missing [mcp_servers.data]:\n${toml}`);
|
|
108
|
+
const rest = toml.slice(start + '[mcp_servers.data]'.length);
|
|
109
|
+
const next = rest.search(/\n\[/);
|
|
110
|
+
return (next >= 0 ? rest.slice(0, next) : rest).trim();
|
|
111
|
+
}
|
|
112
|
+
suite('data-plane engine wiring: codex config + skills sync (no API key)', function () {
|
|
113
|
+
this.timeout(30_000);
|
|
114
|
+
test('config.toml carries the turn-invariant gateway entry; no per-turn value in the file', async () => {
|
|
115
|
+
const { workspace, agentId } = makePack(['alpha', 'beta']);
|
|
116
|
+
try {
|
|
117
|
+
process.env.AGENTHIPPO_DATA_MCP_URL = 'http://127.0.0.1:45999/mcp';
|
|
118
|
+
const codexHome = path.join(workspace, 'home');
|
|
119
|
+
fs.mkdirSync(codexHome, { recursive: true });
|
|
120
|
+
const toml = await writeCodexConfig(workspace, agentId, codexHome);
|
|
121
|
+
const block = mcpDataBlock(toml);
|
|
122
|
+
assert.ok(block.includes('url = "http://127.0.0.1:45999/mcp"'), 'gateway URL must resolve from process env');
|
|
123
|
+
assert.ok(block.includes('bearer_token_env_var = "AGENTHIPPO_TURN_NONCE"'), 'nonce must be read from the codex process env, not the file');
|
|
124
|
+
// The MCP entry must NOT contain any resolved nonce/header — that is what makes it turn-invariant.
|
|
125
|
+
// (The unrelated LiteLLM analytics block legitimately has an env_http_headers line of env-var NAMES.)
|
|
126
|
+
assert.ok(!/http_headers/.test(block), `no resolved auth header may be written into the MCP entry:\n${block}`);
|
|
127
|
+
assert.ok(!/Authorization/i.test(block), block);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
delete process.env.AGENTHIPPO_DATA_MCP_URL;
|
|
131
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
test('the agent sees its skills: each SKILL.md (and its resources) is synced into $CODEX_HOME/skills', async () => {
|
|
135
|
+
const { workspace, agentId } = makePack(['alpha', 'beta']);
|
|
136
|
+
try {
|
|
137
|
+
process.env.AGENTHIPPO_DATA_MCP_URL = 'http://127.0.0.1:45999/mcp';
|
|
138
|
+
const codexHome = path.join(workspace, 'home');
|
|
139
|
+
fs.mkdirSync(codexHome, { recursive: true });
|
|
140
|
+
await writeCodexConfig(workspace, agentId, codexHome);
|
|
141
|
+
for (const name of ['alpha', 'beta']) {
|
|
142
|
+
const skillMd = path.join(codexHome, 'skills', name, 'SKILL.md');
|
|
143
|
+
assert.ok(fs.existsSync(skillMd), `skill ${name} must be visible in $CODEX_HOME/skills: ${skillMd}`);
|
|
144
|
+
assert.ok(fs.readFileSync(skillMd, 'utf8').includes(`description: test skill ${name}`));
|
|
145
|
+
// bundled resources come along, not just the SKILL.md
|
|
146
|
+
assert.ok(fs.existsSync(path.join(codexHome, 'skills', name, 'scripts', 'run.sh')), 'skill resource files must be copied too');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
delete process.env.AGENTHIPPO_DATA_MCP_URL;
|
|
151
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
test('per-user CODEX_HOME: isolated homes+skills; the gateway entry is identical across users (only per-user paths differ)', async () => {
|
|
155
|
+
const { workspace, agentId } = makePack(['alpha']);
|
|
156
|
+
try {
|
|
157
|
+
process.env.AGENTHIPPO_DATA_MCP_URL = 'http://127.0.0.1:45999/mcp';
|
|
158
|
+
const base = path.join(workspace, 'home');
|
|
159
|
+
const aliceHome = (0, paths_1.userScopedEngineHome)(base, 'alice@corp.com');
|
|
160
|
+
const bobHome = (0, paths_1.userScopedEngineHome)(base, 'bob@corp.com');
|
|
161
|
+
assert.notStrictEqual(aliceHome, bobHome, 'distinct users must get distinct homes');
|
|
162
|
+
assert.ok(!aliceHome.includes('alice@corp.com'), 'user id must be hashed, never a raw path segment');
|
|
163
|
+
fs.mkdirSync(aliceHome, { recursive: true });
|
|
164
|
+
fs.mkdirSync(bobHome, { recursive: true });
|
|
165
|
+
const aliceToml = await (0, credential_context_1.runWithCredential)({ userId: 'alice@corp.com' }, () => writeCodexConfig(workspace, agentId, aliceHome));
|
|
166
|
+
const bobToml = await (0, credential_context_1.runWithCredential)({ userId: 'bob@corp.com' }, () => writeCodexConfig(workspace, agentId, bobHome));
|
|
167
|
+
// The MCP gateway entry carries NO per-user/per-turn value → identical across users.
|
|
168
|
+
// (The instructions-file path legitimately differs because it lives inside each user's
|
|
169
|
+
// own home — a per-user path, not a shared file, so it is not a cross-user race.)
|
|
170
|
+
assert.strictEqual(mcpDataBlock(aliceToml), mcpDataBlock(bobToml), 'the gateway MCP entry must be user-invariant');
|
|
171
|
+
assert.ok(fs.existsSync(path.join(aliceHome, 'skills', 'alpha', 'SKILL.md')));
|
|
172
|
+
assert.ok(fs.existsSync(path.join(bobHome, 'skills', 'alpha', 'SKILL.md')));
|
|
173
|
+
assert.ok(aliceHome.startsWith(path.join(base, 'users')) && bobHome.startsWith(path.join(base, 'users')));
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
delete process.env.AGENTHIPPO_DATA_MCP_URL;
|
|
177
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
test('defect-1 fix: two concurrent turns of the SAME user (same home) write byte-identical config.toml', async () => {
|
|
181
|
+
const { workspace, agentId } = makePack(['alpha']);
|
|
182
|
+
try {
|
|
183
|
+
process.env.AGENTHIPPO_DATA_MCP_URL = 'http://127.0.0.1:45999/mcp';
|
|
184
|
+
const base = path.join(workspace, 'home');
|
|
185
|
+
const home = (0, paths_1.userScopedEngineHome)(base, 'alice@corp.com');
|
|
186
|
+
fs.mkdirSync(home, { recursive: true });
|
|
187
|
+
// Two turns, same user, same home — historically the racing writers of defect 1.
|
|
188
|
+
const first = await (0, credential_context_1.runWithCredential)({ userId: 'alice@corp.com', dataToken: 'obo-turn-1' }, () => writeCodexConfig(workspace, agentId, home));
|
|
189
|
+
const second = await (0, credential_context_1.runWithCredential)({ userId: 'alice@corp.com', dataToken: 'obo-turn-2' }, () => writeCodexConfig(workspace, agentId, home));
|
|
190
|
+
assert.strictEqual(first, second, 'same-home concurrent turns must write identical bytes — the race is gone by construction');
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
delete process.env.AGENTHIPPO_DATA_MCP_URL;
|
|
194
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
test('no verified user (IDE path) → home is the un-derived base, unchanged', () => {
|
|
198
|
+
const base = '/tmp/some/codex/home';
|
|
199
|
+
assert.strictEqual((0, paths_1.userScopedEngineHome)(base, undefined), base);
|
|
200
|
+
assert.strictEqual((0, paths_1.userScopedEngineHome)(base, ' '), base);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
suite('data-plane engine wiring: loadMcpServers + claude/custom shape (no API key)', function () {
|
|
204
|
+
this.timeout(30_000);
|
|
205
|
+
test('loadMcpServers reads a gateway .mcp.json into a url entry with bearerTokenEnv (URL resolved from env)', async () => {
|
|
206
|
+
const { workspace, agentId } = makePack([]);
|
|
207
|
+
try {
|
|
208
|
+
process.env.AGENTHIPPO_DATA_MCP_URL = 'http://127.0.0.1:47000/mcp';
|
|
209
|
+
const servers = await (0, mcp_1.loadMcpServers)(agentId, workspace);
|
|
210
|
+
assert.strictEqual(servers.length, 1);
|
|
211
|
+
assert.strictEqual(servers[0].name, 'data');
|
|
212
|
+
assert.strictEqual(servers[0].url, 'http://127.0.0.1:47000/mcp');
|
|
213
|
+
assert.strictEqual(servers[0].bearerTokenEnv, 'AGENTHIPPO_TURN_NONCE');
|
|
214
|
+
assert.strictEqual(servers[0].headers, undefined, 'no static header — the nonce is resolved per turn against the engine env');
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
delete process.env.AGENTHIPPO_DATA_MCP_URL;
|
|
218
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
test('a gateway entry whose URL placeholder does not resolve is dropped (never a broken empty-url entry)', async () => {
|
|
222
|
+
const { workspace, agentId } = makePack([]);
|
|
223
|
+
try {
|
|
224
|
+
delete process.env.AGENTHIPPO_DATA_MCP_URL; // e.g. loaded outside serve
|
|
225
|
+
const servers = await (0, mcp_1.loadMcpServers)(agentId, workspace);
|
|
226
|
+
assert.strictEqual(servers.length, 0, 'unresolved gateway URL → drop the server rather than hand engines an empty URL');
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
test('custom-engine transport: serialize→parse round-trips the http entry with the per-turn nonce header', async () => {
|
|
233
|
+
const { workspace, agentId } = makePack([]);
|
|
234
|
+
try {
|
|
235
|
+
process.env.AGENTHIPPO_DATA_MCP_URL = 'http://127.0.0.1:47000/mcp';
|
|
236
|
+
const servers = await (0, mcp_1.loadMcpServers)(agentId, workspace);
|
|
237
|
+
// This is exactly what custom.ts does: resolve against the per-turn child env, then
|
|
238
|
+
// serialize into AGENTHIPPO_MCP_SERVERS for the runner to parse back.
|
|
239
|
+
const inline = (0, mcp_1.formatInlineMcpServers)(servers, { AGENTHIPPO_TURN_NONCE: 'nonce-xyz' });
|
|
240
|
+
const roundTripped = (0, mcp_1.parseInlineMcpServers)((0, mcp_1.serializeInlineMcpServers)(inline));
|
|
241
|
+
assert.deepStrictEqual(roundTripped, {
|
|
242
|
+
data: { type: 'http', url: 'http://127.0.0.1:47000/mcp', headers: { Authorization: 'Bearer nonce-xyz' } },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
delete process.env.AGENTHIPPO_DATA_MCP_URL;
|
|
247
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|