@agenthippo-ai/agenthippo 0.2.3-dataplane → 1.110.9

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 (130) hide show
  1. package/out/analytics/dashboard/provider.js +13 -2
  2. package/out/analytics/dashboard/webview/panel.js +3 -2
  3. package/out/analytics/identity.js +0 -0
  4. package/out/analytics/service.js +23 -25
  5. package/out/analytics/sessionEvents.js +98 -23
  6. package/out/auth/authBaseUrl.js +59 -0
  7. package/out/auth/bootstrapApply.js +9 -10
  8. package/out/auth/brokerClient.js +48 -14
  9. package/out/auth/provider.js +8 -21
  10. package/out/auth/sessionStore.js +3 -40
  11. package/out/auth/signInFlow.js +2 -1
  12. package/out/auth-broker/config.js +50 -5
  13. package/out/auth-broker/server.js +122 -26
  14. package/out/auth-broker/store.js +25 -24
  15. package/out/authority/warrant.js +206 -0
  16. package/out/branding.js +27 -1
  17. package/out/cli/commands/conformance.js +249 -0
  18. package/out/cli/commands/data-plane.js +67 -0
  19. package/out/cli/commands/deploy.js +1 -0
  20. package/out/cli/commands/gateway.js +4 -0
  21. package/out/cli/commands/pack.js +182 -3
  22. package/out/cli/commands/sandbox.js +332 -0
  23. package/out/cli/commands/serve.js +875 -52
  24. package/out/cli/commands/store.js +12 -4
  25. package/out/cli/data-plane/gateway.js +28 -5
  26. package/out/cli/data-plane/index.js +17 -2
  27. package/out/cli/data-plane/registry.js +9 -0
  28. package/out/cli/data-plane/remote.js +106 -0
  29. package/out/cli/data-plane/sidecar.js +161 -0
  30. package/out/cli/deploy/layers/identity.js +85 -4
  31. package/out/cli/deploy-init.js +89 -24
  32. package/out/cli/deps.js +106 -2
  33. package/out/cli/idp-init.js +35 -0
  34. package/out/cli/index.js +155 -16
  35. package/out/cli/pack-install-ref.js +15 -0
  36. package/out/cli/pack-loader.js +8 -1
  37. package/out/cli/pack-runtime.js +61 -0
  38. package/out/cli/runtime.js +35 -1
  39. package/out/cli/serve-hardening.js +80 -3
  40. package/out/cli/utils.js +12 -1
  41. package/out/engines/base.js +32 -3
  42. package/out/engines/claude.js +33 -15
  43. package/out/engines/codex.js +110 -26
  44. package/out/engines/custom.js +8 -0
  45. package/out/evidence/sessionShip.js +610 -0
  46. package/out/extension.js +11 -2
  47. package/out/gateway/bridge.js +40 -2
  48. package/out/gateway/client.js +34 -1
  49. package/out/gateway/installSource.js +68 -0
  50. package/out/gateway/lifecycle.js +185 -51
  51. package/out/gateway/outboundPaths.js +4 -1
  52. package/out/gateway/secretsPreflight.js +90 -0
  53. package/out/gateway/service.js +7 -2
  54. package/out/headless/index.js +17 -1
  55. package/out/live-e2e/mcp-demo/server.mjs +32 -0
  56. package/out/mcp.js +30 -0
  57. package/out/pack/caps.js +271 -0
  58. package/out/pack/governor.js +309 -0
  59. package/out/pack/lease-schema.js +270 -0
  60. package/out/pack/lease.js +640 -0
  61. package/out/pack/reconcile.js +217 -0
  62. package/out/pack/signature.js +839 -0
  63. package/out/remote/handleRemoteChat.js +44 -3
  64. package/out/remote/remoteTrust.js +277 -0
  65. package/out/remote/serveClient.js +52 -5
  66. package/out/runner/host.js +131 -0
  67. package/out/runner/protocol.js +108 -0
  68. package/out/runner/remoteEngine.js +130 -0
  69. package/out/runtime/interfaces/edit-tracker.js +2 -0
  70. package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
  71. package/out/sandbox/containerDriver.js +570 -0
  72. package/out/sandbox/processDriver.js +315 -0
  73. package/out/sandbox/sandboxRunner.js +191 -0
  74. package/out/sandbox/support.js +301 -0
  75. package/out/sandbox/types.js +55 -0
  76. package/out/scripts/agentide-litellm.sh +67 -5
  77. package/out/scripts/agentide_litellm_hooks_v1.py +182 -21
  78. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +94 -132
  79. package/out/scripts/analytics/setup-analytics.ps1 +37 -13
  80. package/out/scripts/analytics/setup-analytics.sh +93 -13
  81. package/out/scripts/analytics/start-analytics.sh +86 -14
  82. package/out/scripts/gateway/setup-gateway.sh +72 -32
  83. package/out/shared/models.js +11 -110
  84. package/out/spotlight/install.js +7 -1
  85. package/out/store/index.js +41 -10
  86. package/out/sync.js +3 -1
  87. package/out/test/analytics.test.js +162 -23
  88. package/out/test/auth-baseurl.test.js +132 -0
  89. package/out/test/auth-bootstrapApply.test.js +27 -6
  90. package/out/test/auth-broker-hardening.test.js +227 -0
  91. package/out/test/auth-broker.test.js +59 -9
  92. package/out/test/branding.test.js +13 -0
  93. package/out/test/caps-governor.test.js +455 -0
  94. package/out/test/codex-new-file-survival.test.js +119 -0
  95. package/out/test/configuration-scopes.test.js +131 -0
  96. package/out/test/data-plane.test.js +67 -0
  97. package/out/test/deploy-init.test.js +40 -0
  98. package/out/test/gateway-client.test.js +28 -0
  99. package/out/test/gateway-install-source.test.js +111 -0
  100. package/out/test/gateway-lifecycle.test.js +1 -1
  101. package/out/test/gateway-outbound.test.js +7 -0
  102. package/out/test/gateway-secrets-preflight.test.js +127 -0
  103. package/out/test/gateway-session-routing.test.js +18 -0
  104. package/out/test/governor-hardening.test.js +217 -0
  105. package/out/test/headless-sync.test.js +22 -0
  106. package/out/test/lease-signature.test.js +357 -0
  107. package/out/test/lease-trust-root.test.js +273 -0
  108. package/out/test/lease-validation.test.js +169 -0
  109. package/out/test/lease.test.js +114 -0
  110. package/out/test/mcp.test.js +27 -1
  111. package/out/test/pack-admission.test.js +216 -0
  112. package/out/test/pack-digest-parity.test.js +140 -0
  113. package/out/test/pack-reconcile.test.js +183 -0
  114. package/out/test/pack-signature-artifact.test.js +103 -0
  115. package/out/test/pack-signature.test.js +176 -0
  116. package/out/test/pack-trust-root.test.js +202 -0
  117. package/out/test/remote-trust.test.js +242 -0
  118. package/out/test/runner-protocol.test.js +249 -0
  119. package/out/test/sandbox-driver.test.js +553 -0
  120. package/out/test/sandbox-hardening.test.js +395 -0
  121. package/out/test/sandbox-runner.test.js +343 -0
  122. package/out/test/serve-hardening.test.js +45 -6
  123. package/out/test/serveClient.test.js +6 -5
  124. package/out/test/sessionShip.test.js +560 -0
  125. package/out/test/spotlightPublish.test.js +28 -0
  126. package/out/test/store-install-safe.test.js +69 -0
  127. package/out/test/tls-bypass.test.js +90 -0
  128. package/out/test/warrant.test.js +138 -0
  129. package/out/utils.js +46 -9
  130. package/package.json +1 -1
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ *--------------------------------------------------------------------------------------------*/
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseStrictJsonConfig = parseStrictJsonConfig;
7
+ exports.collectMissingChannelEnvSecrets = collectMissingChannelEnvSecrets;
8
+ exports.disableChannelsInConfigText = disableChannelsInConfigText;
9
+ const MAX_WALK_DEPTH = 8;
10
+ function isRecord(value) {
11
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
12
+ }
13
+ /**
14
+ * Parse config content as strict JSON, or undefined when it does not parse.
15
+ *
16
+ * OpenClaw accepts JSON5 (the starter template has comments). A JSON5 file cannot be
17
+ * round-tripped through JSON.parse/stringify without losing comments, so callers must
18
+ * treat `undefined` as "hands off — detect nothing, rewrite nothing".
19
+ */
20
+ function parseStrictJsonConfig(content) {
21
+ try {
22
+ return JSON.parse(content);
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ function collectEnvRefs(value, out, depth) {
29
+ if (depth > MAX_WALK_DEPTH) {
30
+ return;
31
+ }
32
+ if (Array.isArray(value)) {
33
+ for (const item of value) {
34
+ collectEnvRefs(item, out, depth + 1);
35
+ }
36
+ return;
37
+ }
38
+ if (!isRecord(value)) {
39
+ return;
40
+ }
41
+ if (value.source === 'env' && typeof value.id === 'string' && value.id.trim()) {
42
+ out.add(value.id.trim());
43
+ return;
44
+ }
45
+ for (const child of Object.values(value)) {
46
+ collectEnvRefs(child, out, depth + 1);
47
+ }
48
+ }
49
+ /**
50
+ * List enabled channels whose env-sourced secret refs resolve to nothing in `env`.
51
+ *
52
+ * A channel counts as enabled unless it says `enabled: false` — a configured channel is
53
+ * intent, and the gateway will try (and fail) to start it either way. Literal token
54
+ * strings are not env refs and are never reported.
55
+ */
56
+ function collectMissingChannelEnvSecrets(config, env) {
57
+ if (!isRecord(config) || !isRecord(config.channels)) {
58
+ return [];
59
+ }
60
+ const result = [];
61
+ for (const [name, channel] of Object.entries(config.channels)) {
62
+ if (!isRecord(channel) || channel.enabled === false) {
63
+ continue;
64
+ }
65
+ const refs = new Set();
66
+ collectEnvRefs(channel, refs, 0);
67
+ const missing = [...refs].filter((id) => !env[id]?.trim()).sort();
68
+ if (missing.length) {
69
+ result.push({ channel: name, envVars: missing });
70
+ }
71
+ }
72
+ return result;
73
+ }
74
+ /**
75
+ * Return config text with the named channels set to `enabled: false`, or undefined when
76
+ * the content is not strict JSON (rewriting JSON5 would destroy comments).
77
+ */
78
+ function disableChannelsInConfigText(content, channels) {
79
+ const config = parseStrictJsonConfig(content);
80
+ if (!isRecord(config) || !isRecord(config.channels)) {
81
+ return undefined;
82
+ }
83
+ for (const name of channels) {
84
+ const channel = config.channels[name];
85
+ if (isRecord(channel)) {
86
+ channel.enabled = false;
87
+ }
88
+ }
89
+ return `${JSON.stringify(config, null, 2)}\n`;
90
+ }
@@ -46,10 +46,12 @@ exports.GatewayService = exports.GatewaySessionBindingError = void 0;
46
46
  * - Session routing: disk-backed route index + per-session gateway-session.json
47
47
  * - Publish: user connects an existing IDE session to the gateway
48
48
  */
49
+ const crypto_1 = require("crypto");
49
50
  const os = __importStar(require("os"));
50
51
  const path = __importStar(require("path"));
51
52
  const vscode = __importStar(require("vscode"));
52
53
  const scheduledFleetStorage_js_1 = require("../analytics/dashboard/scheduledFleetStorage.js");
54
+ const branding_js_1 = require("../branding.js");
53
55
  const bridge_js_1 = require("./bridge.js");
54
56
  const gateway_scope_errors_js_1 = require("./gateway-scope-errors.js");
55
57
  const gatewayRouteIndex_js_1 = require("./gatewayRouteIndex.js");
@@ -769,7 +771,10 @@ class GatewayService {
769
771
  */
770
772
  createSessionAwareChatUIHandler(agentId, packName) {
771
773
  return async (message, sessionKey, peerInfo, emitter, runContext) => {
772
- const gatewayRequestToken = `gw-${Date.now()}-${Math.random().toString(36).slice(2)}`;
774
+ // Unguessable: this token is a bearer capability for the run's output stream, and
775
+ // `getGatewayEmitter` is a destructive first-reader-wins read. A predictable value
776
+ // lets any chat message beginning with `[gw:...]` steal or drop another run's stream.
777
+ const gatewayRequestToken = `gw-${(0, crypto_1.randomUUID)()}`;
773
778
  GatewayService.gatewayEmitters.set(gatewayRequestToken, emitter);
774
779
  const markedMessage = `[gw:${gatewayRequestToken}]${message}`;
775
780
  try {
@@ -983,7 +988,7 @@ class GatewayService {
983
988
  const tokenRaw = (gwLive.get("token") ?? "").trim() || (this.config.token ?? "").trim();
984
989
  if (!url) {
985
990
  if (!silent) {
986
- vscode.window.showWarningMessage("Gateway URL not configured. Set agentide.gateway.url in settings.");
991
+ vscode.window.showWarningMessage(`Gateway URL not configured. Set ${(0, branding_js_1.toSettingsDisplayKey)("agentide.gateway.url")} in settings.`);
987
992
  }
988
993
  return;
989
994
  }
@@ -350,7 +350,18 @@ function createHeadlessRunner(config) {
350
350
  // Capture warrant userId here (inside serve's runWithCredential continuation), not
351
351
  // later at tool emit — custom-engine IPC callbacks can lose/stick ambient ALS.
352
352
  const sessionId = options.sessionId ?? `headless-${Date.now()}`;
353
- const observableEmitter = (0, sessionEvents_1.wrapEmitterWithObservability)(baseEmitter, sessionEventWriter, sessionId, requestId, (0, credential_context_1.currentCredential)()?.userId);
353
+ const provenance = config.provenance?.();
354
+ const observableEmitter = (0, sessionEvents_1.wrapEmitterWithObservability)(baseEmitter, sessionEventWriter, sessionId, requestId, (0, credential_context_1.currentCredential)()?.userId, provenance && {
355
+ policy_version: provenance.policyVersion,
356
+ pack_digest: provenance.packDigest,
357
+ engine_version: provenance.engineVersion,
358
+ tier: provenance.tier,
359
+ // Layer 2 must be joinable to an instance ON ITS OWN. Without this the session
360
+ // lane could only be attributed by going through Layer 1 (the OTEL span), which
361
+ // defeats the point of self-stamping: with a shared collector spool, the
362
+ // shipper's INSTANCE_ID cannot know which instance actually ran a turn.
363
+ instance_id: provenance.instanceId,
364
+ });
354
365
  const requestOptions = {
355
366
  modelId: options.modelId,
356
367
  engineId: options.engineId,
@@ -367,6 +378,11 @@ function createHeadlessRunner(config) {
367
378
  useLiteLLM: config.useLiteLLM,
368
379
  agentPackId: packId,
369
380
  agentPackVersion: config.packVersion,
381
+ policyVersion: provenance?.policyVersion,
382
+ packDigest: provenance?.packDigest,
383
+ engineVersion: provenance?.engineVersion,
384
+ tier: provenance?.tier,
385
+ instanceId: provenance?.instanceId,
370
386
  permissions: options.permissions,
371
387
  requestId, // Same requestId used by ObservableStreamEmitter for Layer 1/2 correlation
372
388
  };
@@ -0,0 +1,32 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
+
5
+ const BALANCES = {
6
+ 'alice@corp.com': 4242,
7
+ 'bob@corp.com': 99,
8
+ };
9
+ const TURN = 'ai.agenthippo/turn';
10
+
11
+ const server = new Server({ name: 'e2e-demo', version: '0.0.1' }, { capabilities: { tools: {} } });
12
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
13
+ tools: [{
14
+ name: 'get_my_balance',
15
+ description: 'Return the signed-in user balance. No args.',
16
+ inputSchema: { type: 'object', properties: {} },
17
+ }],
18
+ }));
19
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
20
+ const turn = (req.params._meta ?? {})[TURN] ?? null;
21
+ const userId = typeof turn?.userId === 'string' ? turn.userId.trim() : '';
22
+ if (!userId) {
23
+ return { isError: true, content: [{ type: 'text', text: 'fail-closed: no verified user' }] };
24
+ }
25
+ const bal = BALANCES[userId];
26
+ if (bal === undefined) {
27
+ return { isError: true, content: [{ type: 'text', text: `fail-closed: no balance for ${userId}` }] };
28
+ }
29
+ console.error(`[e2e-connector] ${userId} → ${bal}`);
30
+ return { content: [{ type: 'text', text: JSON.stringify({ userId, balance: bal }) }] };
31
+ });
32
+ await server.connect(new StdioServerTransport());
package/out/mcp.js CHANGED
@@ -44,6 +44,7 @@ exports.generateCodexMcpConfig = generateCodexMcpConfig;
44
44
  exports.getDefaultMcpConfig = getDefaultMcpConfig;
45
45
  exports.loadBundledMcpServers = loadBundledMcpServers;
46
46
  exports.resolveHttpMcpHeaders = resolveHttpMcpHeaders;
47
+ exports.referenceHttpMcpHeaders = referenceHttpMcpHeaders;
47
48
  exports.formatInlineMcpServers = formatInlineMcpServers;
48
49
  exports.serializeInlineMcpServers = serializeInlineMcpServers;
49
50
  exports.parseInlineMcpServers = parseInlineMcpServers;
@@ -555,6 +556,35 @@ function resolveHttpMcpHeaders(server, turnEnv) {
555
556
  }
556
557
  return Object.keys(headers).length > 0 ? headers : undefined;
557
558
  }
559
+ /**
560
+ * The variable names the Claude CLI's `${NAME}` config expansion accepts. A `bearerTokenEnv`
561
+ * outside this shape would ship a header the CLI sends verbatim, so it falls back to in-memory
562
+ * resolution instead.
563
+ */
564
+ const CLI_EXPANDABLE_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
565
+ /**
566
+ * Claude-engine variant of {@link resolveHttpMcpHeaders}: emit a `${NAME}` REFERENCE and let the
567
+ * CLI expand it from its own environment at connect time, instead of resolving the secret here.
568
+ *
569
+ * The Agent SDK serializes the whole mcpServers object into `--mcp-config <json>` argv, and
570
+ * /proc/<pid>/cmdline is world-readable — no ptrace gate, unlike environ — so a resolved
571
+ * Authorization header there is visible to every uid on the host and to execve-argv audit
572
+ * tooling. The reference keeps the turn nonce at environ-level exposure, which the engine child
573
+ * already carries for the egress CLI. Expansion verified live on CLI 2.1.220, including the
574
+ * failure mode this relies on: an UNSET name passes through literally and the data gateway
575
+ * rejects it — fail closed, never fail open.
576
+ */
577
+ function referenceHttpMcpHeaders(server, turnEnv) {
578
+ const name = server.bearerTokenEnv;
579
+ if (name && !CLI_EXPANDABLE_ENV_NAME.test(name)) {
580
+ return resolveHttpMcpHeaders(server, turnEnv);
581
+ }
582
+ const headers = { ...(server.headers ?? {}) };
583
+ if (name) {
584
+ headers['Authorization'] = `Bearer \${${name}}`;
585
+ }
586
+ return Object.keys(headers).length > 0 ? headers : undefined;
587
+ }
558
588
  /**
559
589
  * Convert resolved pack MCP servers to the inline record shape engines consume.
560
590
  *
@@ -0,0 +1,271 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ *--------------------------------------------------------------------------------------------*/
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.CapMeter = exports.CAP_ENV_ALIASES = exports.parseCapPolicy = exports.STRICTEST_ON_BREACH = exports.ON_BREACH_ALIASES = exports.ON_BREACH_ACTIONS = exports.LEASE_CAP_FIELDS = exports.LEASE_CAP_ALIASES = exports.DEPRECATED_CAP_SPELLINGS = void 0;
7
+ exports.resolveOnBreach = resolveOnBreach;
8
+ exports.mergeCaps = mergeCaps;
9
+ exports.capsAreUnbounded = capsAreUnbounded;
10
+ exports.capsFromEnv = capsFromEnv;
11
+ /**
12
+ * Caps and the live kill switch — the enforcement half of the control plane (serve-v4-clean §5).
13
+ *
14
+ * `assertAdmitted` already answers "may this artifact run here, now?" at sandbox CREATION. That is
15
+ * necessary and not sufficient for an enterprise control plane, which also needs:
16
+ *
17
+ * caps — a bound on what one session may consume, enforced by serve rather than requested of
18
+ * the agent. A cap the agent could talk its way past is not a cap.
19
+ * kill — "stop now", not "stop admitting". Lease revocation alone leaves running sandboxes alive
20
+ * until their TTL (up to 30 minutes); an operator hitting the emergency stop means the
21
+ * work ends, so the lease watcher below destroys live sandboxes too.
22
+ *
23
+ * Precedence is deliberate: an operator's local limit may only ever be *tighter* than the
24
+ * control plane's. A self-hosted deployment can impose more restraint on itself, never less —
25
+ * so a compromised or misconfigured instance cannot raise its own ceiling by editing a flag.
26
+ */
27
+ const lease_schema_1 = require("./lease-schema");
28
+ /**
29
+ * Re-exported so callers outside pack/ have ONE import site for the cap vocabulary.
30
+ *
31
+ * The tables themselves live in `lease-schema.ts`, next to the lease validator that consumes them,
32
+ * because the whole point is that a lease field, an env var and a CLI flag are held to the same
33
+ * vocabulary. Adding an alias in one place changes all three.
34
+ */
35
+ var lease_schema_2 = require("./lease-schema");
36
+ Object.defineProperty(exports, "DEPRECATED_CAP_SPELLINGS", { enumerable: true, get: function () { return lease_schema_2.DEPRECATED_CAP_SPELLINGS; } });
37
+ Object.defineProperty(exports, "LEASE_CAP_ALIASES", { enumerable: true, get: function () { return lease_schema_2.LEASE_CAP_ALIASES; } });
38
+ Object.defineProperty(exports, "LEASE_CAP_FIELDS", { enumerable: true, get: function () { return lease_schema_2.LEASE_CAP_FIELDS; } });
39
+ Object.defineProperty(exports, "ON_BREACH_ACTIONS", { enumerable: true, get: function () { return lease_schema_2.ON_BREACH_ACTIONS; } });
40
+ Object.defineProperty(exports, "ON_BREACH_ALIASES", { enumerable: true, get: function () { return lease_schema_2.ON_BREACH_ALIASES; } });
41
+ Object.defineProperty(exports, "STRICTEST_ON_BREACH", { enumerable: true, get: function () { return lease_schema_2.STRICTEST_ON_BREACH; } });
42
+ Object.defineProperty(exports, "parseCapPolicy", { enumerable: true, get: function () { return lease_schema_2.parseCapPolicy; } });
43
+ /** Env var aliases, old spelling → current spelling. */
44
+ exports.CAP_ENV_ALIASES = {
45
+ AGENTHIPPO_MAX_CONCURRENT_CELLS: 'AGENTHIPPO_MAX_CONCURRENT_SANDBOXES',
46
+ };
47
+ /**
48
+ * Resolve a breach action from ANY untrusted source (env, CLI flag, lease field).
49
+ *
50
+ * The governing rule, and the reason this is one function rather than three:
51
+ * **an unrecognised action fails closed to the STRICTER action, loudly. It never softens.**
52
+ *
53
+ * It used to soften, in two places at once. `capsFromEnv` mapped an unknown value to `undefined`,
54
+ * and `cli/index.ts` did the same for `--cap-on-breach`; `mergeCaps` then found no
55
+ * `'destroy-sandbox'` on either side and `governor.enforce` defaulted to `revoke-inference`. So a
56
+ * typo — or the perfectly reasonable pre-rename value `destroy-cell` — quietly downgraded
57
+ * enforcement from "end the session" to "take away its model route", while the console showed the
58
+ * stricter setting the operator had typed.
59
+ *
60
+ * @param source what to name in the warning, e.g. `AGENTHIPPO_CAP_ON_BREACH` or `--cap-on-breach`.
61
+ */
62
+ function resolveOnBreach(raw, source) {
63
+ const value = raw?.trim();
64
+ if (!value) {
65
+ return { action: undefined, warnings: [] };
66
+ }
67
+ if (value in lease_schema_1.ON_BREACH_ACTIONS) {
68
+ return { action: value, warnings: [] };
69
+ }
70
+ const aliased = lease_schema_1.ON_BREACH_ALIASES[value];
71
+ if (aliased) {
72
+ return {
73
+ action: aliased,
74
+ warnings: [
75
+ `${source}="${value}" is DEPRECATED; use "${lease_schema_1.DEPRECATED_CAP_SPELLINGS[value] ?? aliased}". ` +
76
+ `Treating it as "${aliased}". The old spelling will stop being accepted.`,
77
+ ],
78
+ };
79
+ }
80
+ return {
81
+ action: lease_schema_1.STRICTEST_ON_BREACH,
82
+ warnings: [
83
+ `${source}="${value}" is not a breach action this build understands. ` +
84
+ `FAILING CLOSED to "${lease_schema_1.STRICTEST_ON_BREACH}" — an unreadable enforcement setting must never ` +
85
+ `weaken enforcement. Known actions: ${Object.keys(lease_schema_1.ON_BREACH_ACTIONS).join(', ')}.`,
86
+ ],
87
+ };
88
+ }
89
+ /** Ceiling on metered sessions, so an unbounded instance cannot grow meters without limit. */
90
+ const MAX_METERED_SESSIONS = 10_000;
91
+ /**
92
+ * Tighten-only merge. `local` may lower any bound the control plane set, never raise it, and may
93
+ * only escalate the breach action (revoke → destroy), never soften it.
94
+ */
95
+ function mergeCaps(controlPlane, local) {
96
+ const cp = controlPlane ?? {};
97
+ const lo = local ?? {};
98
+ const tighter = (a, b) => {
99
+ if (a === undefined) {
100
+ return b;
101
+ }
102
+ if (b === undefined) {
103
+ return a;
104
+ }
105
+ return Math.min(a, b);
106
+ };
107
+ return {
108
+ maxTurnsPerSession: tighter(cp.maxTurnsPerSession, lo.maxTurnsPerSession),
109
+ maxSessionSeconds: tighter(cp.maxSessionSeconds, lo.maxSessionSeconds),
110
+ maxConcurrentSandboxes: tighter(cp.maxConcurrentSandboxes, lo.maxConcurrentSandboxes),
111
+ // destroy is stricter than revoke; either side may escalate, neither may soften.
112
+ onBreach: cp.onBreach === 'destroy-sandbox' || lo.onBreach === 'destroy-sandbox'
113
+ ? 'destroy-sandbox'
114
+ : (cp.onBreach ?? lo.onBreach),
115
+ };
116
+ }
117
+ /** True when no bound is set at all — lets callers skip the machinery entirely. */
118
+ function capsAreUnbounded(caps) {
119
+ return caps.maxTurnsPerSession === undefined
120
+ && caps.maxSessionSeconds === undefined
121
+ && caps.maxConcurrentSandboxes === undefined;
122
+ }
123
+ /**
124
+ * Per-session meter. Checked BEFORE a turn runs: a cap discovered afterwards has already let the
125
+ * spend happen, so `check` is the gate and `record` only advances the counters.
126
+ */
127
+ class CapMeter {
128
+ caps;
129
+ sessions = new Map();
130
+ constructor(caps) {
131
+ this.caps = caps;
132
+ }
133
+ /**
134
+ * Adopt caps the control plane pushed since boot.
135
+ *
136
+ * Caps used to be read from the lease exactly ONCE, at construction, so tightening a cap in
137
+ * the control plane did nothing until every instance was restarted — the fleet reported the
138
+ * new policy while enforcing the old one. Live-caught by the control-plane acceptance test.
139
+ * Existing per-session counters are deliberately kept: a lowered cap must bite immediately on
140
+ * sessions already over it, not hand them a fresh allowance.
141
+ */
142
+ setCaps(caps) {
143
+ this.caps = caps;
144
+ }
145
+ /** Current policy in force — surfaced on the admin/health endpoint. */
146
+ policy() {
147
+ return this.caps;
148
+ }
149
+ /**
150
+ * @param liveSandboxes Current live sandbox count — concurrency is an instance-wide bound, so it is
151
+ * supplied by the caller rather than tracked here.
152
+ */
153
+ check(sessionKey, liveSandboxes, nowMs = Date.now()) {
154
+ const usage = this.sessions.get(sessionKey);
155
+ if (this.caps.maxTurnsPerSession !== undefined && usage && usage.turns >= this.caps.maxTurnsPerSession) {
156
+ return {
157
+ allowed: false,
158
+ cap: 'turns',
159
+ reason: `session turn cap reached (${usage.turns}/${this.caps.maxTurnsPerSession})`,
160
+ };
161
+ }
162
+ if (this.caps.maxSessionSeconds !== undefined && usage) {
163
+ const elapsed = Math.floor((nowMs - usage.startedAtMs) / 1000);
164
+ if (elapsed >= this.caps.maxSessionSeconds) {
165
+ return {
166
+ allowed: false,
167
+ cap: 'duration',
168
+ reason: `session duration cap reached (${elapsed}s/${this.caps.maxSessionSeconds}s)`,
169
+ };
170
+ }
171
+ }
172
+ // Concurrency applies only to sessions that would need a NEW sandbox; an established session
173
+ // reuses its own, so counting it against the cap would strand it once the fleet filled up.
174
+ if (this.caps.maxConcurrentSandboxes !== undefined && !usage && liveSandboxes >= this.caps.maxConcurrentSandboxes) {
175
+ return {
176
+ allowed: false,
177
+ cap: 'concurrency',
178
+ reason: `instance is at its concurrent sandbox cap (${liveSandboxes}/${this.caps.maxConcurrentSandboxes})`,
179
+ };
180
+ }
181
+ return { allowed: true };
182
+ }
183
+ /**
184
+ * Advance the counters for a turn that actually ran.
185
+ *
186
+ * Metering happens even when caps are currently unbounded, and that is the point: caps can be
187
+ * tightened from the control plane mid-flight, and a session that ran fifty turns under an
188
+ * unbounded policy must not then receive a fresh allowance the moment a cap appears. Counting
189
+ * only while a cap happens to be set is precisely the kind of gap an agent can be steered into.
190
+ */
191
+ record(sessionKey, nowMs = Date.now()) {
192
+ const existing = this.sessions.get(sessionKey);
193
+ if (existing) {
194
+ existing.turns += 1;
195
+ return;
196
+ }
197
+ // Bounded so an unbounded, long-lived instance cannot accumulate meters forever; the
198
+ // oldest session is the one least likely to still be running.
199
+ if (this.sessions.size >= MAX_METERED_SESSIONS) {
200
+ let oldestKey;
201
+ let oldestAt = Infinity;
202
+ for (const [key, usage] of this.sessions) {
203
+ if (usage.startedAtMs < oldestAt) {
204
+ oldestAt = usage.startedAtMs;
205
+ oldestKey = key;
206
+ }
207
+ }
208
+ if (oldestKey) {
209
+ this.sessions.delete(oldestKey);
210
+ }
211
+ }
212
+ this.sessions.set(sessionKey, { turns: 1, startedAtMs: nowMs });
213
+ }
214
+ /** Drop a session's meter — called when its sandbox is destroyed so a fresh session starts clean. */
215
+ forget(sessionKey) {
216
+ this.sessions.delete(sessionKey);
217
+ }
218
+ usage(sessionKey) {
219
+ const u = this.sessions.get(sessionKey);
220
+ if (!u) {
221
+ return undefined;
222
+ }
223
+ return { turns: u.turns, ageSeconds: Math.floor((Date.now() - u.startedAtMs) / 1000) };
224
+ }
225
+ }
226
+ exports.CapMeter = CapMeter;
227
+ /**
228
+ * Parse `AGENTHIPPO_MAX_*` style env, ignoring blanks and non-numbers rather than guessing.
229
+ *
230
+ * LOCAL config, so the rules differ from a lease deliberately: a typo in an operator's own env is
231
+ * dropped (they can see and fix it, and dropping it only affects their own instance), whereas the
232
+ * same typo arriving from a control plane means the fleet believes a cap is in force that is not.
233
+ * The `onBreach` vocabulary, however, comes from the same table `parseCapPolicy` uses, so a value
234
+ * that is valid in a lease is valid here and vice versa — including any back-compat aliases layered
235
+ * into `ON_BREACH_ALIASES`.
236
+ */
237
+ function capsFromEnv(env = process.env, warn = (m) => console.warn(`Warning: ${m}`)) {
238
+ const num = (raw) => {
239
+ const v = parseInt((raw ?? '').trim(), 10);
240
+ return Number.isFinite(v) && v > 0 ? v : undefined;
241
+ };
242
+ /** Current spelling wins; a deprecated one is honoured once, loudly. */
243
+ const numWithAlias = (current) => {
244
+ const direct = num(env[current]);
245
+ if (direct !== undefined) {
246
+ return direct;
247
+ }
248
+ for (const [old, replacement] of Object.entries(exports.CAP_ENV_ALIASES)) {
249
+ if (replacement !== current) {
250
+ continue;
251
+ }
252
+ const legacy = num(env[old]);
253
+ if (legacy === undefined) {
254
+ continue;
255
+ }
256
+ warn(`${old} is DEPRECATED; use ${current}. Honouring it for now.`);
257
+ return legacy;
258
+ }
259
+ return undefined;
260
+ };
261
+ const breach = resolveOnBreach(env.AGENTHIPPO_CAP_ON_BREACH, 'AGENTHIPPO_CAP_ON_BREACH');
262
+ for (const w of breach.warnings) {
263
+ warn(w);
264
+ }
265
+ return {
266
+ maxTurnsPerSession: numWithAlias('AGENTHIPPO_MAX_TURNS_PER_SESSION'),
267
+ maxSessionSeconds: numWithAlias('AGENTHIPPO_MAX_SESSION_SECONDS'),
268
+ maxConcurrentSandboxes: numWithAlias('AGENTHIPPO_MAX_CONCURRENT_SANDBOXES'),
269
+ onBreach: breach.action,
270
+ };
271
+ }