@agenthippo-ai/agenthippo 1.110.9 → 1.110.11

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 (50) hide show
  1. package/out/agentpack/index.js +3 -1
  2. package/out/agentpack/paths.js +25 -2
  3. package/out/analytics/dashboard/localFleetAdapter.js +82 -2
  4. package/out/analytics/service.js +6 -0
  5. package/out/analytics/sessionEvents.js +4 -3
  6. package/out/auth-broker/server.js +23 -19
  7. package/out/cli/commands/data-plane.js +84 -0
  8. package/out/cli/commands/sandbox.js +28 -9
  9. package/out/cli/commands/serve.js +289 -97
  10. package/out/cli/credential-context.js +17 -0
  11. package/out/cli/data-plane/index.js +2 -1
  12. package/out/cli/data-plane/sidecar.js +66 -2
  13. package/out/cli/deploy/layers/identity.js +5 -2
  14. package/out/cli/deploy-init.js +4 -4
  15. package/out/cli/index.js +16 -1
  16. package/out/cli/serve-hardening.js +181 -12
  17. package/out/engines/claude.js +1 -1
  18. package/out/engines/codex.js +1 -1
  19. package/out/engines/custom.js +7 -1
  20. package/out/evidence/sessionShip.js +50 -17
  21. package/out/headless/index.js +8 -1
  22. package/out/pack/governor.js +32 -15
  23. package/out/pack/lease-schema.js +4 -1
  24. package/out/pack/lease.js +42 -0
  25. package/out/runner/host.js +24 -14
  26. package/out/runner/protocol.js +24 -4
  27. package/out/runner/remoteEngine.js +53 -17
  28. package/out/sandbox/containerDriver.js +4 -2
  29. package/out/sandbox/processDriver.js +4 -1
  30. package/out/sandbox/sandboxRunner.js +148 -36
  31. package/out/scripts/agentide_litellm_hooks_v1.py +85 -1
  32. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +4 -0
  33. package/out/scripts/analytics/setup-analytics.ps1 +8 -1
  34. package/out/scripts/analytics/setup-analytics.sh +13 -2
  35. package/out/test/caps-governor.test.js +6 -1
  36. package/out/test/data-plane-engine-wiring.test.js +32 -0
  37. package/out/test/data-plane.test.js +313 -0
  38. package/out/test/deploy-init.test.js +7 -1
  39. package/out/test/governor-hardening.test.js +6 -1
  40. package/out/test/lease-validation.test.js +55 -0
  41. package/out/test/localFleetAdapter.test.js +91 -0
  42. package/out/test/sandbox-driver.test.js +2 -2
  43. package/out/test/sandbox-hardening.test.js +12 -1
  44. package/out/test/sandbox-runner.test.js +1 -1
  45. package/out/test/sandbox-scope.test.js +449 -0
  46. package/out/test/serve-cron-acceptance.test.js +370 -0
  47. package/out/test/serve-gateway-turn.test.js +122 -0
  48. package/out/test/serve-hardening.test.js +182 -9
  49. package/out/test/sessionShip.test.js +58 -0
  50. package/package.json +1 -1
@@ -95,6 +95,10 @@ function setStatus(patch) {
95
95
  function isTruthy(value) {
96
96
  return ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase());
97
97
  }
98
+ /** evidence-ship reads EVIDENCE_ALLOW_INSECURE_URL; serve documents AGENTHIPPO_EVIDENCE_ALLOW_INSECURE_URL. */
99
+ function resolveAllowInsecure(env) {
100
+ return isTruthy(env[ALLOW_INSECURE_ENV]) || isTruthy(env.EVIDENCE_ALLOW_INSECURE_URL);
101
+ }
98
102
  const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0']);
99
103
  /**
100
104
  * Validate the ingest URL.
@@ -120,16 +124,19 @@ function validateEvidenceUrl(raw, env = process.env) {
120
124
  if (LOOPBACK_HOSTS.has(parsed.hostname)) {
121
125
  return { url: parsed.toString().replace(/\/$/, '') };
122
126
  }
123
- if (isTruthy(env[ALLOW_INSECURE_ENV])) {
127
+ if (resolveAllowInsecure(env)) {
128
+ const reason = isTruthy(env[ALLOW_INSECURE_ENV])
129
+ ? ALLOW_INSECURE_ENV
130
+ : 'EVIDENCE_ALLOW_INSECURE_URL';
124
131
  console.warn(`[evidence-ship] INSECURE: shipping evidence and the ingest token over plaintext http ` +
125
- `to ${parsed.host} because ${ALLOW_INSECURE_ENV} is set. Use https unless this network ` +
132
+ `to ${parsed.host} because ${reason} is set. Use https unless this network ` +
126
133
  `is genuinely private.`);
127
134
  return { url: parsed.toString().replace(/\/$/, '') };
128
135
  }
129
136
  return {
130
137
  error: `${URL_ENV} must use https — plaintext http to ${parsed.host} would put the ingest ` +
131
138
  `token and the audit trail on the wire. Use https, a loopback address, or set ` +
132
- `${ALLOW_INSECURE_ENV}=1 if the network is genuinely private.`,
139
+ `${ALLOW_INSECURE_ENV}=1 (or EVIDENCE_ALLOW_INSECURE_URL=1) if the network is genuinely private.`,
133
140
  };
134
141
  }
135
142
  /** Resolve config from env; returns undefined when shipping is disabled or misconfigured. */
@@ -180,6 +187,7 @@ function resolveSessionShipConfig(instanceId, sessionsDir, stateDir, env = proce
180
187
  intervalSec,
181
188
  sinceDays,
182
189
  drainTimeoutSec,
190
+ allowInsecure: resolveAllowInsecure(env),
183
191
  };
184
192
  }
185
193
  // -----------------------------------------------------------------------------
@@ -262,6 +270,7 @@ function startSessionShipper(config) {
262
270
  const intervalSec = config.intervalSec ?? 60;
263
271
  const drainTimeoutMs = (config.drainTimeoutSec ?? DEFAULT_DRAIN_TIMEOUT_SEC) * 1000;
264
272
  const captureContent = (0, identity_1.isContentCaptureEnabled)();
273
+ const allowInsecure = config.allowInsecure === true;
265
274
  let stopped = false;
266
275
  let inFlight = false;
267
276
  let lastOkAt;
@@ -299,6 +308,10 @@ function startSessionShipper(config) {
299
308
  // is safe — but it would also strip content on a deployment that deliberately
300
309
  // opted in, turning the opt-in into a no-op. Pass our own decision through.
301
310
  captureContent,
311
+ // Compose stacks use http://evidence:8787 on a private network. serve accepts that
312
+ // via AGENTHIPPO_EVIDENCE_ALLOW_INSECURE_URL; evidence-ship needs allowInsecure here
313
+ // (or EVIDENCE_ALLOW_INSECURE_URL in the process env).
314
+ allowInsecure,
302
315
  // Heartbeat is posted here instead: the upstream one is the literal '{}', which
303
316
  // proves the process was alive and nothing about whether records are complete.
304
317
  heartbeat: false,
@@ -449,7 +462,14 @@ function startSessionShipper(config) {
449
462
  }
450
463
  if (losses.length > 0) {
451
464
  // Appended, never truncated: a loss record must outlive the tick that noticed it.
452
- const lossFile = path.join(stagingDir, '_evidence-loss.jsonl');
465
+ //
466
+ // The NAME matters and cannot be `_evidence-loss.jsonl`: @agenthippo-ai/evidence-ship's
467
+ // shipSessions() skips every file whose name starts with `_evidence-loss` (it posts its
468
+ // OWN losses under that name from its cursor, and refuses to re-ingest one found on
469
+ // disk). Staging ours under that name made it a dead letter — the warning printed, the
470
+ // counters incremented, and the record never left the machine, which is precisely the
471
+ // silent gap this feature exists to close.
472
+ const lossFile = path.join(stagingDir, 'evidence-loss.jsonl');
453
473
  await fs_1.promises.appendFile(lossFile, `${losses.join('\n')}\n`);
454
474
  const seconds = Date.now() / 1000;
455
475
  await fs_1.promises.utimes(lossFile, seconds, seconds);
@@ -532,28 +552,41 @@ function startSessionShipper(config) {
532
552
  try {
533
553
  const staged = await stage();
534
554
  unshippedRecords += staged;
535
- const shipper = await getShipper();
536
- const ok = await shipper.drain({ sessions: stagingDir, heartbeat: false });
537
- if (ok) {
538
- const state = await loadCursor();
539
- state.pending.shipped += unshippedRecords;
540
- totals.shipped += unshippedRecords;
541
- unshippedRecords = 0;
542
- lastOkAt = Date.now();
543
- consecutiveFailures = 0;
555
+ try {
556
+ const shipper = await getShipper();
557
+ const ok = await shipper.drain({ sessions: stagingDir, heartbeat: false });
558
+ if (ok) {
559
+ const state = await loadCursor();
560
+ state.pending.shipped += unshippedRecords;
561
+ totals.shipped += unshippedRecords;
562
+ unshippedRecords = 0;
563
+ lastOkAt = Date.now();
564
+ consecutiveFailures = 0;
565
+ }
566
+ else {
567
+ consecutiveFailures++;
568
+ warn(`[evidence-ship] drain failed (${consecutiveFailures} in a row); ` +
569
+ `${unshippedRecords} record(s) still unshipped`);
570
+ }
544
571
  }
545
- else {
572
+ catch (shipErr) {
546
573
  consecutiveFailures++;
547
- warn(`[evidence-ship] drain failed (${consecutiveFailures} in a row); ` +
548
- `${unshippedRecords} record(s) still unshipped`);
574
+ warn(`[evidence-ship] ${shipErr instanceof Error ? shipErr.message : String(shipErr)}`);
549
575
  }
550
- await postHeartbeat();
551
576
  }
552
577
  catch (err) {
553
578
  consecutiveFailures++;
554
579
  warn(`[evidence-ship] ${err instanceof Error ? err.message : String(err)}`);
555
580
  }
556
581
  finally {
582
+ // R-13: heartbeat runs every tick even when drain/shipper fails, so gap detection
583
+ // sees window bounds and staged-vs-shipped counts instead of silence.
584
+ try {
585
+ await postHeartbeat();
586
+ }
587
+ catch (hbErr) {
588
+ warn(`[evidence-ship] heartbeat failed: ${hbErr instanceof Error ? hbErr.message : String(hbErr)}`);
589
+ }
557
590
  inFlight = false;
558
591
  setStatus({
559
592
  lastOkAt,
@@ -373,7 +373,11 @@ function createHeadlessRunner(config) {
373
373
  // the key for the engine's destination — passing the collapsed key here would let
374
374
  // it win over the correct provider key (the original bug). Back-compat single-key
375
375
  // callers keep options.apiKey.
376
- apiKey: hasSplitKeys ? undefined : config.apiKey,
376
+ //
377
+ // A per-run `modelToken` outranks both. That is safe precisely where it is used:
378
+ // inside a sandbox there are no provider keys to lose a precedence fight with
379
+ // (the credential invariant), and the warrant IS the only route to a model.
380
+ apiKey: options.modelToken ?? (hasSplitKeys ? undefined : config.apiKey),
377
381
  baseUrl: config.baseUrl,
378
382
  useLiteLLM: config.useLiteLLM,
379
383
  agentPackId: packId,
@@ -384,6 +388,9 @@ function createHeadlessRunner(config) {
384
388
  tier: provenance?.tier,
385
389
  instanceId: provenance?.instanceId,
386
390
  permissions: options.permissions,
391
+ // Only ever set by a sandbox child. In serve's own process the engine reads the
392
+ // credential frame directly and this is undefined.
393
+ engineHomeUserKey: options.engineHomeUserKey,
387
394
  requestId, // Same requestId used by ObservableStreamEmitter for Layer 1/2 correlation
388
395
  };
389
396
  // Emit turn_start event
@@ -98,7 +98,7 @@ function createGovernor(opts) {
98
98
  const killAll = async (reason) => {
99
99
  const live = opts.sandboxes.sandboxes();
100
100
  for (const c of live) {
101
- await opts.sandboxes.destroySession(c.sessionKey).catch(() => undefined);
101
+ await opts.sandboxes.destroySession(c.sandboxKey).catch(() => undefined);
102
102
  // The meter deliberately SURVIVES the kill, for exactly the reason `enforce` gives when
103
103
  // it destroys a sandbox on a cap breach: a cap that renews when the sandbox goes away is
104
104
  // not a cap. `meter.forget` here meant a kill-then-restore (a billing hold lifted, a
@@ -159,22 +159,39 @@ function createGovernor(opts) {
159
159
  // `kill` rules end work already running. `drain` deliberately does not — it lets the
160
160
  // in-flight turn finish and only refuses the next one.
161
161
  for (const sandbox of opts.sandboxes.sandboxes()) {
162
- const v = (0, lease_1.denyVerdict)(lease, {
163
- sessionKey: sandbox.sessionKey,
164
- pack: opts.pack ?? '',
165
- userHash: opts.userHash,
166
- // Remembered from this session's turns, so a `user`-scoped kill reaches a
167
- // gateway session whose key carries no `u<hash>--` prefix to read it off.
168
- userId: sessionUsers.get(sandbox.sessionKey),
169
- followedUsers,
170
- });
171
- if (v.denied && effectiveDenyMode(v.mode, canTerminateInFlight) === 'kill') {
172
- await opts.sandboxes.destroySession(sandbox.sessionKey).catch(() => undefined);
173
- sessionUsers.delete(sandbox.sessionKey);
162
+ // Per SESSION, not per sandbox: under user scope one sandbox carries several, and a
163
+ // rule naming any one of them ends the sandbox they share.
164
+ const matched = sandbox.sessionKeys
165
+ .map((sessionKey) => ({
166
+ sessionKey,
167
+ v: (0, lease_1.denyVerdict)(lease, {
168
+ sessionKey,
169
+ pack: opts.pack ?? '',
170
+ userHash: opts.userHash,
171
+ // Remembered from this session's turns, so a `user`-scoped kill reaches a
172
+ // gateway session whose key carries no `u<hash>--` prefix to read it off.
173
+ userId: sessionUsers.get(sessionKey),
174
+ followedUsers,
175
+ }),
176
+ }))
177
+ .find(({ v }) => v.denied && effectiveDenyMode(v.mode, canTerminateInFlight) === 'kill');
178
+ if (matched) {
179
+ await opts.sandboxes.destroySession(sandbox.sandboxKey).catch(() => undefined);
180
+ for (const sessionKey of sandbox.sessionKeys) {
181
+ sessionUsers.delete(sessionKey);
182
+ }
174
183
  // The meter survives, same rule as `killAll` and `enforce`: a stop order must not
175
184
  // hand the session a fresh budget when it is lifted.
176
- opts.logger?.warn(`[Governor] deny(kill) destroyed ${sandbox.sandboxId} — ${v.reason}`);
177
- audit('session_killed', { sessionKey: sandbox.sessionKey, sandboxId: sandbox.sandboxId, reason: v.reason });
185
+ opts.logger?.warn(`[Governor] deny(kill) destroyed ${sandbox.sandboxId} — ${matched.v.reason}`);
186
+ // The sessions that were NOT named are recorded too: under user scope a rule
187
+ // aimed at one conversation stops the others sharing its sandbox, and an
188
+ // operator reading the audit must be able to see that.
189
+ audit('session_killed', {
190
+ sessionKey: matched.sessionKey,
191
+ sandboxId: sandbox.sandboxId,
192
+ reason: matched.v.reason,
193
+ collateralSessions: sandbox.sessionKeys.filter((s) => s !== matched.sessionKey),
194
+ });
178
195
  }
179
196
  }
180
197
  const verdict = (0, lease_1.leaseIsDead)(lease);
@@ -233,7 +233,10 @@ function parseLease(raw) {
233
233
  }
234
234
  lease[key] = v;
235
235
  }
236
- for (const key of ['requireSignedPack', 'requireSignedLease']) {
236
+ // Strict booleans, never coerced — `"false"` is truthy in JavaScript, and for every flag here
237
+ // that would flip the safe answer to the unsafe one. `allowServiceTurns` joins the same loop
238
+ // so it inherits that property for free (PLAN/agent-identity.md §5.6).
239
+ for (const key of ['requireSignedPack', 'requireSignedLease', 'allowServiceTurns']) {
237
240
  const v = raw[key];
238
241
  if (v === undefined || v === null) {
239
242
  continue;
package/out/pack/lease.js CHANGED
@@ -8,6 +8,8 @@ exports.resolveLeasePollSeconds = resolveLeasePollSeconds;
8
8
  exports.leaseCacheSecondsFor = leaseCacheSecondsFor;
9
9
  exports.effectiveKillLatencySeconds = effectiveKillLatencySeconds;
10
10
  exports.leaseMaxStalenessFromEnv = leaseMaxStalenessFromEnv;
11
+ exports.resolveAllowServiceTurns = resolveAllowServiceTurns;
12
+ exports.describeServiceTurnGate = describeServiceTurnGate;
11
13
  exports.denyVerdict = denyVerdict;
12
14
  exports.resolveFollowedUsers = resolveFollowedUsers;
13
15
  exports.leaseIsDead = leaseIsDead;
@@ -104,6 +106,46 @@ function leaseMaxStalenessFromEnv(env = process.env) {
104
106
  }
105
107
  return Math.floor(n);
106
108
  }
109
+ /**
110
+ * May this deployment run turns with no human behind them (PLAN/agent-identity.md §5.6)?
111
+ *
112
+ * **Polarity note, because it is the opposite of every other flag here.** `requireSignedPack` and
113
+ * friends are TIGHTEN-ONLY: absent means "nobody asked for the restriction", so absent is
114
+ * permissive and that is safe. This flag GRANTS a standing unattended identity, so the same
115
+ * treatment would be exactly backwards — a control plane that has not been taught the field would
116
+ * silently authorise unsupervised runs across the fleet. Absent therefore means DENY wherever a
117
+ * control plane exists to have said otherwise.
118
+ *
119
+ * Unmanaged deployments (no lease URL configured at all) keep the existing local-first philosophy:
120
+ * there is no control plane to ask, so the operator's env is the whole answer. serve says so at
121
+ * boot rather than leaving it implicit.
122
+ */
123
+ function resolveAllowServiceTurns(opts) {
124
+ if (!opts.managed) {
125
+ return { allowed: true, gate: 'unmanaged_no_lease' };
126
+ }
127
+ if (opts.lease === true) {
128
+ return { allowed: true, gate: 'lease_allows' };
129
+ }
130
+ // `undefined` and `false` are deliberately NOT distinguished in the outcome, only in the
131
+ // reason: both deny, so a control plane too old to emit the field is exactly as safe as one
132
+ // that emitted `false`.
133
+ return { allowed: false, gate: opts.lease === false ? 'lease_denies' : 'lease_absent_field' };
134
+ }
135
+ /** Operator-facing explanation for a `service_turn_denied` audit row and the boot banner. */
136
+ function describeServiceTurnGate(gate) {
137
+ switch (gate) {
138
+ case 'lease_allows':
139
+ return 'permitted by the control plane (lease.allowServiceTurns)';
140
+ case 'unmanaged_no_lease':
141
+ return 'permitted: no control plane configured, so AGENTHIPPO_SERVICE_USER is the only gate';
142
+ case 'lease_denies':
143
+ return 'refused: the control plane set lease.allowServiceTurns=false';
144
+ case 'lease_absent_field':
145
+ return 'refused: this is a managed deployment and the lease does not set allowServiceTurns '
146
+ + '(absent means deny — enable it in the console)';
147
+ }
148
+ }
107
149
  /**
108
150
  * Does any live rule name this session?
109
151
  *
@@ -78,7 +78,7 @@ function startRunnerHost(opts) {
78
78
  }
79
79
  const controller = new AbortController();
80
80
  inflight.set(id, controller);
81
- const emitter = makeEmitter(send);
81
+ const emitter = makeEmitter(send, id);
82
82
  try {
83
83
  // `options` crossed the boundary already scrubbed of apiKey (see toWireOptions); the
84
84
  // engine reaches models via baseUrl + modelToken, both sandbox-scoped.
@@ -96,29 +96,39 @@ function startRunnerHost(opts) {
96
96
  inflight.delete(id);
97
97
  }
98
98
  }
99
- // The runner is live once the engine is constructed and the loop is wired.
100
- send({ type: 'ready' });
99
+ // The runner is live once the engine is constructed and the loop is wired. `demux: true` tells
100
+ // serve this host tags streamed events with their run id, so it may run several turns here at
101
+ // once; a serve talking to an older image never sees it and serializes instead.
102
+ send({ type: 'ready', demux: true });
101
103
  return { done, close: () => finish() };
102
104
  }
103
- /** Bridges `StreamEmitter` calls onto the wire, 1:1 with the existing worker event union. */
104
- function makeEmitter(send) {
105
+ /**
106
+ * Bridges `StreamEmitter` calls onto the wire, 1:1 with the existing worker event union.
107
+ *
108
+ * Every event is tagged with the run it came from. One sandbox may host several of a user's
109
+ * sessions, and an untagged event can only be broadcast to every in-flight turn — which wrote one
110
+ * session's tokens and tool calls into another's transcript.
111
+ */
112
+ function makeEmitter(send, id) {
113
+ const emit = (event) => send({ id, event });
105
114
  return {
106
- async thinking(delta) { send({ type: 'thinking', delta }); },
107
- async text(delta) { send({ type: 'text', delta }); },
115
+ async thinking(delta) { emit({ type: 'thinking', delta }); },
116
+ async text(delta) { emit({ type: 'text', delta }); },
108
117
  async toolStart(toolName, input, toolCallId) {
109
- send({ type: 'toolStart', toolName, ...(input !== undefined ? { input } : {}), ...(toolCallId ? { toolCallId } : {}) });
118
+ emit({ type: 'toolStart', toolName, ...(input !== undefined ? { input } : {}), ...(toolCallId ? { toolCallId } : {}) });
110
119
  },
111
120
  async toolEnd(toolName, toolCallId, result, isError) {
112
- send({ type: 'toolEnd', toolName, toolCallId, ...(result !== undefined ? { result } : {}), ...(isError !== undefined ? { isError } : {}) });
121
+ emit({ type: 'toolEnd', toolName, toolCallId, ...(result !== undefined ? { result } : {}), ...(isError !== undefined ? { isError } : {}) });
113
122
  },
114
- async progress(message) { send({ type: 'progress', message }); },
123
+ async progress(message) { emit({ type: 'progress', message }); },
115
124
  async question(payload) {
116
- // One-way: forwarded for visibility, answered here. No answer frame exists.
117
- send({ type: 'question', payload });
125
+ // One-way: forwarded for visibility, answered here. No answer frame exists, but it
126
+ // still reaches a client stream, so it carries the run id like any other event.
127
+ send({ type: 'question', payload, id });
118
128
  return DECLINED;
119
129
  },
120
- async error(message) { send({ type: 'error', message }); },
121
- async done() { send({ type: 'done' }); },
130
+ async error(message) { emit({ type: 'error', message }); },
131
+ async done() { emit({ type: 'done' }); },
122
132
  };
123
133
  }
124
134
  function errText(err) {
@@ -4,6 +4,7 @@
4
4
  *--------------------------------------------------------------------------------------------*/
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.isTerminalFrame = isTerminalFrame;
7
+ exports.isTaggedEvent = isTaggedEvent;
7
8
  exports.encodeRunnerFrame = encodeRunnerFrame;
8
9
  exports.encodeClientFrame = encodeClientFrame;
9
10
  exports.parseRunnerFrame = parseRunnerFrame;
@@ -11,7 +12,11 @@ exports.parseClientFrame = parseClientFrame;
11
12
  exports.splitFrames = splitFrames;
12
13
  exports.toWireOptions = toWireOptions;
13
14
  function isTerminalFrame(frame) {
14
- return frame.type === 'result' || frame.type === 'failed';
15
+ return 'type' in frame && (frame.type === 'result' || frame.type === 'failed');
16
+ }
17
+ /** A streamed event that names its turn — the only shape safe to route to a single emitter. */
18
+ function isTaggedEvent(frame) {
19
+ return 'event' in frame;
15
20
  }
16
21
  function encodeRunnerFrame(frame) {
17
22
  return `${JSON.stringify(frame)}\n`;
@@ -27,7 +32,18 @@ function parseRunnerFrame(line) {
27
32
  }
28
33
  const type = parsed.type;
29
34
  if (type === 'ready') {
30
- return { type: 'ready' };
35
+ return { type: 'ready', ...(parsed.demux === true ? { demux: true } : {}) };
36
+ }
37
+ // Tagged streamed event. Checked before the untagged branch below because this envelope
38
+ // carries no top-level `type` — the inner event holds it. Validating that inner type here
39
+ // keeps a malformed envelope from becoming an event the emitter switch silently ignores,
40
+ // which would look like a turn that streamed nothing.
41
+ if (typeof parsed.id === 'string' && parsed.event && typeof parsed.event === 'object') {
42
+ const inner = parsed.event.type;
43
+ if (typeof inner === 'string' && RUNNER_EVENT_TYPES.has(inner)) {
44
+ return { id: parsed.id, event: parsed.event };
45
+ }
46
+ return undefined;
31
47
  }
32
48
  if (type === 'result' && typeof parsed.id === 'string') {
33
49
  return {
@@ -44,9 +60,13 @@ function parseRunnerFrame(line) {
44
60
  };
45
61
  }
46
62
  if (type === 'question' && parsed.payload && typeof parsed.payload === 'object') {
47
- return { type: 'question', payload: parsed.payload };
63
+ return {
64
+ type: 'question',
65
+ payload: parsed.payload,
66
+ ...(typeof parsed.id === 'string' ? { id: parsed.id } : {}),
67
+ };
48
68
  }
49
- // Streamed engine events keep the existing union — validated by shape, not re-listed here.
69
+ // Untagged streamed event (legacy peer) — validated by shape, not re-listed here.
50
70
  if (typeof type === 'string' && RUNNER_EVENT_TYPES.has(type)) {
51
71
  return parsed;
52
72
  }
@@ -23,6 +23,12 @@ class RemoteEngine {
23
23
  buffer = '';
24
24
  seq = 0;
25
25
  wired = false;
26
+ /**
27
+ * Set from the peer's `ready` frame. Starts FALSE and stays false until the peer proves it
28
+ * tags streamed events: a run frame can be sent before `ready` arrives, so "not yet known"
29
+ * must mean "assume it cannot", or the first pair of concurrent turns races the handshake.
30
+ */
31
+ peerDemux = false;
26
32
  constructor(opts) {
27
33
  this.id = opts.id;
28
34
  this.displayName = opts.displayName;
@@ -34,7 +40,7 @@ class RemoteEngine {
34
40
  this.wire();
35
41
  const id = `r${++this.seq}`;
36
42
  const settled = new Promise((resolve, reject) => {
37
- this.pending.set(id, { emitter, resolve, reject });
43
+ this.pending.set(id, { emitter, resolve, reject, chain: Promise.resolve() });
38
44
  });
39
45
  const onAbort = () => { this.send({ type: 'abort', id }); };
40
46
  if (signal) {
@@ -85,38 +91,68 @@ class RemoteEngine {
85
91
  this.logger?.warn('[RemoteEngine] dropped malformed runner frame');
86
92
  continue;
87
93
  }
88
- void this.dispatch(frame);
94
+ this.dispatch(frame);
89
95
  }
90
96
  });
91
97
  void this.channel.closed?.then(() => this.failAll(new Error('sandbox channel closed')), (err) => this.failAll(err instanceof Error ? err : new Error(String(err))));
92
98
  }
93
- async dispatch(frame) {
99
+ dispatch(frame) {
100
+ if ((0, protocol_1.isTaggedEvent)(frame)) {
101
+ // Routed to exactly one turn. No entry means the turn already settled or was aborted
102
+ // and a late event arrived behind its terminal frame — dropping it is correct.
103
+ this.enqueue(this.pending.get(frame.id), (e) => (0, customEngineIpc_1.applyParsedRunnerEvent)(e.emitter, frame.event));
104
+ return;
105
+ }
94
106
  if (frame.type === 'ready') {
107
+ this.peerDemux = frame.demux === true;
95
108
  return;
96
109
  }
97
110
  if (frame.type === 'result') {
98
- this.pending.get(frame.id)?.resolve(frame.nativeSessionId);
111
+ // Behind this turn's own events, so a turn never settles while its last delta is still
112
+ // inside an unfinished emitter call.
113
+ this.settle(frame.id, (entry) => entry.resolve(frame.nativeSessionId));
99
114
  return;
100
115
  }
101
116
  if (frame.type === 'failed') {
102
- this.pending.get(frame.id)?.reject(new Error(frame.message));
117
+ this.settle(frame.id, (entry) => entry.reject(new Error(frame.message)));
103
118
  return;
104
119
  }
105
- // Streamed events fan out to every in-flight emitter. In the sandbox model a runner hosts
106
- // exactly one session, so in practice there is one — but the run id is not carried on
107
- // streamed events (matching the existing worker protocol), so this stays a broadcast.
108
- for (const entry of this.pending.values()) {
109
- if (frame.type === 'question') {
110
- // One-way notification; the runner already answered it `declined` locally.
111
- await entry.emitter.question(frame.payload).catch(() => undefined);
112
- continue;
120
+ if (frame.type === 'question') {
121
+ // One-way notification; the runner already answered it `declined` locally.
122
+ const targets = frame.id ? [this.pending.get(frame.id)] : [...this.pending.values()];
123
+ for (const entry of targets) {
124
+ this.enqueue(entry, (e) => e.emitter.question(frame.payload).then(() => undefined));
113
125
  }
114
- await (0, customEngineIpc_1.applyParsedRunnerEvent)(entry.emitter, frame).catch((err) => {
115
- this.logger?.warn(`[RemoteEngine] emitter error: ${err instanceof Error ? err.message : String(err)}`);
116
- return undefined;
117
- });
126
+ return;
127
+ }
128
+ // LEGACY peer only: untagged events carry nothing that names their turn, so the best
129
+ // available behaviour is to broadcast. That is only safe because a peer which has not
130
+ // advertised `demux` is given one turn at a time (see sandboxRunner's `exclusive`).
131
+ for (const entry of this.pending.values()) {
132
+ this.enqueue(entry, (e) => (0, customEngineIpc_1.applyParsedRunnerEvent)(e.emitter, frame).then(() => undefined));
118
133
  }
119
134
  }
135
+ /** Appends work to one turn's ordered chain. An emitter failure never breaks the chain. */
136
+ enqueue(entry, work) {
137
+ if (!entry) {
138
+ return;
139
+ }
140
+ entry.chain = entry.chain.then(() => work(entry)).then(() => undefined, (err) => {
141
+ this.logger?.warn(`[RemoteEngine] emitter error: ${err instanceof Error ? err.message : String(err)}`);
142
+ });
143
+ }
144
+ /** Settles a turn once everything already streamed for it has been applied. */
145
+ settle(id, finish) {
146
+ const entry = this.pending.get(id);
147
+ if (!entry) {
148
+ return;
149
+ }
150
+ void entry.chain.then(() => finish(entry));
151
+ }
152
+ /** True once the peer's `ready` proved it tags streamed events — see `peerDemux`. */
153
+ supportsDemux() {
154
+ return this.peerDemux;
155
+ }
120
156
  failAll(err) {
121
157
  for (const [id, entry] of this.pending) {
122
158
  entry.reject(err);
@@ -153,7 +153,7 @@ class ContainerSandboxDriver {
153
153
  }
154
154
  async create(spec) {
155
155
  assertPinnedByDigest(spec.image);
156
- const id = `sandbox-${this.instance}-${++this.seq}-${sanitize(spec.sessionKey)}`;
156
+ const id = `sandbox-${this.instance}-${++this.seq}-${sanitize(spec.sandboxKey)}`;
157
157
  // One DIRECTORY per sandbox, mounted at a fixed path inside. Docker cannot bind-mount a
158
158
  // socket file that does not exist yet — it would create a directory in its place and the
159
159
  // sandbox's bind() then fails (observed live). The sandbox creates the socket inside its own
@@ -217,7 +217,9 @@ class ContainerSandboxDriver {
217
217
  // The channel: this sandbox's own socket directory and no other's.
218
218
  '--volume', `${sandboxSocketDir}:${exports.SANDBOX_SOCKET_DIR_IN_SANDBOX}`,
219
219
  '--label', `ai.agenthippo.sandbox=${id}`,
220
- '--label', `ai.agenthippo.session=${spec.sessionKey}`,
220
+ // Kept as `session` for operators' existing greps; under user scope it names the user's
221
+ // sandbox rather than one conversation.
222
+ '--label', `ai.agenthippo.session=${spec.sandboxKey}`,
221
223
  '--label', `ai.agenthippo.tier=${spec.tier}`,
222
224
  ];
223
225
  for (const entry of spec.fs.readonly) {
@@ -171,7 +171,7 @@ class ProcessSandboxDriver {
171
171
  return this.nsWrap;
172
172
  }
173
173
  async create(spec) {
174
- const id = `sandbox-${++this.seq}-${sanitize(spec.sessionKey)}`;
174
+ const id = `sandbox-${++this.seq}-${sanitize(spec.sandboxKey)}`;
175
175
  const spawnFn = this.opts.spawnFn ?? node_child_process_1.spawn;
176
176
  const wrap = await this.namespaceWrap();
177
177
  const command = wrap ? 'unshare' : this.opts.command;
@@ -275,6 +275,9 @@ function baseSandboxEnv(opts = {}, sourceEnv = process.env) {
275
275
  // the sandbox resolves bundled engines itself via findClaudeCodePath()/findCodexPath(),
276
276
  // which already know about .agenthippo-deps and the image's baked SDKs.
277
277
  'CLAUDE_CODE_PATH', 'CODEX_PATH', 'AGENTHIPPO_CUSTOM_ENGINES_DIR', 'AGENTHIPPO_NO_AUTO_INSTALL',
278
+ // Boot-stable route URLs (not credentials). Sandboxed serve resolves ${env:AGENTHIPPO_DATA_MCP_URL}
279
+ // when loading pack MCP configs; without these the postgres gateway entry is dropped.
280
+ 'AGENTHIPPO_DATA_MCP_URL', 'AGENTHIPPO_LITELLM_BASE_URL',
278
281
  ];
279
282
  const out = {};
280
283
  for (const key of allow) {