@agenthippo-ai/agenthippo 0.1.0 → 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.
Files changed (68) hide show
  1. package/out/agentpack/index.js +2 -1
  2. package/out/agentpack/paths.js +23 -0
  3. package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
  4. package/out/analytics/dashboard/webview/panel.js +290 -80
  5. package/out/analytics/sessionEvents.js +21 -4
  6. package/out/auth/brokerClient.js +1 -1
  7. package/out/auth/onboardingPrompt.js +112 -0
  8. package/out/auth/provider.js +1 -1
  9. package/out/auth-broker/config.js +8 -3
  10. package/out/auth-broker/pkce.js +3 -1
  11. package/out/auth-broker/server.js +43 -1
  12. package/out/auth-broker/store.js +6 -5
  13. package/out/cli/commands/ask.js +3 -0
  14. package/out/cli/commands/chat.js +3 -0
  15. package/out/cli/commands/deploy.js +49 -10
  16. package/out/cli/commands/gateway.js +54 -0
  17. package/out/cli/commands/idp.js +85 -0
  18. package/out/cli/commands/serve.js +401 -49
  19. package/out/cli/credential-context.js +47 -69
  20. package/out/cli/data-plane/broker.js +78 -0
  21. package/out/cli/data-plane/connector-host.js +121 -0
  22. package/out/cli/data-plane/egress.js +189 -0
  23. package/out/cli/data-plane/gateway.js +195 -0
  24. package/out/cli/data-plane/index.js +137 -0
  25. package/out/cli/data-plane/registry.js +80 -0
  26. package/out/cli/deploy/blueprints/registry.js +84 -0
  27. package/out/cli/deploy/layers/identity.js +91 -0
  28. package/out/cli/deploy-init.js +1156 -285
  29. package/out/cli/idp-init.js +303 -0
  30. package/out/cli/index.js +46 -8
  31. package/out/cli/pack-loader.js +37 -2
  32. package/out/cli/runtime.js +10 -3
  33. package/out/cli/serve-hardening.js +217 -12
  34. package/out/cli/state-sync.js +318 -29
  35. package/out/engines/claude.js +26 -5
  36. package/out/engines/codex.js +24 -5
  37. package/out/engines/custom.js +46 -12
  38. package/out/extension.js +2 -0
  39. package/out/gateway/outboundSend.js +6 -6
  40. package/out/headless/index.js +24 -6
  41. package/out/live-probe/connector.mjs +8 -0
  42. package/out/live-probe/env-report.mjs +8 -0
  43. package/out/mcp.js +88 -61
  44. package/out/remote/handleRemoteChat.js +7 -1
  45. package/out/remote/serveClient.js +27 -13
  46. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
  47. package/out/shared/models.js +1 -1
  48. package/out/shared/providers.js +5 -2
  49. package/out/test/analytics.test.js +93 -0
  50. package/out/test/auth-broker.test.js +59 -2
  51. package/out/test/blueprint-registry.test.js +105 -0
  52. package/out/test/credential-context.test.js +55 -0
  53. package/out/test/custom-env-layering.test.js +151 -0
  54. package/out/test/data-plane-engine-wiring.test.js +250 -0
  55. package/out/test/data-plane.test.js +244 -0
  56. package/out/test/deploy-init.test.js +287 -5
  57. package/out/test/egress-broker.test.js +172 -0
  58. package/out/test/fleetDisplayUtils.test.js +4 -0
  59. package/out/test/idp-init.test.js +151 -0
  60. package/out/test/mcp-obo.test.js +52 -27
  61. package/out/test/mcp.test.js +60 -4
  62. package/out/test/model-routing.test.js +133 -0
  63. package/out/test/serve-gateway-turn.test.js +267 -0
  64. package/out/test/serve-hardening.test.js +187 -0
  65. package/out/test/serveClient.test.js +37 -2
  66. package/out/test/state-sync-peer.test.js +83 -1
  67. package/out/utils.js +27 -11
  68. package/package.json +3 -4
@@ -2,37 +2,93 @@
2
2
  /*---------------------------------------------------------------------------------------------
3
3
  * Copyright (c) AgentHippo.ai. All rights reserved.
4
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
+ })();
5
38
  Object.defineProperty(exports, "__esModule", { value: true });
6
39
  exports.isAwsCliAvailable = isAwsCliAvailable;
7
40
  exports.resolveS3StateConfig = resolveS3StateConfig;
41
+ exports.resolveRestoreBootMode = resolveRestoreBootMode;
42
+ exports.safeSessionId = safeSessionId;
43
+ exports.sessionRemoteUri = sessionRemoteUri;
44
+ exports.shardBelongsToSession = shardBelongsToSession;
45
+ exports.hasLocalSessionState = hasLocalSessionState;
46
+ exports.listSessionShardFiles = listSessionShardFiles;
47
+ exports.stageSessionPushDir = stageSessionPushDir;
48
+ exports.mergeSessionStagingIntoState = mergeSessionStagingIntoState;
8
49
  exports.restoreState = restoreState;
9
50
  exports.pushState = pushState;
51
+ exports.pushSessionState = pushSessionState;
52
+ exports.ensureSessionLocal = ensureSessionLocal;
53
+ exports.clearRestoreAttemptCache = clearRestoreAttemptCache;
10
54
  exports.makeStatePusher = makeStatePusher;
55
+ exports.providerSessionShardName = providerSessionShardName;
11
56
  /**
12
- * Object-store state sync (serve-v3 Q1, shared SessionStore).
57
+ * Object-store state sync (serve-v3 Q1 + serve-v4 P1.2).
13
58
  *
14
- * Persists the durable state root (/state) to an S3-compatible object store so
15
- * sessions survive scale-to-zero / a fresh instance even where there is NO
16
- * filesystem mount (Cloudflare R2, Databricks → S3/DBFS), and so a sticky-routed
17
- * fleet replica can reload an affinity-owned session.
59
+ * Persists the durable state root to an S3-compatible object store so sessions
60
+ * survive scale-to-zero / a fresh instance (Cloudflare R2, MinIO, GCS, AWS S3)
61
+ * and so a sticky-routed fleet can restore-on-miss after resharding.
18
62
  *
19
- * Strategy (agreed): boot restore + per-turn async-delta push (debounced, off
20
- * the response path) + a SIGTERM flush. Uses `aws s3 sync` (delta by default;
21
- * S3-compatible via --endpoint-url → MinIO / R2 / GCS / S3) so we add no SDK to
22
- * the binary. Single-writer-per-session safety comes from sticky-by-session_id.
63
+ * Strategy:
64
+ * - Per-session async-delta push (debounced, off the response path) →
65
+ * s3://…/sessions/<sessionId>/
66
+ * - Restore-on-miss before a turn when the session is not local
67
+ * - Optional boot restore of the whole prefix (`AGENTHIPPO_STATE_RESTORE_BOOT=all`)
68
+ * - SIGTERM flush of pending session pushes
69
+ *
70
+ * Uses `aws s3 sync` (S3-compatible via --endpoint-url). No SDK in the binary.
71
+ * Last-write-wins per session prefix — no CAS. Mid-reshard may lose one turn of
72
+ * context; sticky routing makes that rare.
23
73
  *
24
74
  * Config (no-op unless AGENTHIPPO_STATE_S3_URI is set):
25
- * AGENTHIPPO_STATE_S3_URI s3://bucket/prefix
26
- * AGENTHIPPO_STATE_S3_ENDPOINT http://minio:9000 (for non-AWS S3)
27
- * AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION (standard aws creds)
75
+ * AGENTHIPPO_STATE_S3_URI s3://bucket/prefix
76
+ * AGENTHIPPO_STATE_S3_ENDPOINT http://minio:9000 (non-AWS S3)
77
+ * AGENTHIPPO_STATE_RESTORE_BOOT all|none (default all for single-instance compat;
78
+ * multi-replica templates set none)
79
+ * AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION
28
80
  */
81
+ const crypto_1 = require("crypto");
29
82
  const child_process_1 = require("child_process");
83
+ const fsp = __importStar(require("fs/promises"));
84
+ const path = __importStar(require("path"));
30
85
  const util_1 = require("util");
31
86
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
87
+ /** In-process cache: skip repeat S3 pulls for the same session on this replica. */
88
+ const restoreAttempted = new Set();
32
89
  /**
33
90
  * Verify the `aws` CLI is callable. State sync shells out to it, so when S3 is
34
91
  * configured a missing binary means silent data loss — callers should fail fast.
35
- * Resolves true when available, false otherwise.
36
92
  */
37
93
  async function isAwsCliAvailable() {
38
94
  try {
@@ -54,6 +110,27 @@ function resolveS3StateConfig(env = process.env) {
54
110
  endpoint: env.AGENTHIPPO_STATE_S3_ENDPOINT?.trim() || undefined,
55
111
  };
56
112
  }
113
+ /**
114
+ * Boot restore mode. Default `all` preserves single-instance behavior.
115
+ * Multi-replica templates set `none` and rely on restore-on-miss (P1.2).
116
+ */
117
+ function resolveRestoreBootMode(env = process.env) {
118
+ const raw = env.AGENTHIPPO_STATE_RESTORE_BOOT?.trim().toLowerCase();
119
+ if (raw === 'none') {
120
+ return 'none';
121
+ }
122
+ return 'all';
123
+ }
124
+ /** Sanitize a client session id for use as an S3 path segment / local dir name. */
125
+ function safeSessionId(sessionId) {
126
+ const trimmed = sessionId.trim();
127
+ const safe = trimmed.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '_').replace(/\.\./g, '_').slice(0, 160);
128
+ return safe || 'session';
129
+ }
130
+ /** Remote prefix for one session: s3://bucket/prefix/sessions/<id> */
131
+ function sessionRemoteUri(cfg, sessionId) {
132
+ return `${cfg.uri}/sessions/${safeSessionId(sessionId)}`;
133
+ }
57
134
  function syncArgs(cfg, from, to) {
58
135
  const args = ['s3', 'sync', from, to, '--no-progress'];
59
136
  if (cfg.endpoint) {
@@ -61,6 +138,127 @@ function syncArgs(cfg, from, to) {
61
138
  }
62
139
  return args;
63
140
  }
141
+ function attemptKey(stateDir, sessionId) {
142
+ return `${path.resolve(stateDir)}\0${sessionId}`;
143
+ }
144
+ /** Conversation key format: engine:chatSessionId:pack:signature */
145
+ function shardBelongsToSession(shardKey, chatSessionId, value) {
146
+ if (value?.chatSessionId && value.chatSessionId === chatSessionId) {
147
+ return true;
148
+ }
149
+ const parts = shardKey.split(':');
150
+ return parts.length >= 2 && parts[1] === chatSessionId;
151
+ }
152
+ /**
153
+ * True when this replica already has provider-session state for the chat session.
154
+ * Used to skip restore-on-miss on the hot path.
155
+ */
156
+ async function hasLocalSessionState(stateDir, sessionId) {
157
+ const dir = path.join(stateDir, 'provider-sessions');
158
+ let names;
159
+ try {
160
+ names = await fsp.readdir(dir);
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ for (const name of names) {
166
+ if (!name.endsWith('.json')) {
167
+ continue;
168
+ }
169
+ try {
170
+ const raw = await fsp.readFile(path.join(dir, name), 'utf8');
171
+ const parsed = JSON.parse(raw);
172
+ if (typeof parsed.key === 'string' && shardBelongsToSession(parsed.key, sessionId, parsed.value)) {
173
+ return true;
174
+ }
175
+ }
176
+ catch {
177
+ // skip unreadable
178
+ }
179
+ }
180
+ return false;
181
+ }
182
+ /** Collect provider-session shard filenames that belong to this chat session. */
183
+ async function listSessionShardFiles(stateDir, sessionId) {
184
+ const dir = path.join(stateDir, 'provider-sessions');
185
+ let names;
186
+ try {
187
+ names = await fsp.readdir(dir);
188
+ }
189
+ catch {
190
+ return [];
191
+ }
192
+ const out = [];
193
+ for (const name of names) {
194
+ if (!name.endsWith('.json')) {
195
+ continue;
196
+ }
197
+ try {
198
+ const raw = await fsp.readFile(path.join(dir, name), 'utf8');
199
+ const parsed = JSON.parse(raw);
200
+ if (typeof parsed.key === 'string' && shardBelongsToSession(parsed.key, sessionId, parsed.value)) {
201
+ out.push(name);
202
+ }
203
+ }
204
+ catch {
205
+ // skip
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+ async function pathExists(p) {
211
+ try {
212
+ await fsp.access(p);
213
+ return true;
214
+ }
215
+ catch {
216
+ return false;
217
+ }
218
+ }
219
+ /**
220
+ * Build a local staging tree for session S: provider-sessions/<shards for S> only.
221
+ *
222
+ * `<stateDir>/agents/<agentId>/{claude,codex}` (the SDK's own on-disk session/config
223
+ * cache — see agentpack/paths.ts) is deliberately NOT included here. That directory is
224
+ * shared by every session/user of the same pack on this replica, keyed only by agentId —
225
+ * never session-scoped. Treating it as part of one session's push/restore let one
226
+ * session's stale snapshot force-overwrite the live SDK cache for every other
227
+ * concurrently active session on the replica (fs.cp force:true in the old
228
+ * mergeSessionStagingIntoState). It's local-disk-only now, same as before object-store
229
+ * sync existed: it lives and dies with its replica. A resume target missing after a
230
+ * replica change already falls back to a fresh SDK session — the same path every
231
+ * session's true first turn on any replica already takes — so this doesn't introduce a
232
+ * new failure mode, it just stops the wrong-session-data clobber.
233
+ */
234
+ async function stageSessionPushDir(stateDir, sessionId, stagingDir) {
235
+ await fsp.rm(stagingDir, { recursive: true, force: true });
236
+ await fsp.mkdir(stagingDir, { recursive: true });
237
+ const shards = await listSessionShardFiles(stateDir, sessionId);
238
+ const psDest = path.join(stagingDir, 'provider-sessions');
239
+ if (shards.length > 0) {
240
+ await fsp.mkdir(psDest, { recursive: true });
241
+ for (const name of shards) {
242
+ await fsp.copyFile(path.join(stateDir, 'provider-sessions', name), path.join(psDest, name));
243
+ }
244
+ }
245
+ return shards.length > 0;
246
+ }
247
+ /** Merge a restored staging tree back into stateDir (provider-session shards only — see
248
+ * stageSessionPushDir for why the SDK config cache is intentionally excluded). */
249
+ async function mergeSessionStagingIntoState(stagingDir, stateDir) {
250
+ const psSrc = path.join(stagingDir, 'provider-sessions');
251
+ if (await pathExists(psSrc)) {
252
+ const psDest = path.join(stateDir, 'provider-sessions');
253
+ await fsp.mkdir(psDest, { recursive: true });
254
+ for (const name of await fsp.readdir(psSrc)) {
255
+ if (!name.endsWith('.json')) {
256
+ continue;
257
+ }
258
+ await fsp.copyFile(path.join(psSrc, name), path.join(psDest, name));
259
+ }
260
+ }
261
+ }
64
262
  /** Download state from the bucket into stateDir on boot. An empty bucket is a no-op. */
65
263
  async function restoreState(cfg, stateDir, log) {
66
264
  try {
@@ -68,46 +266,130 @@ async function restoreState(cfg, stateDir, log) {
68
266
  log?.(`State restored from ${cfg.uri}`);
69
267
  }
70
268
  catch (err) {
71
- // First boot / empty bucket / transient — don't block startup.
72
269
  log?.(`State restore skipped: ${err instanceof Error ? err.message : String(err)}`);
73
270
  }
74
271
  }
75
- /** Upload changed files from stateDir to the bucket (delta). */
272
+ /** Upload changed files from stateDir to the bucket (full-tree delta — legacy / flush fallback). */
76
273
  async function pushState(cfg, stateDir, log) {
77
274
  try {
78
275
  await execFileAsync('aws', syncArgs(cfg, stateDir, cfg.uri));
79
276
  }
80
277
  catch (err) {
81
- // A failed push means sessions aren't durable — surface it unconditionally,
82
- // not just under --verbose, so the operator notices state isn't persisting.
83
278
  console.error(`[state-sync] push to ${cfg.uri} failed: ${err instanceof Error ? err.message : String(err)}`);
84
279
  log?.(`State push failed: ${err instanceof Error ? err.message : String(err)}`);
85
280
  }
86
281
  }
282
+ /** Push one session's scoped tree to s3://…/sessions/<id>/. */
283
+ async function pushSessionState(cfg, stateDir, sessionId, log) {
284
+ const safe = safeSessionId(sessionId);
285
+ const staging = path.join(stateDir, '.push-staging', safe);
286
+ try {
287
+ const hasContent = await stageSessionPushDir(stateDir, sessionId, staging);
288
+ if (!hasContent) {
289
+ log?.(`State push skipped for session ${safe}: nothing local`);
290
+ return;
291
+ }
292
+ const remote = sessionRemoteUri(cfg, sessionId);
293
+ await execFileAsync('aws', syncArgs(cfg, staging, remote));
294
+ log?.(`State pushed session ${safe} → ${remote}`);
295
+ }
296
+ catch (err) {
297
+ console.error(`[state-sync] push session ${safe} failed: ${err instanceof Error ? err.message : String(err)}`);
298
+ log?.(`State push failed for ${safe}: ${err instanceof Error ? err.message : String(err)}`);
299
+ }
300
+ finally {
301
+ await fsp.rm(staging, { recursive: true, force: true }).catch(() => undefined);
302
+ }
303
+ }
304
+ /**
305
+ * On a request whose session_id has no local provider-session state, pull
306
+ * s3://…/sessions/<id>/ before runner.run(). No-ops if already local or already
307
+ * attempted this process (avoids re-pulling brand-new sessions every turn).
308
+ */
309
+ async function ensureSessionLocal(cfg, stateDir, sessionId, log) {
310
+ const key = attemptKey(stateDir, sessionId);
311
+ if (restoreAttempted.has(key)) {
312
+ return;
313
+ }
314
+ if (await hasLocalSessionState(stateDir, sessionId)) {
315
+ restoreAttempted.add(key);
316
+ return;
317
+ }
318
+ const safe = safeSessionId(sessionId);
319
+ const staging = path.join(stateDir, '.restore-staging', safe);
320
+ try {
321
+ await fsp.rm(staging, { recursive: true, force: true });
322
+ await fsp.mkdir(staging, { recursive: true });
323
+ const remote = sessionRemoteUri(cfg, sessionId);
324
+ await execFileAsync('aws', syncArgs(cfg, remote, staging));
325
+ await mergeSessionStagingIntoState(staging, stateDir);
326
+ log?.(`State restored session ${safe} from ${remote}`);
327
+ }
328
+ catch (err) {
329
+ // Empty prefix / first turn / transient — continue; runner creates fresh state.
330
+ log?.(`State restore-on-miss skipped for ${safe}: ${err instanceof Error ? err.message : String(err)}`);
331
+ }
332
+ finally {
333
+ restoreAttempted.add(key);
334
+ await fsp.rm(staging, { recursive: true, force: true }).catch(() => undefined);
335
+ }
336
+ }
337
+ /** Test helper — clear the restore-attempted cache. */
338
+ function clearRestoreAttemptCache() {
339
+ restoreAttempted.clear();
340
+ }
87
341
  /**
88
- * Debounced, non-overlapping pusher. `schedule()` after each turn (cheap, off the
89
- * response path); coalesces bursts; `flush()` awaits a final push (SIGTERM).
342
+ * Debounced, non-overlapping per-session pusher.
343
+ * `schedule(sessionId)` after each turn; `flush()` awaits pending pushes (SIGTERM).
90
344
  */
91
345
  function makeStatePusher(cfg, stateDir, log, waitMs = 1000) {
92
346
  let timer = null;
93
347
  let running = false;
94
- let pending = false;
95
348
  let inflightPromise = null;
349
+ const pendingSessions = new Set();
350
+ let pendingFull = false;
96
351
  const runNow = () => {
97
352
  running = true;
98
- pending = false;
99
- inflightPromise = pushState(cfg, stateDir, log).then(() => {
353
+ const sessions = [...pendingSessions];
354
+ const doFull = pendingFull;
355
+ pendingSessions.clear();
356
+ pendingFull = false;
357
+ inflightPromise = (async () => {
358
+ if (doFull) {
359
+ await pushState(cfg, stateDir, log);
360
+ }
361
+ for (const id of sessions) {
362
+ await pushSessionState(cfg, stateDir, id, log);
363
+ }
364
+ })()
365
+ .catch((err) => {
366
+ console.error(`[state-sync] push batch failed: ${err instanceof Error ? err.message : String(err)}`);
367
+ })
368
+ .finally(() => {
100
369
  running = false;
101
370
  inflightPromise = null;
102
- if (pending) {
103
- schedule();
371
+ if (pendingSessions.size > 0 || pendingFull) {
372
+ // Re-arm debounce without treating this as a legacy full-push schedule().
373
+ if (!timer) {
374
+ timer = setTimeout(() => {
375
+ timer = null;
376
+ void runNow();
377
+ }, waitMs);
378
+ timer.unref?.();
379
+ }
104
380
  }
105
381
  });
106
382
  return inflightPromise;
107
383
  };
108
- const schedule = () => {
384
+ const schedule = (sessionId) => {
385
+ if (sessionId?.trim()) {
386
+ pendingSessions.add(sessionId.trim());
387
+ }
388
+ else {
389
+ // Legacy no-arg call → full-tree push (boot-compat / callers that omit id).
390
+ pendingFull = true;
391
+ }
109
392
  if (running) {
110
- pending = true;
111
393
  return;
112
394
  }
113
395
  if (timer) {
@@ -124,12 +406,19 @@ function makeStatePusher(cfg, stateDir, log, waitMs = 1000) {
124
406
  clearTimeout(timer);
125
407
  timer = null;
126
408
  }
127
- // Wait for any in-flight push to complete before the final push,
128
- // so SIGTERM never runs two concurrent aws s3 sync processes.
129
409
  if (inflightPromise) {
130
410
  await inflightPromise;
131
411
  }
132
- await pushState(cfg, stateDir, log);
412
+ if (pendingSessions.size > 0 || pendingFull) {
413
+ await runNow();
414
+ if (inflightPromise) {
415
+ await inflightPromise;
416
+ }
417
+ }
133
418
  };
134
419
  return { schedule, flush };
135
420
  }
421
+ /** Stable hash helper exposed for tests (matches DirectorySessionStore naming). */
422
+ function providerSessionShardName(conversationKey) {
423
+ return `${(0, crypto_1.createHash)('sha256').update(conversationKey).digest('hex').slice(0, 40)}.json`;
424
+ }
@@ -43,6 +43,8 @@ const base_1 = require("./base");
43
43
  const utils_1 = require("../utils");
44
44
  const permissions_1 = require("../permissions");
45
45
  const mcp_1 = require("../mcp");
46
+ const data_plane_1 = require("../cli/data-plane");
47
+ const runtime_1 = require("../cli/runtime");
46
48
  const session_1 = require("../session");
47
49
  const agentpack_1 = require("../agentpack");
48
50
  const shared_1 = require("../shared");
@@ -241,7 +243,8 @@ class ClaudeEngine {
241
243
  // Edit tracker from runtime context (optional)
242
244
  const editTracker = runtime.editTracker;
243
245
  // Resolve API routing (LiteLLM proxy vs direct Anthropic)
244
- const { apiKey, baseUrl, useLiteLLM } = (0, utils_1.resolveApiRouting)(options, this.config.litellmApiKey, this.config.litellmBaseUrl);
246
+ // claude routes to Anthropic when direct (or the LiteLLM proxy when configured) — never OpenAI.
247
+ const { apiKey, baseUrl, useLiteLLM } = (0, utils_1.resolveApiRouting)(options, this.config, 'anthropic');
245
248
  if (!apiKey) {
246
249
  throw new Error('Claude API key not provided');
247
250
  }
@@ -267,6 +270,11 @@ class ClaudeEngine {
267
270
  'DEBUG_CLAUDE_AGENT_SDK': '1',
268
271
  'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC': '1',
269
272
  };
273
+ // Egress broker active ⇒ the gateway token stays broker-side; scrub it from the child
274
+ // env so it never reaches the model (secure-obo-design §7.6 / §7.4 gap 1).
275
+ if (claudeEnv[data_plane_1.EGRESS_URL_ENV]) {
276
+ delete claudeEnv[runtime_1.OPENCLAW_GATEWAY_TOKEN_ENV];
277
+ }
270
278
  const isSetupTokenAuth = (0, shared_1.isAnthropicSetupToken)(apiKey);
271
279
  if (isSetupTokenAuth) {
272
280
  const normalizedSetupToken = (0, shared_1.normalizeAnthropicSetupToken)(apiKey);
@@ -541,8 +549,10 @@ class ClaudeEngine {
541
549
  logger.debug(`[AgentIDE] SessionStart hook called! event=${input.hook_event_name}`);
542
550
  return CONTINUE;
543
551
  };
544
- // Load MCP servers for the active agent and pass directly to SDK
545
- const mcpServers = await this.loadMcpServersForClaude(logger, options.workspaceRoot, agentId);
552
+ // Load MCP servers for the active agent and pass directly to SDK. claudeEnv is the
553
+ // per-turn child env — URL servers resolve their bearerTokenEnv (turn nonce) from it,
554
+ // in memory, so no per-turn value ever lands in a shared config file.
555
+ const mcpServers = await this.loadMcpServersForClaude(logger, options.workspaceRoot, agentId, claudeEnv);
546
556
  // Determine cwd and settingSources
547
557
  // cwd is ALWAYS workspaceRoot - active agent content has been synced to workspace's .claude/
548
558
  // Agent packs: settingSources is 'project' only (no user ~/.claude/ mixing)
@@ -1191,9 +1201,11 @@ class ClaudeEngine {
1191
1201
  * - Default agent: uses bundled MCP servers from extension
1192
1202
  * - Pack agents: uses pack's mcp/ directory
1193
1203
  *
1194
- * The SDK accepts mcpServers directly in query options.
1204
+ * The SDK accepts mcpServers directly in query options — including HTTP Streamable
1205
+ * servers (`{type:'http', url, headers}`). `turnEnv` is the per-turn child env; a URL
1206
+ * server's `bearerTokenEnv` (e.g. AGENTHIPPO_TURN_NONCE) resolves against it in memory.
1195
1207
  */
1196
- async loadMcpServersForClaude(logger, workspaceRoot, agentId = 'default') {
1208
+ async loadMcpServersForClaude(logger, workspaceRoot, agentId = 'default', turnEnv) {
1197
1209
  const basePromptsDir = this.getBundledBasePromptsDir();
1198
1210
  const servers = await (0, mcp_1.loadMcpServers)(agentId, workspaceRoot, basePromptsDir);
1199
1211
  if (servers.length === 0) {
@@ -1201,6 +1213,15 @@ class ClaudeEngine {
1201
1213
  }
1202
1214
  const mcpServers = {};
1203
1215
  for (const s of servers) {
1216
+ if (s.url) {
1217
+ const headers = (0, mcp_1.resolveHttpMcpHeaders)(s, turnEnv ?? process.env);
1218
+ mcpServers[s.name] = {
1219
+ type: 'http',
1220
+ url: s.url,
1221
+ ...(headers ? { headers } : {}),
1222
+ };
1223
+ continue;
1224
+ }
1204
1225
  mcpServers[s.name] = {
1205
1226
  command: s.command,
1206
1227
  args: s.args,
@@ -46,6 +46,9 @@ const utils_1 = require("../utils");
46
46
  const permissions_1 = require("../permissions");
47
47
  const session_1 = require("../session");
48
48
  const agentpack_1 = require("../agentpack");
49
+ const credential_context_1 = require("../cli/credential-context");
50
+ const data_plane_1 = require("../cli/data-plane");
51
+ const runtime_1 = require("../cli/runtime");
49
52
  // Note: HTTP edit server removed - file changes are now handled directly via file_change events
50
53
  // -----------------------------------------------------------------------------
51
54
  // Constants
@@ -63,7 +66,7 @@ const AGENTIDE_CODEX_SOURCE_ENV_VAR = 'AGENTIDE_ANALYTICS_SOURCE';
63
66
  const AGENTIDE_CODEX_ENGINE_ENV_VAR = 'AGENTIDE_ANALYTICS_ENGINE';
64
67
  const AGENTIDE_CODEX_REQUEST_ID_ENV_VAR = 'AGENTIDE_ANALYTICS_REQUEST_ID';
65
68
  // Bump when we change generated config semantics so long-lived sessions regenerate config.toml.
66
- const AGENTIDE_CODEX_CONFIG_VERSION = 18; // v18: Codex CLI dropped wire_api=chat; always use responses
69
+ const AGENTIDE_CODEX_CONFIG_VERSION = 20; // v20: default_tools_approval_mode "auto"→"approve" ("auto" is invalid on codex ≥0.131 and cancels every headless MCP tool call)
67
70
  // Per-process runtime context for agents (not analytics): lets the agent locate its per-session context export.
68
71
  const AGENTIDE_CONTEXT_EXPORT_PATH_ENV_VAR = 'AGENTIDE_CONTEXT_EXPORT_PATH';
69
72
  const AGENTIDE_TERMINAL_LOG_PATH_ENV_VAR = 'AGENTIDE_TERMINAL_LOG_PATH';
@@ -100,7 +103,8 @@ class CodexEngine {
100
103
  const threadModelId = options.modelId;
101
104
  // Resolve API routing (LiteLLM proxy vs direct OpenAI)
102
105
  // Use openaiBaseUrl which has overrideOpenAIBaseUrl applied
103
- const routing = (0, utils_1.resolveApiRouting)(options, this.config.litellmApiKey, this.config.litellmBaseUrl);
106
+ // codex routes to OpenAI when direct (or the LiteLLM proxy when configured) — never Anthropic.
107
+ const routing = (0, utils_1.resolveApiRouting)(options, this.config, 'openai');
104
108
  const apiKey = routing.apiKey;
105
109
  const baseUrl = routing.openaiBaseUrl; // Use OpenAI-specific URL
106
110
  const useLiteLLM = routing.useLiteLLM;
@@ -126,6 +130,13 @@ class CodexEngine {
126
130
  ...(runtime.cliEnvPatch ?? {}),
127
131
  OPENAI_LOG: 'debug',
128
132
  };
133
+ // When serve runs the egress broker, the OpenClaw gateway token must never enter the
134
+ // agent's trust domain (secure-obo-design §7.6 / §7.4 gap 1). Scrub it from the child env
135
+ // at the spawn boundary — belt-and-suspenders with serve's boot-time process.env strip,
136
+ // so a token in process.env (e.g. captured before the strip) still cannot reach the model.
137
+ if (codexEnv[data_plane_1.EGRESS_URL_ENV]) {
138
+ delete codexEnv[runtime_1.OPENCLAW_GATEWAY_TOKEN_ENV];
139
+ }
129
140
  // Always expose the VS Code chat session id to the agent process so it can locate:
130
141
  // .agentide/sessions/<sessionId>/context.md
131
142
  const rawChatSessionId = sessionId?.trim();
@@ -141,8 +152,12 @@ class CodexEngine {
141
152
  // Note: workingDirectory is ALWAYS workspaceRoot - active agent content is synced to workspace's .codex/
142
153
  const agentId = options.agentPackId ?? 'default';
143
154
  const runtimePaths = (0, agentpack_1.resolveRuntimePaths)(options.workspaceRoot, this.config.storagePath, agentId, options.agentPackVersion);
144
- // Use agent-isolated CODEX_HOME (for session/state isolation)
145
- const codexHome = runtimePaths.codexHome;
155
+ // Use agent-isolated CODEX_HOME (for session/state isolation). In multi-tenant serve,
156
+ // additionally key it by the verified request user: config.toml is rewritten per turn,
157
+ // and a shared per-agent home lets concurrent turns of different users race it
158
+ // (secure-obo-design §1 defect 1 / §8 Phase 0). No credential frame (IDE,
159
+ // single-tenant CLI) ⇒ unchanged per-agent path.
160
+ const codexHome = (0, agentpack_1.userScopedEngineHome)(runtimePaths.codexHome, (0, credential_context_1.currentCredential)()?.userId);
146
161
  codexEnv['CODEX_HOME'] = codexHome;
147
162
  await fs_1.promises.mkdir(codexHome, { recursive: true });
148
163
  // Always set AGENT_ID for agent to know its identity
@@ -601,7 +616,11 @@ class CodexEngine {
601
616
  // Codex CLI requires wire_api=responses (chat wire was removed in newer Codex releases).
602
617
  const wireApi = 'responses';
603
618
  const routingType = useLiteLLM ? 'litellm' : (customBaseUrl ? 'custom' : 'openai');
604
- const signature = `${AGENTIDE_CODEX_CONFIG_VERSION}:${routingType}:${customBaseUrl ?? ''}:${wireApi}:${agentId}:${codexInstructionsFile ?? ''}:${(0, mcp_1.mcpConfigCacheSignatureSuffix)()}`;
619
+ // No per-turn component in this signature: config.toml must be byte-identical across
620
+ // turns (per-turn auth rides the process env via bearer_token_env_var, never the file),
621
+ // so concurrent same-home turns write identical bytes and the old cross-turn race is
622
+ // gone by construction (secure-obo-design §3.2).
623
+ const signature = `${AGENTIDE_CODEX_CONFIG_VERSION}:${routingType}:${customBaseUrl ?? ''}:${wireApi}:${agentId}:${codexInstructionsFile ?? ''}`;
605
624
  if (this.lastWrittenConfigSignature === signature) {
606
625
  return;
607
626
  }
@@ -38,6 +38,8 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.CustomEngine = void 0;
40
40
  exports.resolveCustomEngineLoadStrategy = resolveCustomEngineLoadStrategy;
41
+ exports.deliverModelRoutingEnv = deliverModelRoutingEnv;
42
+ exports.buildRunnerChildEnv = buildRunnerChildEnv;
41
43
  exports.probeCustomEngineImport = probeCustomEngineImport;
42
44
  const node_child_process_1 = require("node:child_process");
43
45
  const node_readline_1 = require("node:readline");
@@ -83,10 +85,45 @@ function prependNodePath(env, engineDir) {
83
85
  ? `${engineNodeModules}${path.delimiter}${existing}`
84
86
  : engineNodeModules;
85
87
  }
88
+ /**
89
+ * Deliver the per-turn model-routing values into the env vars a manifest declares
90
+ * (`model.apiKeyEnvVar` / `baseUrlEnvVar` / `openaiBaseUrlEnvVar`).
91
+ *
92
+ * NON-CLOBBERING: a var already set in the host's own ambient env is left alone. Two reasons:
93
+ * (1) it preserves the effective behavior deployments already relied on — the old
94
+ * `{...turn.env, ...process.env}` child-env spread accidentally let ambient values win,
95
+ * and operators' engine-owned keys (e.g. a real CURSOR_API_KEY vs a stale manifest that
96
+ * wrongly declared it as a delivery slot) must keep winning after that spread is fixed;
97
+ * (2) it makes the manifest contract safe by construction: mis-declaring an operator-owned
98
+ * credential as a delivery slot can shadow nothing.
99
+ * The flip side is documented on CustomEngineModelConfig: ambient-exporting a genuine
100
+ * delivery var (e.g. LITELLM_API_KEY in serve's shell) shadows per-turn rotation.
101
+ */
102
+ function deliverModelRoutingEnv(env, manifest, routing, ambientEnv, logger) {
103
+ const deliver = (envVar, value, label) => {
104
+ if (!envVar || !value) {
105
+ return;
106
+ }
107
+ if (ambientEnv[envVar]?.trim()) {
108
+ logger?.info(`[AgentIDE] Custom engine "${manifest.id}": ${envVar} already set in ambient env — keeping the operator's value, skipping per-turn ${label} delivery`);
109
+ return;
110
+ }
111
+ env[envVar] = value;
112
+ };
113
+ deliver(manifest.model?.apiKeyEnvVar, routing.apiKey, 'API key');
114
+ deliver(manifest.model?.baseUrlEnvVar, routing.baseUrl, 'base URL');
115
+ deliver(manifest.model?.openaiBaseUrlEnvVar, routing.openaiBaseUrl, 'OpenAI base URL');
116
+ }
86
117
  function buildRunnerChildEnv(manifest, turn, probeImport) {
87
118
  const childEnv = {
88
- ...turn.env,
119
+ // Per-turn values WIN over the host's ambient env. turn.env starts from a spread of
120
+ // process.env, so for undeclared vars this is identical either way; the difference is
121
+ // deliberate per-turn state (AGENTHIPPO_* control vars, the data-plane turn nonce,
122
+ // session paths, manifest-declared deliveries) which must never be reverted by a
123
+ // same-named var in the host's own environment. Ambient-vs-delivery precedence for
124
+ // manifest credential vars is decided earlier, in deliverModelRoutingEnv (non-clobber).
89
125
  ...process.env,
126
+ ...turn.env,
90
127
  AGENTHIPPO_ENGINE_ENTRY: manifest.entryPath,
91
128
  AGENTHIPPO_MESSAGE: turn.message,
92
129
  AGENTHIPPO_WORKSPACE_ROOT: turn.workspaceRoot,
@@ -466,7 +503,10 @@ class CustomEngine {
466
503
  const attachmentsDir = path.join(workspaceSessionDir, 'attachments');
467
504
  const nativeSessionId = this.activeSessions.get(conversationKey)?.nativeSessionId ??
468
505
  (0, base_1.getPersistedSessionId)(`custom:${manifest.id}`, conversationKey);
469
- const routing = (0, utils_1.resolveApiRouting)(options, this.config.litellmApiKey, this.config.litellmBaseUrl, runtime.configProvider);
506
+ // A custom engine isn't pinned to one vendor — infer its direct provider from the model
507
+ // (e.g. openclaw's litellm/* routes through the proxy anyway; a claude-* model → Anthropic).
508
+ // The engine's manifest still decides which env var receives the resolved key.
509
+ const routing = (0, utils_1.resolveApiRouting)(options, this.config, (0, utils_1.inferModelProvider)(options.modelId), runtime.configProvider);
470
510
  const routingApiKey = routing.apiKey?.trim() || undefined;
471
511
  const routingBaseUrl = routing.baseUrl?.trim() || undefined;
472
512
  const routingOpenAIBaseUrl = routing.openaiBaseUrl?.trim() || undefined;
@@ -532,15 +572,7 @@ class CustomEngine {
532
572
  if (manifest.homeEnvVar) {
533
573
  env[manifest.homeEnvVar] = engineHomeDir;
534
574
  }
535
- if (manifest.model?.apiKeyEnvVar && routingApiKey) {
536
- env[manifest.model.apiKeyEnvVar] = routingApiKey;
537
- }
538
- if (manifest.model?.baseUrlEnvVar && routingBaseUrl) {
539
- env[manifest.model.baseUrlEnvVar] = routingBaseUrl;
540
- }
541
- if (manifest.model?.openaiBaseUrlEnvVar && routingOpenAIBaseUrl) {
542
- env[manifest.model.openaiBaseUrlEnvVar] = routingOpenAIBaseUrl;
543
- }
575
+ deliverModelRoutingEnv(env, manifest, { apiKey: routingApiKey, baseUrl: routingBaseUrl, openaiBaseUrl: routingOpenAIBaseUrl }, process.env, runtime.logger);
544
576
  if ((routingBaseUrl && (0, utils_1.applyLocalTlsBypass)(env, routingBaseUrl))
545
577
  || (routingOpenAIBaseUrl && routingOpenAIBaseUrl !== routingBaseUrl && (0, utils_1.applyLocalTlsBypass)(env, routingOpenAIBaseUrl))) {
546
578
  runtime.logger.info(`[AgentIDE] Disabling TLS verification for local HTTPS (${manifest.id})`);
@@ -550,7 +582,9 @@ class CustomEngine {
550
582
  : (routingBaseUrl ? 'custom provider' : 'native provider');
551
583
  runtime.logger.info(`[AgentIDE] Custom engine "${manifest.id}" routing: ${routingDescription}, baseUrl=${routingBaseUrl || 'default'}, model=${modelId}`);
552
584
  const resolvedMcp = await (0, mcp_1.loadMcpServers)(agentId, options.workspaceRoot, this.getBundledBasePromptsDir());
553
- const mcpServers = (0, mcp_1.formatInlineMcpServers)(resolvedMcp);
585
+ // `env` is this turn's child env — URL servers resolve bearerTokenEnv (turn nonce)
586
+ // from it in memory, so per-turn auth never lands in a shared file.
587
+ const mcpServers = (0, mcp_1.formatInlineMcpServers)(resolvedMcp, env);
554
588
  if (resolvedMcp.length > 0) {
555
589
  runtime.logger.debug(`[AgentIDE] Custom engine "${manifest.id}" MCP (${agentId}): ${resolvedMcp.map(s => s.name).join(', ')}`);
556
590
  }