@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
@@ -39,6 +39,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.serveCommand = serveCommand;
40
40
  exports.resolveGatewaySessionKey = resolveGatewaySessionKey;
41
41
  exports.runGatewayTurn = runGatewayTurn;
42
+ exports.runBridgeTurn = runBridgeTurn;
42
43
  /**
43
44
  * Serve Command - HTTP Server
44
45
  */
@@ -66,6 +67,8 @@ const pack_runtime_1 = require("../pack-runtime");
66
67
  const sessionShip_1 = require("../../evidence/sessionShip");
67
68
  const serve_hardening_1 = require("../serve-hardening");
68
69
  const credential_context_1 = require("../credential-context");
70
+ const lease_2 = require("../../pack/lease");
71
+ const identity_1 = require("../../analytics/identity");
69
72
  const data_plane_1 = require("../data-plane");
70
73
  const runtime_1 = require("../runtime");
71
74
  const outboundSend_1 = require("../../gateway/outboundSend");
@@ -221,6 +224,25 @@ async function serveCommand(options) {
221
224
  for (const line of (0, serve_hardening_1.identityHeaderAdvice)(process.env, bindHost)) {
222
225
  console.warn(`${utils_1.COLORS.yellow}Warning:${utils_1.COLORS.reset} ${line}`);
223
226
  }
227
+ // A pinned header means multiple users; a tier-0 or sandbox-less serve means one filesystem for
228
+ // all of them. That pairing had no boot-time signal at all, while the strictly SAFER container
229
+ // case (--shared-workspace) has warned since it shipped.
230
+ const tenantIsolation = (0, serve_hardening_1.tenantIsolationAdvice)({
231
+ sandbox: options.sandbox,
232
+ sharedWorkspace: Boolean(options.sharedWorkspace),
233
+ workspace: options.workspace,
234
+ stateDir: storagePath,
235
+ });
236
+ if (tenantIsolation.level === 'refuse') {
237
+ for (const line of tenantIsolation.lines) {
238
+ console.error(`${utils_1.COLORS.red}Error:${utils_1.COLORS.reset} ${line}`);
239
+ }
240
+ console.error(`Refusing to start because ${serve_hardening_1.REQUIRE_TENANT_ISOLATION_ENV} is set. Unset it to downgrade this to a warning.`);
241
+ process.exit(1);
242
+ }
243
+ for (const line of tenantIsolation.lines) {
244
+ console.warn(`${utils_1.COLORS.yellow}Warning:${utils_1.COLORS.reset} ${line}`);
245
+ }
224
246
  // Passed to createPackRunner below so the runner's own SessionEventWriter uses the same
225
247
  // root — a fail-closed warrant denial lands in the same per-session JSONL as the tool events
226
248
  // it precedes. The denial happens before runner.run() is ever called, so it needs its own
@@ -248,7 +270,26 @@ async function serveCommand(options) {
248
270
  // One shared registry: a single per-turn nonce grants BOTH the data-plane and egress
249
271
  // capabilities (secure-obo-design §7.6 step 2). Created when either LOCAL broker will run.
250
272
  const turnRegistry = ((dataPlaneCfg && !remoteDataPlane && !edgeDataPlane) || options.gatewayBridge) ? new data_plane_1.TurnRegistry() : null;
251
- const auditEvent = (sessionKey, event, fields) => sessionEventWriter.append(sessionKey, (0, base_1.generateRequestId)(), event, fields);
273
+ /**
274
+ * Every data-plane audit row carries BOTH principals (PLAN/agent-identity.md §1 failure 3,
275
+ * §10).
276
+ *
277
+ * The user dimension was already on these rows. The agent was not, so evidence could answer
278
+ * "which user touched which rows" but not "via which agent" — the auditor's second question,
279
+ * and the one the whole design exists to make answerable. It was only ever *implied*, by which
280
+ * deployment happened to write the file, and that implication breaks entirely the moment two
281
+ * serves share a connector (§4.4).
282
+ *
283
+ * Read at emit time, not captured once: `activeVersion` and `packDigestValue` both change on a
284
+ * pack hot-swap, and a row must name the pack that actually served it.
285
+ *
286
+ * The digest is carried in full rather than truncated — it is not a secret, and a prefix cannot
287
+ * be checked against a signed artifact, which is the entire point of recording it.
288
+ */
289
+ const auditEvent = (sessionKey, event, fields) => sessionEventWriter.append(sessionKey, (0, base_1.generateRequestId)(), event, {
290
+ ...fields,
291
+ agent: { pack: packId, version: activeVersion, digest: packDigestValue ?? null },
292
+ });
252
293
  let dataPlane = null;
253
294
  if (edgeDataPlane) {
254
295
  process.env[data_plane_1.DATA_MCP_URL_ENV] = edgeDataPlane.url;
@@ -317,7 +358,13 @@ async function serveCommand(options) {
317
358
  }
318
359
  // Sidecar: register with the connector container; serve never sees a base credential.
319
360
  if (remoteDataPlane) {
320
- const r = await remoteDataPlane.registerTurn(reg);
361
+ // Name the agent on every registration (PR-8). Asserted, not proven — the sidecar
362
+ // records it and flags a mismatch against the pack it loaded, which is how "one
363
+ // connector, one agent" (§4.4) becomes observable rather than merely assumed.
364
+ const r = await remoteDataPlane.registerTurn({
365
+ ...reg,
366
+ agent: { pack: packId, version: activeVersion, digest: packDigestValue },
367
+ });
321
368
  const env = { ...r.env };
322
369
  if (egressBroker) {
323
370
  env[data_plane_1.EGRESS_URL_ENV] = egressBroker.url;
@@ -708,6 +755,9 @@ async function serveCommand(options) {
708
755
  // next turn. That silently contradicts "one disposable sandbox per session" for everything that
709
756
  // matters, since the container boundary does not extend to a directory both containers mount.
710
757
  const sharedWorkspace = Boolean(options.sharedWorkspace);
758
+ // One sandbox per conversation unless told otherwise. Every key below derives from this, and
759
+ // setting it back to `session` is the whole rollback.
760
+ const sandboxScope = options.sandboxScope === 'user' ? 'user' : 'session';
711
761
  const sandboxMounts = options.sandbox === 'container'
712
762
  ? {
713
763
  // serve's resolved pack, not the image's baked copy — the two cannot then disagree
@@ -734,25 +784,31 @@ async function serveCommand(options) {
734
784
  });
735
785
  sandboxImageUser = await (0, support_1.resolveSandboxImageUser)(support_1.dockerExec, options.sandboxImage);
736
786
  }
737
- // A session's engine state must outlive its sandboxes or the conversation restarts every time a
738
- // sandbox is reaped (serve-v4-sandbox §8.4). Per session, under serve's own state dir, so the
739
- // existing per-session sync/prune machinery already covers it.
740
- const sandboxStateDirFor = (sessionKey) => (0, support_1.ensureSandboxStateDir)(path.join(storagePath, 'sandbox-state', (0, state_sync_1.safeSessionId)(sessionKey)), sandboxImageUser);
787
+ // Engine state must outlive the sandboxes that use it or the conversation restarts every time a
788
+ // sandbox is reaped (serve-v4-sandbox §8.4). Keyed at SANDBOX scope, under serve's own state
789
+ // dir: one directory per session by default, or one per user under `--sandbox-scope user`,
790
+ // where the sessions sharing it stay separate beneath in `provider-sessions/`.
791
+ const sandboxStateDirFor = (sandboxKey) => (0, support_1.ensureSandboxStateDir)(path.join(storagePath, 'sandbox-state', (0, state_sync_1.safeSessionId)(sandboxKey)), sandboxImageUser);
741
792
  /**
742
- * Mounts for ONE session's sandbox: the pack read-only, and a workspace only this session sees.
793
+ * Mounts for ONE sandbox: the pack read-only, and a workspace only that sandbox sees.
743
794
  *
744
- * The per-session workspace lives beside that session's engine state, so the same reap/prune
745
- * machinery already covers it and the agent's output stays visible on the host — the property
746
- * the shared mount was added for — without also being visible to every other session.
795
+ * The workspace lives beside the engine state it belongs to, so the same reap/prune machinery
796
+ * already covers it and the agent's output stays visible on the host — the property the shared
797
+ * mount was added for — without being visible to every other sandbox.
798
+ *
799
+ * Keyed at sandbox scope, never narrower: the in-sandbox host chdir's here on every turn, and
800
+ * that is only safe while every session sharing a sandbox resolves the same directory (see the
801
+ * INVARIANT note in cli/commands/sandbox.ts). Under user scope this means a user's own sessions
802
+ * share a workspace — same trust domain, on the same terms their engine homes already shared.
747
803
  */
748
- const sandboxFsFor = async (sessionKey) => {
804
+ const sandboxFsFor = async (sandboxKey) => {
749
805
  if (!sandboxMounts) {
750
806
  return { readonly: [], writable: [] };
751
807
  }
752
808
  if (sharedWorkspace) {
753
809
  return sandboxMounts;
754
810
  }
755
- const dir = (0, support_1.ensureSandboxStateDir)(path.join(storagePath, 'sandbox-workspace', (0, state_sync_1.safeSessionId)(sessionKey)), sandboxImageUser);
811
+ const dir = (0, support_1.ensureSandboxStateDir)(path.join(storagePath, 'sandbox-workspace', (0, state_sync_1.safeSessionId)(sandboxKey)), sandboxImageUser);
756
812
  return { readonly: sandboxMounts.readonly, writable: [`${dir}:${containerDriver_1.SANDBOX_WORKSPACE_DIR}`] };
757
813
  };
758
814
  /**
@@ -814,6 +870,7 @@ async function serveCommand(options) {
814
870
  // must reach an operator who did not pass --verbose.
815
871
  ? new containerDriver_1.ContainerSandboxDriver({
816
872
  exec: support_1.dockerExec,
873
+ socketOwner: sandboxImageUser,
817
874
  logger: {
818
875
  info: (m) => logger.info(m),
819
876
  warn: (m) => console.warn(`${utils_1.COLORS.yellow}Warning:${utils_1.COLORS.reset} ${m}`),
@@ -872,7 +929,9 @@ async function serveCommand(options) {
872
929
  }),
873
930
  // Kill switch + rollback, checked before a sandbox can exist. Revoking the lease stops
874
931
  // new work fleet-wide immediately; TTL bounds the sandboxes already running.
875
- admit: async (sessionKey) => {
932
+ // sessionKey, not sandboxKey: deny rules can name a SESSION, and under user scope the
933
+ // sandbox key names the user instead — checking that would stop matching them.
934
+ admit: async ({ sessionKey }) => {
876
935
  const currentLease = await leaseSource.get();
877
936
  assertPackAdmitted(currentLease, sessionKey);
878
937
  },
@@ -887,11 +946,11 @@ async function serveCommand(options) {
887
946
  // packId here made every downstream user_id (LiteLLM, spend rows, evidence provenance)
888
947
  // the pack. Every mint runs inside runWithCredential (create-time on first-turn
889
948
  // acquire, then per-turn re-mints in RemoteEngine.stream), so the ambient credential is
890
- // the turn's own. An unidentified turn gets a session-scoped marker rather than
891
- // silently borrowing the pack's name — sessionKey is already user-namespaced, so the
892
- // marker cannot collide across identified users.
893
- mintModelToken: (sandboxId, sessionKey) => (0, support_1.mintSandboxInferenceWarrant)(warrantKey, {
894
- user: (0, credential_context_1.currentCredential)()?.userId?.trim() || `anon:${sessionKey}`,
949
+ // the turn's own. An unidentified turn gets a sandbox-scoped marker rather than
950
+ // silently borrowing the pack's name — the sandbox key is user-namespaced under either
951
+ // scope, so the marker cannot collide across identified users.
952
+ mintModelToken: (sandboxId, sandboxKey) => (0, support_1.mintSandboxInferenceWarrant)(warrantKey, {
953
+ user: (0, credential_context_1.currentCredential)()?.userId?.trim() || `anon:${sandboxKey}`,
895
954
  pack: packId,
896
955
  packVersion: activeVersion,
897
956
  sandboxId,
@@ -990,6 +1049,51 @@ async function serveCommand(options) {
990
1049
  logger.info('[Serve] lease signature is NOT required — set --require-signed-lease (or lease.requireSignedLease) once the control plane signs leases.');
991
1050
  }
992
1051
  }
1052
+ /**
1053
+ * Unattended runs (PLAN/agent-identity.md §5). Two gates, BOTH operator-side, plus a third
1054
+ * that needs no code here: the identity must be mapped in the connector's role map or the
1055
+ * connector fails the call closed exactly as it does for an unmapped human.
1056
+ *
1057
+ * Nothing in the pack participates. Enabling or disabling this never requires a re-sign.
1058
+ */
1059
+ const serviceIdentity = (0, serve_hardening_1.resolveServiceIdentity)();
1060
+ if (serviceIdentity.error) {
1061
+ // A malformed value must not degrade to "anonymous": that shape looks configured and runs
1062
+ // every scheduled job with no identity at all.
1063
+ console.error(`${utils_1.COLORS.red}Error:${utils_1.COLORS.reset} ${serviceIdentity.error}`);
1064
+ process.exit(1);
1065
+ }
1066
+ if (serviceIdentity.warning) {
1067
+ console.warn(`${utils_1.COLORS.yellow}Warning:${utils_1.COLORS.reset} ${serviceIdentity.warning}`);
1068
+ }
1069
+ const serviceTurnsManaged = Boolean(process.env.AGENTHIPPO_LEASE_URL);
1070
+ /** Resolved per turn, not at boot: revoking in the console must take effect on the next poll. */
1071
+ const serviceUserIfPermitted = async () => {
1072
+ if (!serviceIdentity.identity) {
1073
+ return undefined;
1074
+ }
1075
+ const gate = (0, lease_2.resolveAllowServiceTurns)({
1076
+ lease: (await leaseSource.get())?.allowServiceTurns,
1077
+ managed: serviceTurnsManaged,
1078
+ });
1079
+ if (!gate.allowed) {
1080
+ // Named gate, so the operator reads which knob to turn rather than "denied".
1081
+ auditEvent('service-turn', 'service_turn_denied', {
1082
+ identity: (0, identity_1.hashIdentity)(serviceIdentity.identity),
1083
+ gate: gate.gate,
1084
+ detail: (0, lease_2.describeServiceTurnGate)(gate.gate),
1085
+ });
1086
+ return undefined;
1087
+ }
1088
+ return serviceIdentity.identity;
1089
+ };
1090
+ if (serviceIdentity.identity) {
1091
+ const boot = (0, lease_2.resolveAllowServiceTurns)({ lease: undefined, managed: serviceTurnsManaged });
1092
+ logger.info(`[Serve] unattended runs: identity configured (${serve_hardening_1.SERVICE_USER_ENV}); `
1093
+ + (serviceTurnsManaged
1094
+ ? 'the control plane decides per turn via lease.allowServiceTurns — absent means deny'
1095
+ : (0, lease_2.describeServiceTurnGate)(boot.gate)));
1096
+ }
993
1097
  if (sandboxRunner) {
994
1098
  logger.info(`[Serve] sandbox mode: ${options.sandbox} (tier ${options.sandbox === 'container' ? 2 : 0}) — engines run in per-session sandboxes`);
995
1099
  // A sandbox's ONLY model credential is the per-sandbox warrant, which is meaningful only to an
@@ -1111,11 +1215,11 @@ async function serveCommand(options) {
1111
1215
  }
1112
1216
  // User-namespaced session key (secure-obo-design §3.4): kills cross-user session
1113
1217
  // attachment via a guessed session_id. Fails closed on pinned-identity deployments.
1114
- const { sessionKey, rejected } = (0, serve_hardening_1.resolveServeSessionKey)(body.session_id, userId);
1218
+ const { sessionKey, sandboxKey, rejected } = (0, serve_hardening_1.resolveServeSessionKey)(body.session_id, userId, sandboxScope);
1115
1219
  if (rejected) {
1116
1220
  sessionEventWriter.append('unverified', (0, base_1.generateRequestId)(), 'warrant_denied', { reason: rejected });
1117
1221
  res.writeHead(403, { 'Content-Type': 'application/json' });
1118
- res.end(JSON.stringify({ error: 'session_id requires an authenticated user' }));
1222
+ res.end(JSON.stringify({ error: 'verified user identity required' }));
1119
1223
  return;
1120
1224
  }
1121
1225
  // Acquire before turn-limiter so a capacity reject does not burn a turn slot.
@@ -1143,7 +1247,7 @@ async function serveCommand(options) {
1143
1247
  if (userId?.trim()) {
1144
1248
  turnReg = await registerServeTurn({ userId: userId.trim(), sessionKey, inboundToken: dataToken, edgeNonce: edgeTurnNonce(req.headers) });
1145
1249
  }
1146
- const result = await (0, credential_context_1.runWithCredential)({ userId, dataToken }, () => handleChat(runner, body, sessionKey, defaultEngine, defaultModel, options.workspace, permissions, userId, turnReg?.env), { onDenied: logWarrantDenied(sessionKey) });
1250
+ const result = await (0, credential_context_1.runWithCredential)({ userId, dataToken }, () => handleChat(runner, body, sessionKey, sandboxKey, defaultEngine, defaultModel, options.workspace, permissions, userId, turnReg?.env), { onDenied: logWarrantDenied(sessionKey) });
1147
1251
  res.writeHead(200, { 'Content-Type': 'application/json' });
1148
1252
  res.end(JSON.stringify(result));
1149
1253
  statePusher?.schedule(sessionKey); // per-session delta push, off the response path
@@ -1168,11 +1272,11 @@ async function serveCommand(options) {
1168
1272
  return;
1169
1273
  }
1170
1274
  // Same user-namespaced session key + fail-closed rule as /v1/chat above.
1171
- const { sessionKey, rejected } = (0, serve_hardening_1.resolveServeSessionKey)(body.session_id, userId);
1275
+ const { sessionKey, sandboxKey, rejected } = (0, serve_hardening_1.resolveServeSessionKey)(body.session_id, userId, sandboxScope);
1172
1276
  if (rejected) {
1173
1277
  sessionEventWriter.append('unverified', (0, base_1.generateRequestId)(), 'warrant_denied', { reason: rejected });
1174
1278
  res.writeHead(403, { 'Content-Type': 'application/json' });
1175
- res.end(JSON.stringify({ error: 'session_id requires an authenticated user' }));
1279
+ res.end(JSON.stringify({ error: 'verified user identity required' }));
1176
1280
  return;
1177
1281
  }
1178
1282
  // Acquire BEFORE writeHead(200, SSE) so a reject still gets a clean 429.
@@ -1208,7 +1312,7 @@ async function serveCommand(options) {
1208
1312
  'Cache-Control': 'no-cache',
1209
1313
  'Connection': 'keep-alive',
1210
1314
  });
1211
- await handleStreamingChat(runner, body, sessionKey, defaultEngine, defaultModel, options.workspace, permissions, res, userId, turnReg?.env);
1315
+ await handleStreamingChat(runner, body, sessionKey, sandboxKey, defaultEngine, defaultModel, options.workspace, permissions, res, userId, turnReg?.env);
1212
1316
  }, { onDenied: logWarrantDenied(sessionKey) });
1213
1317
  statePusher?.schedule(sessionKey); // per-session delta push, off the response path
1214
1318
  }
@@ -1330,8 +1434,10 @@ async function serveCommand(options) {
1330
1434
  turnLimiter,
1331
1435
  warnGateReject,
1332
1436
  prepareSession,
1437
+ sandboxScope,
1333
1438
  onTurnComplete: (sessionId) => statePusher?.schedule(sessionId),
1334
1439
  logWarrantDenied,
1440
+ serviceUserIfPermitted,
1335
1441
  });
1336
1442
  }
1337
1443
  catch (err) {
@@ -1419,7 +1525,7 @@ async function parseBody(req) {
1419
1525
  throw new Error('Invalid JSON');
1420
1526
  }
1421
1527
  }
1422
- async function handleChat(runner, body, sessionKey, defaultEngine, defaultModel, workspace, permissions, userId, turnEnv) {
1528
+ async function handleChat(runner, body, sessionKey, sandboxKey, defaultEngine, defaultModel, workspace, permissions, userId, turnEnv) {
1423
1529
  const result = await runner.run({
1424
1530
  engineId: resolveRequestedEngineId(body.engine, defaultEngine),
1425
1531
  modelId: body.model || defaultModel,
@@ -1428,6 +1534,9 @@ async function handleChat(runner, body, sessionKey, defaultEngine, defaultModel,
1428
1534
  // The user-namespaced key (never raw body.session_id) so engine conversation
1429
1535
  // state and per-session artifacts are per-user — see resolveServeSessionKey.
1430
1536
  sessionId: sessionKey,
1537
+ // Which sandbox runs it. Equal to sessionKey unless --sandbox-scope user; ignored
1538
+ // entirely by the in-process runner.
1539
+ sandboxKey,
1431
1540
  analyticsUserId: userId,
1432
1541
  permissions,
1433
1542
  turnEnv,
@@ -1462,21 +1571,70 @@ async function handleChat(runner, body, sessionKey, defaultEngine, defaultModel,
1462
1571
  * reply handles under it (bridge.ts upsertGatewayOutboundHandle), so the outbound-routing
1463
1572
  * file must keep resolving from the un-namespaced key or reply-to-sender breaks.
1464
1573
  */
1465
- function resolveGatewaySessionKey(opts, userId) {
1574
+ function resolveGatewaySessionKey(opts, userId, scope = 'session',
1575
+ /**
1576
+ * This turn runs as the deployment's SERVICE identity — no human behind it
1577
+ * (PLAN/agent-identity.md §5.4). Passed explicitly rather than sniffed from a prefix on
1578
+ * `userId`: deriving behaviour from the shape of an identity string is how the `"false"` class
1579
+ * of bug happens, and §5.2 deliberately allows identities with no recognisable prefix at all.
1580
+ */
1581
+ isServiceIdentity = false) {
1582
+ if (isServiceIdentity && userId) {
1583
+ // Two independent reasons this branch cannot reuse the durable human shape.
1584
+ //
1585
+ // sessionKey FRESH: TurnLimiter is a monotonic per-session counter with no window, no
1586
+ // reset and no decay (serve-hardening.ts). A recurring job on a stable key therefore burns
1587
+ // a LIFETIME budget one run per night and, on night maxTurnsPerSession + 1, is rejected
1588
+ // with `turn_limit` forever — recovering only on a serve restart. A nightly job that dies
1589
+ // after N nights, where N is a cap nobody associates with cron.
1590
+ //
1591
+ // sandboxKey STABLE: under the default --sandbox-scope session it would otherwise track
1592
+ // sessionKey, so "fresh key per run" would silently mean a NEW CONTAINER every night —
1593
+ // cold start, a new governor entry per run, and maxSessionSeconds that never accumulates.
1594
+ // Holding it stable gives the bot one warm sandbox and, more importantly, ONE handle for
1595
+ // caps and the kill switch.
1596
+ //
1597
+ // Net shape: fresh conversation, warm sandbox.
1598
+ const stable = `gateway-${userId}`;
1599
+ return {
1600
+ sessionKey: `${stable}--${(0, node_crypto_1.randomUUID)()}`,
1601
+ sandboxKey: stable,
1602
+ // Replies must still route: the egress broker resolves this run's destinations from the
1603
+ // raw channel key, so a cron job that messages Slack keeps working.
1604
+ outboundKey: opts.sessionId?.trim() || opts.gatewaySessionKey?.trim() || stable,
1605
+ // No prior state to restore and no future turn to restore it for. A batch job that
1606
+ // silently accumulates context also silently accumulates cost; continuity across runs
1607
+ // stays available by passing a stable session_id, which takes the durable branch below.
1608
+ durable: false,
1609
+ };
1610
+ }
1611
+ // The SAME derivation resolveServeSessionKey uses. If the two disagreed, one person reaching
1612
+ // the same serve over HTTP and over a channel would get two sandboxes under user scope —
1613
+ // neither the old behaviour nor the new one.
1614
+ const userSandboxKey = userId ? `u${(0, serve_hardening_1.userNamespaceHash)(userId)}` : undefined;
1466
1615
  const explicit = opts.sessionId?.trim() || opts.gatewaySessionKey?.trim();
1467
1616
  if (explicit) {
1468
1617
  const sessionKey = userId ? `u${(0, serve_hardening_1.userNamespaceHash)(userId)}--${explicit}` : explicit;
1469
- return { sessionKey, outboundKey: explicit, durable: true };
1618
+ const sandboxKey = scope === 'user' && userSandboxKey ? userSandboxKey : sessionKey;
1619
+ return { sessionKey, sandboxKey, outboundKey: explicit, durable: true };
1470
1620
  }
1471
1621
  if (userId) {
1472
1622
  // Already keyed on the sender's own identity — no cross-user attachment surface.
1473
- return { sessionKey: `gateway-${userId}`, outboundKey: `gateway-${userId}`, durable: true };
1623
+ const sessionKey = `gateway-${userId}`;
1624
+ return {
1625
+ sessionKey,
1626
+ sandboxKey: scope === 'user' && userSandboxKey ? userSandboxKey : sessionKey,
1627
+ outboundKey: sessionKey,
1628
+ durable: true,
1629
+ };
1474
1630
  }
1475
1631
  // randomUUID (122 bits), not generateRequestId (~32 bits) — this is the sole thing
1476
1632
  // standing between two unrelated anonymous senders and a real collision, so it needs to
1477
1633
  // be architecturally collision-free, not just statistically unlikely.
1478
1634
  const anon = `gateway-anon-${(0, node_crypto_1.randomUUID)()}`;
1479
- return { sessionKey: anon, outboundKey: anon, durable: false };
1635
+ // Its own sandbox, under either scope: with no identity there is no user to group by, and
1636
+ // grouping unidentified senders would be inventing one.
1637
+ return { sessionKey: anon, sandboxKey: anon, outboundKey: anon, durable: false };
1480
1638
  }
1481
1639
  /**
1482
1640
  * Gateway-channel turn admission: rate limit → turn gate (capacity) → per-session turn cap,
@@ -1539,79 +1697,112 @@ async function startServeGatewayBridge(params) {
1539
1697
  workspace: params.workspace,
1540
1698
  engineId: params.engine,
1541
1699
  syncOpenClawConfigAgent: true,
1542
- messageHandler: async (message, opts) => {
1543
- const requestedEngine = opts.engineId?.trim().toLowerCase();
1544
- const effectiveEngine = requestedEngine === 'custom'
1545
- ? params.engine
1546
- : (requestedEngine || params.engine);
1547
- // #4: the channel sender becomes the per-user user_id (analytics + per-user data scoping).
1548
- const userId = (0, serve_hardening_1.peerToUserId)(opts.peerInfo);
1549
- const { sessionKey, outboundKey, durable } = resolveGatewaySessionKey(opts, userId);
1550
- return runGatewayTurn({
1551
- rateLimiter: params.rateLimiter,
1552
- turnLimiter: params.turnLimiter,
1553
- turnGate: params.turnGate,
1554
- warnGateReject: params.warnGateReject,
1555
- // Same identity-keyed cap the HTTP endpoints enforce — a channel sender is already
1556
- // trusted enough here to gate the OBO credential scope below, so it's trusted enough
1557
- // to rate-limit on directly. Unidentified senders share one 'gateway' bucket rather
1558
- // than being exempt from throttling entirely.
1559
- rlKey: userId ?? 'gateway',
1560
- sessionKey,
1561
- trackSession: durable,
1562
- emitter: opts.emitter,
1563
- run: async () => {
1564
- // A one-off anonymous sessionKey can never have prior state to restore, and
1565
- // nothing will ever look it up again to push state for — skip both S3 round-trips.
1566
- if (durable) {
1567
- await params.prepareSession(sessionKey);
1568
- }
1569
- // Channel-originated turns carry no inbound platform token: with the
1570
- // passthrough provider they fail closed at the gateway (an unlinked chat
1571
- // peer gets NO data credential — secure-obo-design §7.5 bind-before-
1572
- // credential); the identity provider maps the peer id server-side instead.
1573
- // egressStoreKey is the RAW gateway key (outboundKey) the bridge records reply
1574
- // handles under — it lets the egress broker resolve this session's bound
1575
- // destinations server-side (§7.6). userId gates BOTH capabilities: an
1576
- // unidentified peer gets neither a nonce nor an egress route.
1577
- const turnReg = userId?.trim()
1578
- ? await params.registerTurn({ userId: userId.trim(), sessionKey, egressStoreKey: outboundKey })
1579
- : null;
1580
- try {
1581
- const result = await (0, credential_context_1.runWithCredential)({ userId }, () => params.runner.run({
1582
- engineId: effectiveEngine,
1583
- modelId: params.defaultModel,
1584
- message,
1585
- workspaceRoot: params.workspace,
1586
- // Use the resolved sessionKey (not the raw opts fields) so the engine's own
1587
- // session state and the S3 restore/push above key off the same, user-namespaced
1588
- // identity. The outbound-routing file (buildSessionEnvPatch) keeps the raw
1589
- // gateway key — the bridge records reply handles under it, so namespacing it
1590
- // here would break reply-to-sender. See resolveGatewaySessionKey.
1591
- sessionId: sessionKey,
1592
- gatewaySessionKey: outboundKey,
1593
- analyticsUserId: userId,
1594
- emitter: opts.emitter,
1595
- permissions: params.permissions,
1596
- turnEnv: turnReg?.env,
1597
- }), { onDenied: params.logWarrantDenied(sessionKey) });
1598
- if (durable) {
1599
- params.onTurnComplete?.(sessionKey);
1600
- }
1601
- return { success: result.success, error: result.error };
1602
- }
1603
- finally {
1604
- await turnReg?.release();
1605
- }
1606
- },
1607
- });
1608
- },
1700
+ messageHandler: (message, opts) => runBridgeTurn(params, message, opts),
1609
1701
  });
1610
1702
  console.log(`${utils_1.COLORS.green}✓${utils_1.COLORS.reset} Gateway bridge: registered "${params.gatewayAgentId}" at ${gatewayUrl}`);
1611
1703
  console.log(`${utils_1.COLORS.dim} Gateway outbound routes: ${params.workspace}/.agent-hippo/sessions/<sessionKey>/gateway-outbound.json${utils_1.COLORS.reset}`);
1612
1704
  console.log(`${utils_1.COLORS.dim} openclaw agent --agent ${params.gatewayAgentId} --message "…"${utils_1.COLORS.reset}`);
1613
1705
  return bridge;
1614
1706
  }
1707
+ /**
1708
+ * One gateway-bridge turn, extracted from the message handler so the shipped path is directly
1709
+ * testable.
1710
+ *
1711
+ * It was previously an inline closure inside `bridge.registerAgent`, reachable only through a live
1712
+ * OpenClaw websocket — so the composition that actually delivers unattended runs (five identity
1713
+ * sites, four session-key values, the limiter, the registry) had no test that ran it. Transcribing
1714
+ * it into a test would have been a second implementation, free to drift from the one that ships.
1715
+ */
1716
+ async function runBridgeTurn(params, message, opts) {
1717
+ const requestedEngine = opts.engineId?.trim().toLowerCase();
1718
+ const effectiveEngine = requestedEngine === 'custom'
1719
+ ? params.engine
1720
+ : (requestedEngine || params.engine);
1721
+ // #4: the channel sender becomes the per-user user_id (analytics + per-user data scoping).
1722
+ const peerUser = (0, serve_hardening_1.peerToUserId)(opts.peerInfo);
1723
+ // No chat peer ⇒ this is an unattended run (cron, or a channel event with no sender).
1724
+ // It may act as the deployment's own service identity if BOTH operator-side gates
1725
+ // allow it (PLAN/agent-identity.md §5.6). Never on the anonymous HTTP lane — that is
1726
+ // externally reachable, and an unauthenticated caller must not be promotable to a
1727
+ // standing identity; this handler is the gateway-bridge lane only.
1728
+ const svcUser = peerUser ? undefined : await params.serviceUserIfPermitted?.();
1729
+ const effectiveUser = peerUser ?? svcUser;
1730
+ // effectiveUser flows to FIVE places, not two. Missing any of them degrades the design
1731
+ // silently rather than loudly — see each site below.
1732
+ const { sessionKey, sandboxKey, outboundKey, durable } = resolveGatewaySessionKey(opts, effectiveUser, params.sandboxScope, Boolean(svcUser));
1733
+ return runGatewayTurn({
1734
+ rateLimiter: params.rateLimiter,
1735
+ turnLimiter: params.turnLimiter,
1736
+ turnGate: params.turnGate,
1737
+ warnGateReject: params.warnGateReject,
1738
+ // (1) Same identity-keyed cap the HTTP endpoints enforce — a channel sender is already
1739
+ // trusted enough here to gate the OBO credential scope below, so it's trusted enough
1740
+ // to rate-limit on directly. Unidentified senders share one 'gateway' bucket rather
1741
+ // than being exempt from throttling entirely. A service turn gets its OWN bucket:
1742
+ // leaving it in the shared pool would let one bot's schedule throttle every
1743
+ // anonymous sender, and vice versa.
1744
+ rlKey: effectiveUser ?? 'gateway',
1745
+ sessionKey,
1746
+ trackSession: durable,
1747
+ emitter: opts.emitter,
1748
+ run: async () => {
1749
+ // A one-off anonymous sessionKey can never have prior state to restore, and
1750
+ // nothing will ever look it up again to push state for — skip both S3 round-trips.
1751
+ if (durable) {
1752
+ await params.prepareSession(sessionKey);
1753
+ }
1754
+ // Channel-originated turns carry no inbound platform token: with the
1755
+ // passthrough provider they fail closed at the gateway (an unlinked chat
1756
+ // peer gets NO data credential — secure-obo-design §7.5 bind-before-
1757
+ // credential); the identity provider maps the peer id server-side instead.
1758
+ // egressStoreKey is the RAW gateway key (outboundKey) the bridge records reply
1759
+ // handles under — it lets the egress broker resolve this session's bound
1760
+ // destinations server-side (§7.6). userId gates BOTH capabilities: an
1761
+ // unidentified peer gets neither a nonce nor an egress route.
1762
+ // (2) Data authority.
1763
+ const turnReg = effectiveUser?.trim()
1764
+ ? await params.registerTurn({ userId: effectiveUser.trim(), sessionKey, egressStoreKey: outboundKey })
1765
+ : null;
1766
+ try {
1767
+ // (3) Warrant + spend. The easy site to miss and the expensive one: the
1768
+ // warrant's `user` claim reads `currentCredential()?.userId ?? anon:<sandboxKey>`,
1769
+ // so registering without ALSO scoping here gives the bot data access under its
1770
+ // service identity while its SPEND attributes to `anon:` — outside the per-user
1771
+ // meter and unattributable in evidence.
1772
+ const result = await (0, credential_context_1.runWithCredential)({ userId: effectiveUser }, () => params.runner.run({
1773
+ engineId: effectiveEngine,
1774
+ modelId: params.defaultModel,
1775
+ message,
1776
+ workspaceRoot: params.workspace,
1777
+ // Use the resolved sessionKey (not the raw opts fields) so the engine's own
1778
+ // session state and the S3 restore/push above key off the same, user-namespaced
1779
+ // identity. The outbound-routing file (buildSessionEnvPatch) keeps the raw
1780
+ // gateway key — the bridge records reply handles under it, so namespacing it
1781
+ // here would break reply-to-sender. See resolveGatewaySessionKey.
1782
+ sessionId: sessionKey,
1783
+ // (4) Which sandbox runs it — stable across runs for a service
1784
+ // identity, so a nightly job reuses one warm container.
1785
+ sandboxKey,
1786
+ gatewaySessionKey: outboundKey,
1787
+ // (5) Attribution. Left unset, Spotlight rows would have no user
1788
+ // while the warrant claims the service identity — two attribution
1789
+ // channels disagreeing, which is worse than one being wrong.
1790
+ analyticsUserId: effectiveUser,
1791
+ emitter: opts.emitter,
1792
+ permissions: params.permissions,
1793
+ turnEnv: turnReg?.env,
1794
+ }), { onDenied: params.logWarrantDenied(sessionKey) });
1795
+ if (durable) {
1796
+ params.onTurnComplete?.(sessionKey);
1797
+ }
1798
+ return { success: result.success, error: result.error };
1799
+ }
1800
+ finally {
1801
+ await turnReg?.release();
1802
+ }
1803
+ },
1804
+ });
1805
+ }
1615
1806
  /**
1616
1807
  * Connect to the gateway and set channel bindings for this agent.
1617
1808
  * Called when --bind is specified (e.g., --bind "telegram,whatsapp" or --bind "*").
@@ -1667,7 +1858,7 @@ async function bindToGatewayChannels(agentId, bindSpec, token) {
1667
1858
  client.close();
1668
1859
  }
1669
1860
  }
1670
- async function handleStreamingChat(runner, body, sessionKey, defaultEngine, defaultModel, workspace, permissions, res, userId, turnEnv) {
1861
+ async function handleStreamingChat(runner, body, sessionKey, sandboxKey, defaultEngine, defaultModel, workspace, permissions, res, userId, turnEnv) {
1671
1862
  let streamEnded = false;
1672
1863
  const sendEvent = (type, data) => {
1673
1864
  if (streamEnded || res.writableEnded) {
@@ -1723,6 +1914,7 @@ async function handleStreamingChat(runner, body, sessionKey, defaultEngine, defa
1723
1914
  workspaceRoot: workspace,
1724
1915
  // The user-namespaced key (never raw body.session_id) — see resolveServeSessionKey.
1725
1916
  sessionId: sessionKey,
1917
+ sandboxKey,
1726
1918
  analyticsUserId: userId,
1727
1919
  emitter,
1728
1920
  permissions,
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.ON_DENIED_TIMEOUT_MS = exports.WARRANT_DENIED_REASON = void 0;
7
7
  exports.runWithCredential = runWithCredential;
8
8
  exports.currentCredential = currentCredential;
9
+ exports.resolveEngineHomeUserKey = resolveEngineHomeUserKey;
9
10
  exports.requireUserId = requireUserId;
10
11
  /**
11
12
  * Request-scoped credential context — the "Design B" seam from serve-v3 §11.1.
@@ -23,6 +24,7 @@ exports.requireUserId = requireUserId;
23
24
  * (secure-obo-design §2/§3 — the agent gets a capability, not the credential).
24
25
  */
25
26
  const async_hooks_1 = require("async_hooks");
27
+ const paths_1 = require("../agentpack/paths");
26
28
  /** Stable, queryable reason code for the one case runWithCredential rejects. */
27
29
  exports.WARRANT_DENIED_REASON = 'token_without_verified_user';
28
30
  /** Default bound on `onDenied` so a hung audit write cannot stall admission forever. */
@@ -78,6 +80,21 @@ async function runWithCredential(cred, fn, options) {
78
80
  function currentCredential() {
79
81
  return storage.getStore();
80
82
  }
83
+ /**
84
+ * The per-user engine-home segment for this turn, from whichever source actually has it.
85
+ *
86
+ * Two processes need the same answer and only one of them can read the credential. In serve the
87
+ * frame is authoritative. In a sandbox child there is no frame — that is the design, the
88
+ * credential never crosses — so the key arrives pre-computed on the run frame and is used as-is.
89
+ * The frame wins where both exist, so a child cannot widen its own scope by sending a key.
90
+ *
91
+ * Every engine must go through this rather than reading the credential directly: doing the latter
92
+ * is what silently unscoped every sandboxed turn, and the failure was invisible because the
93
+ * unscoped path is a perfectly good directory that simply holds everyone's data.
94
+ */
95
+ function resolveEngineHomeUserKey(fromRunFrame) {
96
+ return (0, paths_1.engineHomeUserKey)(storage.getStore()?.userId) ?? fromRunFrame?.trim() ?? undefined;
97
+ }
81
98
  /**
82
99
  * Fail-closed accessor for the acting user id. Throws when there is no
83
100
  * authenticated user in scope, so a tool can never silently run unscoped.
@@ -36,7 +36,7 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  };
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.DataPlane = exports.resolveEdgeDataPlane = exports.EdgeDataPlane = exports.resolveRemoteDataPlane = exports.RemoteDataPlane = exports.startDataPlaneSidecar = exports.EGRESS_URL_ENV = exports.startEgressBroker = exports.TURN_NONCE_HEADER = exports.TURN_NONCE_ENV = exports.DATA_MCP_URL_ENV = exports.startDataGateway = exports.TURN_META_KEY = exports.ConnectorHost = exports.identityProvider = exports.passthroughProvider = exports.resolveProvider = exports.CredentialBroker = exports.DEFAULT_TURN_DEADLINE_MS = exports.hashNonce = exports.TurnRegistry = void 0;
39
+ exports.DataPlane = exports.resolveEdgeDataPlane = exports.EdgeDataPlane = exports.resolveRemoteDataPlane = exports.RemoteDataPlane = exports.ASSERTION_HEADER = exports.startDataPlaneSidecar = exports.EGRESS_URL_ENV = exports.startEgressBroker = exports.TURN_NONCE_HEADER = exports.TURN_NONCE_ENV = exports.DATA_MCP_URL_ENV = exports.startDataGateway = exports.TURN_META_KEY = exports.ConnectorHost = exports.identityProvider = exports.passthroughProvider = exports.resolveProvider = exports.CredentialBroker = exports.DEFAULT_TURN_DEADLINE_MS = exports.hashNonce = exports.TurnRegistry = void 0;
40
40
  exports.resolveDataPlaneConfig = resolveDataPlaneConfig;
41
41
  /**
42
42
  * DataPlane — orchestration of the credential broker + data-plane gateway
@@ -75,6 +75,7 @@ Object.defineProperty(exports, "startEgressBroker", { enumerable: true, get: fun
75
75
  Object.defineProperty(exports, "EGRESS_URL_ENV", { enumerable: true, get: function () { return egress_1.EGRESS_URL_ENV; } });
76
76
  var sidecar_1 = require("./sidecar");
77
77
  Object.defineProperty(exports, "startDataPlaneSidecar", { enumerable: true, get: function () { return sidecar_1.startDataPlaneSidecar; } });
78
+ Object.defineProperty(exports, "ASSERTION_HEADER", { enumerable: true, get: function () { return sidecar_1.ASSERTION_HEADER; } });
78
79
  var remote_1 = require("./remote");
79
80
  Object.defineProperty(exports, "RemoteDataPlane", { enumerable: true, get: function () { return remote_1.RemoteDataPlane; } });
80
81
  Object.defineProperty(exports, "resolveRemoteDataPlane", { enumerable: true, get: function () { return remote_1.resolveRemoteDataPlane; } });