@agenthippo-ai/agenthippo 0.1.1 → 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 (158) hide show
  1. package/out/agentpack/index.js +2 -1
  2. package/out/agentpack/paths.js +23 -0
  3. package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
  4. package/out/analytics/dashboard/provider.js +13 -2
  5. package/out/analytics/dashboard/webview/panel.js +293 -82
  6. package/out/analytics/identity.js +0 -0
  7. package/out/analytics/service.js +23 -25
  8. package/out/analytics/sessionEvents.js +109 -22
  9. package/out/auth/authBaseUrl.js +59 -0
  10. package/out/auth/bootstrapApply.js +9 -10
  11. package/out/auth/brokerClient.js +49 -15
  12. package/out/auth/onboardingPrompt.js +112 -0
  13. package/out/auth/provider.js +9 -22
  14. package/out/auth/sessionStore.js +3 -40
  15. package/out/auth/signInFlow.js +2 -1
  16. package/out/auth-broker/config.js +54 -4
  17. package/out/auth-broker/server.js +152 -20
  18. package/out/auth-broker/store.js +25 -24
  19. package/out/authority/warrant.js +206 -0
  20. package/out/branding.js +27 -1
  21. package/out/cli/commands/ask.js +3 -0
  22. package/out/cli/commands/chat.js +3 -0
  23. package/out/cli/commands/conformance.js +249 -0
  24. package/out/cli/commands/data-plane.js +67 -0
  25. package/out/cli/commands/deploy.js +50 -10
  26. package/out/cli/commands/gateway.js +58 -0
  27. package/out/cli/commands/idp.js +85 -0
  28. package/out/cli/commands/pack.js +182 -3
  29. package/out/cli/commands/sandbox.js +332 -0
  30. package/out/cli/commands/serve.js +1165 -82
  31. package/out/cli/commands/store.js +12 -4
  32. package/out/cli/credential-context.js +42 -80
  33. package/out/cli/data-plane/broker.js +78 -0
  34. package/out/cli/data-plane/connector-host.js +121 -0
  35. package/out/cli/data-plane/egress.js +189 -0
  36. package/out/cli/data-plane/gateway.js +218 -0
  37. package/out/cli/data-plane/index.js +152 -0
  38. package/out/cli/data-plane/registry.js +89 -0
  39. package/out/cli/data-plane/remote.js +106 -0
  40. package/out/cli/data-plane/sidecar.js +161 -0
  41. package/out/cli/deploy/blueprints/registry.js +84 -0
  42. package/out/cli/deploy/layers/identity.js +172 -0
  43. package/out/cli/deploy-init.js +1182 -347
  44. package/out/cli/deps.js +106 -2
  45. package/out/cli/idp-init.js +338 -0
  46. package/out/cli/index.js +192 -23
  47. package/out/cli/pack-install-ref.js +15 -0
  48. package/out/cli/pack-loader.js +45 -3
  49. package/out/cli/pack-runtime.js +61 -0
  50. package/out/cli/runtime.js +44 -3
  51. package/out/cli/serve-hardening.js +139 -3
  52. package/out/cli/state-sync.js +16 -15
  53. package/out/cli/utils.js +12 -1
  54. package/out/engines/base.js +32 -3
  55. package/out/engines/claude.js +47 -8
  56. package/out/engines/codex.js +125 -22
  57. package/out/engines/custom.js +54 -12
  58. package/out/evidence/sessionShip.js +610 -0
  59. package/out/extension.js +13 -2
  60. package/out/gateway/bridge.js +40 -2
  61. package/out/gateway/client.js +34 -1
  62. package/out/gateway/installSource.js +68 -0
  63. package/out/gateway/lifecycle.js +185 -51
  64. package/out/gateway/outboundPaths.js +4 -1
  65. package/out/gateway/outboundSend.js +6 -6
  66. package/out/gateway/secretsPreflight.js +90 -0
  67. package/out/gateway/service.js +7 -2
  68. package/out/headless/index.js +40 -9
  69. package/out/live-e2e/mcp-demo/server.mjs +32 -0
  70. package/out/live-probe/connector.mjs +8 -0
  71. package/out/live-probe/env-report.mjs +8 -0
  72. package/out/mcp.js +115 -67
  73. package/out/pack/caps.js +271 -0
  74. package/out/pack/governor.js +309 -0
  75. package/out/pack/lease-schema.js +270 -0
  76. package/out/pack/lease.js +640 -0
  77. package/out/pack/reconcile.js +217 -0
  78. package/out/pack/signature.js +839 -0
  79. package/out/remote/handleRemoteChat.js +44 -3
  80. package/out/remote/remoteTrust.js +277 -0
  81. package/out/remote/serveClient.js +52 -5
  82. package/out/runner/host.js +131 -0
  83. package/out/runner/protocol.js +108 -0
  84. package/out/runner/remoteEngine.js +130 -0
  85. package/out/runtime/interfaces/edit-tracker.js +2 -0
  86. package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
  87. package/out/sandbox/containerDriver.js +570 -0
  88. package/out/sandbox/processDriver.js +315 -0
  89. package/out/sandbox/sandboxRunner.js +191 -0
  90. package/out/sandbox/support.js +301 -0
  91. package/out/sandbox/types.js +55 -0
  92. package/out/scripts/agentide-litellm.sh +67 -5
  93. package/out/scripts/agentide_litellm_hooks_v1.py +182 -21
  94. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +94 -118
  95. package/out/scripts/analytics/setup-analytics.ps1 +37 -13
  96. package/out/scripts/analytics/setup-analytics.sh +93 -13
  97. package/out/scripts/analytics/start-analytics.sh +86 -14
  98. package/out/scripts/gateway/setup-gateway.sh +72 -32
  99. package/out/shared/models.js +12 -111
  100. package/out/shared/providers.js +2 -2
  101. package/out/spotlight/install.js +7 -1
  102. package/out/store/index.js +41 -10
  103. package/out/sync.js +3 -1
  104. package/out/test/analytics.test.js +198 -34
  105. package/out/test/auth-baseurl.test.js +132 -0
  106. package/out/test/auth-bootstrapApply.test.js +27 -6
  107. package/out/test/auth-broker-hardening.test.js +227 -0
  108. package/out/test/auth-broker.test.js +112 -9
  109. package/out/test/blueprint-registry.test.js +105 -0
  110. package/out/test/branding.test.js +13 -0
  111. package/out/test/caps-governor.test.js +455 -0
  112. package/out/test/codex-new-file-survival.test.js +119 -0
  113. package/out/test/configuration-scopes.test.js +131 -0
  114. package/out/test/credential-context.test.js +23 -28
  115. package/out/test/custom-env-layering.test.js +151 -0
  116. package/out/test/data-plane-engine-wiring.test.js +250 -0
  117. package/out/test/data-plane.test.js +311 -0
  118. package/out/test/deploy-init.test.js +295 -4
  119. package/out/test/egress-broker.test.js +172 -0
  120. package/out/test/fleetDisplayUtils.test.js +4 -0
  121. package/out/test/gateway-client.test.js +28 -0
  122. package/out/test/gateway-install-source.test.js +111 -0
  123. package/out/test/gateway-lifecycle.test.js +1 -1
  124. package/out/test/gateway-outbound.test.js +7 -0
  125. package/out/test/gateway-secrets-preflight.test.js +127 -0
  126. package/out/test/gateway-session-routing.test.js +18 -0
  127. package/out/test/governor-hardening.test.js +217 -0
  128. package/out/test/headless-sync.test.js +22 -0
  129. package/out/test/idp-init.test.js +151 -0
  130. package/out/test/lease-signature.test.js +357 -0
  131. package/out/test/lease-trust-root.test.js +273 -0
  132. package/out/test/lease-validation.test.js +169 -0
  133. package/out/test/lease.test.js +114 -0
  134. package/out/test/mcp-obo.test.js +52 -27
  135. package/out/test/mcp.test.js +69 -3
  136. package/out/test/model-routing.test.js +133 -0
  137. package/out/test/pack-admission.test.js +216 -0
  138. package/out/test/pack-digest-parity.test.js +140 -0
  139. package/out/test/pack-reconcile.test.js +183 -0
  140. package/out/test/pack-signature-artifact.test.js +103 -0
  141. package/out/test/pack-signature.test.js +176 -0
  142. package/out/test/pack-trust-root.test.js +202 -0
  143. package/out/test/remote-trust.test.js +242 -0
  144. package/out/test/runner-protocol.test.js +249 -0
  145. package/out/test/sandbox-driver.test.js +553 -0
  146. package/out/test/sandbox-hardening.test.js +395 -0
  147. package/out/test/sandbox-runner.test.js +343 -0
  148. package/out/test/serve-gateway-turn.test.js +267 -0
  149. package/out/test/serve-hardening.test.js +125 -6
  150. package/out/test/serveClient.test.js +38 -5
  151. package/out/test/sessionShip.test.js +560 -0
  152. package/out/test/spotlightPublish.test.js +28 -0
  153. package/out/test/state-sync-peer.test.js +18 -5
  154. package/out/test/store-install-safe.test.js +69 -0
  155. package/out/test/tls-bypass.test.js +90 -0
  156. package/out/test/warrant.test.js +138 -0
  157. package/out/utils.js +73 -20
  158. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,309 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ *--------------------------------------------------------------------------------------------*/
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.effectiveDenyMode = effectiveDenyMode;
7
+ exports.createGovernor = createGovernor;
8
+ exports.withGovernor = withGovernor;
9
+ const caps_1 = require("./caps");
10
+ const lease_1 = require("./lease");
11
+ /**
12
+ * What a deny mode MEANS on this deployment, as opposed to what it was written as.
13
+ *
14
+ * Without a sandbox driver there is no non-cooperative way to stop a turn already executing in
15
+ * this process, so `kill` degrades to `drain`. The degradation is real and unavoidable at this
16
+ * layer; what is not acceptable is reporting `kill` while doing `drain`.
17
+ */
18
+ function effectiveDenyMode(mode, supportsInFlightTermination) {
19
+ if (mode === 'kill' && !supportsInFlightTermination) {
20
+ return 'drain';
21
+ }
22
+ return mode;
23
+ }
24
+ function createGovernor(opts) {
25
+ const meter = new caps_1.CapMeter(opts.caps);
26
+ // Not const: every lease poll re-merges the control plane's caps over the local ones.
27
+ let unbounded = (0, caps_1.capsAreUnbounded)(opts.caps);
28
+ let timer;
29
+ /** Latches once the lease kills us: no further turn may sneak through between polls. */
30
+ let killed;
31
+ /** Last lease seen, so per-session deny checks need no fetch on the turn path. */
32
+ let currentLease;
33
+ /**
34
+ * sessionKey → the verified user it belongs to, learned from `admitTurn`.
35
+ *
36
+ * Only the turn path knows the resolved identity, but the lease WATCHER is what ends running
37
+ * work — so without remembering it, a `user`-scoped kill could refuse the next turn and still
38
+ * leave the user's live sandbox running.
39
+ */
40
+ const sessionUsers = new Map();
41
+ /** True unless the deployment has no way to end a running turn — see GovernableSandboxes. */
42
+ const canTerminateInFlight = opts.sandboxes.supportsInFlightTermination !== false;
43
+ /** Session rules extended to the users behind them, so id rotation does not escape. */
44
+ let followedUsers = new Map();
45
+ /** Kill rules already reported as degraded, so the warning is loud once, not every poll. */
46
+ const degradedKillsReported = new Set();
47
+ const audit = (event, fields) => {
48
+ try {
49
+ opts.audit?.(event, fields);
50
+ }
51
+ catch { /* evidence must never break enforcement */ }
52
+ };
53
+ /**
54
+ * Bounded on purpose: a long-lived serve sees unbounded distinct session keys (every anonymous
55
+ * gateway sender gets a fresh uuid), and an unbounded map here would be a slow leak in the one
56
+ * component that must never be the reason an instance falls over. Oldest-first eviction costs
57
+ * only the ability to kill an already-idle session by user scope.
58
+ */
59
+ const MAX_TRACKED_SESSION_USERS = 10_000;
60
+ const rememberSessionUser = (sessionKey, userId) => {
61
+ if (sessionUsers.size >= MAX_TRACKED_SESSION_USERS && !sessionUsers.has(sessionKey)) {
62
+ const oldest = sessionUsers.keys().next();
63
+ if (!oldest.done) {
64
+ sessionUsers.delete(oldest.value);
65
+ }
66
+ }
67
+ sessionUsers.set(sessionKey, userId);
68
+ };
69
+ const enforce = async (sessionKey, decision) => {
70
+ // The MERGED policy, not the local one: a control plane that escalates revoke → destroy
71
+ // must be obeyed on the next breach, not on the next restart.
72
+ const action = meter.policy().onBreach ?? 'revoke-inference';
73
+ try {
74
+ if (action === 'destroy-sandbox') {
75
+ // The SANDBOX is destroyed; the session's meter deliberately survives it.
76
+ // `meter.forget` here reset turns and the duration clock, so a breached session got
77
+ // a brand-new budget and `maxTurnsPerSession: 10` degraded into "one refused turn
78
+ // every ten, forever" — a cap that renews on breach is not a cap.
79
+ await opts.sandboxes.destroySession(sessionKey);
80
+ }
81
+ else {
82
+ // Narrower: the session keeps its sandbox (and its state, for inspection) but loses
83
+ // the model route, so it cannot spend another token.
84
+ await opts.sandboxes.revokeRoute(sessionKey, 'inference');
85
+ }
86
+ }
87
+ catch (err) {
88
+ // A tier-0 sandbox cannot revoke a single route; falling back to destroy keeps the cap
89
+ // honest instead of silently letting the session continue.
90
+ opts.logger?.warn(`[Governor] ${action} failed for ${sessionKey}, destroying instead: ${err instanceof Error ? err.message : String(err)}`);
91
+ await opts.sandboxes.destroySession(sessionKey).catch(() => undefined);
92
+ // Same rule as the deliberate destroy above: the meter outlives the sandbox, or the
93
+ // fallback path quietly hands back the budget the primary path refused.
94
+ }
95
+ opts.logger?.warn(`[Governor] cap breach on ${sessionKey}: ${decision.reason} — ${action}`);
96
+ audit('cap_breach', { sessionKey, cap: decision.cap, reason: decision.reason, action });
97
+ };
98
+ const killAll = async (reason) => {
99
+ const live = opts.sandboxes.sandboxes();
100
+ for (const c of live) {
101
+ await opts.sandboxes.destroySession(c.sessionKey).catch(() => undefined);
102
+ // The meter deliberately SURVIVES the kill, for exactly the reason `enforce` gives when
103
+ // it destroys a sandbox on a cap breach: a cap that renews when the sandbox goes away is
104
+ // not a cap. `meter.forget` here meant a kill-then-restore (a billing hold lifted, a
105
+ // drill, an operator toggling the switch) handed every session a brand-new turn and
106
+ // duration budget — so the cheapest way past a cap was to trip the kill switch. The two
107
+ // paths now agree: destroying a sandbox never returns budget.
108
+ }
109
+ if (live.length > 0) {
110
+ opts.logger?.warn(`[Governor] kill switch: destroyed ${live.length} live sandbox(s) — ${reason}`);
111
+ }
112
+ else if (!canTerminateInFlight) {
113
+ // Without a driver there is nothing to destroy, and the kill switch reduces to refusing
114
+ // the next turn. Say so rather than logging a successful stop of zero sandboxes.
115
+ opts.logger?.warn(`[Governor] kill switch: no sandbox driver, so nothing running can be stopped — ` +
116
+ `in-flight turns will finish and only NEW turns are refused (${reason}). Run with --sandbox for true in-flight kill.`);
117
+ }
118
+ audit('kill_switch', { reason, destroyed: live.length, inFlightTerminated: canTerminateInFlight });
119
+ return live.length;
120
+ };
121
+ const checkLease = async () => {
122
+ if (!opts.getLease) {
123
+ return;
124
+ }
125
+ try {
126
+ const lease = await opts.getLease();
127
+ // Adopt caps the control plane has pushed since boot. Same tighten-only merge as at
128
+ // construction, re-applied every poll: an operator who lowers a cap in the console
129
+ // must not have to restart the fleet for it to bite. Without this the instance
130
+ // reported the new policy while enforcing the boot-time one.
131
+ const merged = (0, caps_1.mergeCaps)(lease?.caps, opts.caps);
132
+ meter.setCaps(merged);
133
+ unbounded = (0, caps_1.capsAreUnbounded)(merged);
134
+ // Cache the deny rules so `admitTurn` stays synchronous and cheap: enforcement latency
135
+ // is the poll interval, exactly like the kill switch, rather than a fetch per turn.
136
+ currentLease = lease;
137
+ // Recomputed each poll from what the turn path has learned, so a session stop reaches the
138
+ // user's other sessions even after they rotate `session_id`.
139
+ followedUsers = (0, lease_1.resolveFollowedUsers)(lease, sessionUsers);
140
+ // A `kill` rule this deployment cannot honour must not be reported as honoured. Warn
141
+ // once per rule, before sweeping, so an operator learns immediately that the rule they
142
+ // just published means `drain` here.
143
+ if (!canTerminateInFlight) {
144
+ for (const rule of lease?.deny ?? []) {
145
+ if (rule.mode !== 'kill') {
146
+ continue;
147
+ }
148
+ const id = `${rule.scope}:${rule.match}`;
149
+ if (degradedKillsReported.has(id)) {
150
+ continue;
151
+ }
152
+ degradedKillsReported.add(id);
153
+ opts.logger?.warn(`[Governor] deny(kill) on ${id} DEGRADED TO DRAIN: this deployment has no sandbox driver, so a turn ` +
154
+ 'already executing cannot be stopped. It will run to completion; only the next turn is refused. ' +
155
+ 'Run serve with --sandbox for a kill that ends work mid-flight.');
156
+ audit('deny_kill_degraded', { scope: rule.scope, match: rule.match, effectiveMode: 'drain' });
157
+ }
158
+ }
159
+ // `kill` rules end work already running. `drain` deliberately does not — it lets the
160
+ // in-flight turn finish and only refuses the next one.
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);
174
+ // The meter survives, same rule as `killAll` and `enforce`: a stop order must not
175
+ // 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 });
178
+ }
179
+ }
180
+ const verdict = (0, lease_1.leaseIsDead)(lease);
181
+ if (verdict.dead) {
182
+ killed = verdict.reason ?? 'lease revoked';
183
+ await killAll(killed);
184
+ }
185
+ else if (killed) {
186
+ // A restored lease lifts the latch — an operator can turn the fleet back on
187
+ // without restarting every instance.
188
+ opts.logger?.info('[Governor] lease restored — accepting turns again');
189
+ audit('kill_switch_lifted', {});
190
+ killed = undefined;
191
+ }
192
+ }
193
+ catch (err) {
194
+ // Fail-open on transport, exactly like the lease source: a control-plane outage must
195
+ // not take a customer's fleet down.
196
+ opts.logger?.warn(`[Governor] lease check failed: ${err instanceof Error ? err.message : String(err)}`);
197
+ }
198
+ };
199
+ return {
200
+ async admitTurn(sessionKey, userId) {
201
+ if (killed) {
202
+ return { allowed: false, reason: `stopped by the control plane: ${killed}` };
203
+ }
204
+ if (userId) {
205
+ rememberSessionUser(sessionKey, userId);
206
+ }
207
+ // Scoped stop orders refuse NEW work in both modes; the difference between drain and
208
+ // kill is only what happened to the turn already running, decided at the last poll.
209
+ const denied = (0, lease_1.denyVerdict)(currentLease, {
210
+ sessionKey,
211
+ pack: opts.pack ?? '',
212
+ userHash: opts.userHash,
213
+ userId: userId ?? sessionUsers.get(sessionKey),
214
+ // A session stop follows the user, so sending a fresh `session_id` does not escape it.
215
+ // Recomputed here as well as on the poll because the identity behind THIS session may
216
+ // only just have become known — waiting a poll would admit exactly the turn we are
217
+ // trying to refuse.
218
+ followedUsers: (0, lease_1.resolveFollowedUsers)(currentLease, sessionUsers),
219
+ });
220
+ if (denied.denied) {
221
+ return { allowed: false, reason: denied.reason };
222
+ }
223
+ // What is running, not just whether it may run: signature + pin, for the no-sandbox
224
+ // path where nothing else asks. Before the caps check, because an unsigned or
225
+ // unpinned pack must be refused whether or not the session has budget left.
226
+ if (opts.admitPack) {
227
+ // The lease the poll last saw — the SAME view the kill and deny checks just used,
228
+ // and no fetch on the turn path.
229
+ const packVerdict = opts.admitPack(sessionKey, currentLease);
230
+ if (!packVerdict.allowed) {
231
+ return { allowed: false, reason: packVerdict.reason ?? 'refused by pack admission' };
232
+ }
233
+ }
234
+ if (unbounded) {
235
+ return { allowed: true };
236
+ }
237
+ const decision = meter.check(sessionKey, opts.sandboxes.sandboxes().length);
238
+ if (!decision.allowed) {
239
+ // Concurrency is a "come back later" refusal about the INSTANCE — there is no
240
+ // offending session to punish, and revoking someone else's route would be wrong.
241
+ if (decision.cap !== 'concurrency') {
242
+ await enforce(sessionKey, decision);
243
+ }
244
+ }
245
+ return decision;
246
+ },
247
+ recordTurn(sessionKey) {
248
+ // Always meter, even while unbounded: caps can be tightened mid-flight from the
249
+ // control plane, and a session must not get a clean slate the moment one appears.
250
+ meter.record(sessionKey);
251
+ },
252
+ killAll,
253
+ refresh: checkLease,
254
+ start() {
255
+ if (!opts.getLease || timer) {
256
+ return;
257
+ }
258
+ // One constant for the poll interval, shared with the lease source's cache — see
259
+ // DEFAULT_LEASE_POLL_SECONDS. These two used to disagree (30 vs 60) and the banner
260
+ // printed the smaller one.
261
+ const everyMs = (0, lease_1.resolveLeasePollSeconds)(opts.pollSeconds) * 1000;
262
+ timer = setInterval(() => { void checkLease(); }, everyMs);
263
+ timer.unref?.();
264
+ void checkLease();
265
+ },
266
+ stop() {
267
+ if (timer) {
268
+ clearInterval(timer);
269
+ timer = undefined;
270
+ }
271
+ },
272
+ // The caps ACTUALLY in force, which after a poll may be tighter than the boot-time ones.
273
+ policy: () => meter.policy(),
274
+ };
275
+ }
276
+ /**
277
+ * Put the governor in front of a runner, so every turn passes the gate no matter which endpoint
278
+ * it arrived on. serve has three call sites into `runner.run`; decorating the runner covers all
279
+ * of them and cannot be forgotten when a fourth is added — the same relocation trick
280
+ * `createSandboxRunner` uses one layer down.
281
+ */
282
+ function withGovernor(runner, governor) {
283
+ return {
284
+ async run(options) {
285
+ const sessionKey = options.sessionId ?? 'default';
286
+ // The identity serve resolved for this turn — what makes "stop this user" exact.
287
+ const decision = await governor.admitTurn(sessionKey, options.analyticsUserId);
288
+ if (!decision.allowed) {
289
+ // A refusal is a normal, reportable outcome — not an exception. The caller turns
290
+ // this into a 429/403 with the operator's reason intact.
291
+ return {
292
+ success: false,
293
+ text: '',
294
+ thinking: '',
295
+ toolCalls: [],
296
+ error: decision.reason ?? 'refused by policy',
297
+ };
298
+ }
299
+ // Counted BEFORE the turn runs: a turn that crashes or is aborted still consumed the
300
+ // budget, and not counting it would let a session retry past its cap forever.
301
+ governor.recordTurn(sessionKey);
302
+ return runner.run(options);
303
+ },
304
+ dispose() {
305
+ governor.stop();
306
+ runner.dispose();
307
+ },
308
+ };
309
+ }