@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,343 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * createSandboxRunner (PLAN/serve-v4-clean.md §3) + per-sandbox model token (blocker C).
4
+ *
5
+ * Behaviors pinned here are the ones a live test caught the hard way:
6
+ * - one sandbox per session, reused across turns;
7
+ * - a dead sandbox is never handed out again, and the next turn recovers transparently;
8
+ * - a retry never duplicates output a streaming client already saw;
9
+ * - admission (lease) is checked BEFORE a sandbox exists — the kill switch;
10
+ * - the model token is sandbox-bound, so lifting it from one sandbox fails in another.
11
+ *--------------------------------------------------------------------------------------------*/
12
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ var desc = Object.getOwnPropertyDescriptor(m, k);
15
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
16
+ desc = { enumerable: true, get: function() { return m[k]; } };
17
+ }
18
+ Object.defineProperty(o, k2, desc);
19
+ }) : (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ o[k2] = m[k];
22
+ }));
23
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
24
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
25
+ }) : function(o, v) {
26
+ o["default"] = v;
27
+ });
28
+ var __importStar = (this && this.__importStar) || (function () {
29
+ var ownKeys = function(o) {
30
+ ownKeys = Object.getOwnPropertyNames || function (o) {
31
+ var ar = [];
32
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
33
+ return ar;
34
+ };
35
+ return ownKeys(o);
36
+ };
37
+ return function (mod) {
38
+ if (mod && mod.__esModule) return mod;
39
+ var result = {};
40
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
41
+ __setModuleDefault(result, mod);
42
+ return result;
43
+ };
44
+ })();
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ require("mocha");
47
+ const assert = __importStar(require("assert"));
48
+ const node_stream_1 = require("node:stream");
49
+ const sandboxRunner_1 = require("../sandbox/sandboxRunner");
50
+ const support_1 = require("../sandbox/support");
51
+ const credential_context_1 = require("../cli/credential-context");
52
+ const warrant_1 = require("../authority/warrant");
53
+ const host_1 = require("../runner/host");
54
+ const sandbox_1 = require("../cli/commands/sandbox");
55
+ const RUNTIME = { logger: { info() { }, warn() { }, error() { }, debug() { } } };
56
+ /** A driver whose sandboxes are real runner hosts over in-memory pipes. */
57
+ function fakeDriver(engineFor) {
58
+ const created = [];
59
+ const specs = [];
60
+ const destroyed = [];
61
+ const revoked = [];
62
+ const closers = new Map();
63
+ let seq = 0;
64
+ const driver = {
65
+ kind: 'process', tier: 0,
66
+ async create(spec) {
67
+ const id = `sandbox-${++seq}`;
68
+ created.push(spec.sessionKey);
69
+ specs.push(spec);
70
+ const toRunner = new node_stream_1.PassThrough();
71
+ const toServe = new node_stream_1.PassThrough();
72
+ (0, host_1.startRunnerHost)({
73
+ engine: engineFor(), runtime: RUNTIME, input: toRunner,
74
+ output: { write: (c) => toServe.write(c) },
75
+ maintenanceIntervalMs: 60_000,
76
+ });
77
+ let closeChannel = () => undefined;
78
+ const closed = new Promise((res) => { closeChannel = res; });
79
+ closers.set(id, closeChannel);
80
+ return {
81
+ id, tier: 0, endpoint: 'stdio',
82
+ connect: () => ({ input: toServe, output: { write: (c) => toRunner.write(c) }, closed }),
83
+ };
84
+ },
85
+ async revoke(handle, what) { revoked.push({ id: handle.id, what }); },
86
+ async destroy(handle) { destroyed.push(handle.id); closers.get(handle.id)?.(); },
87
+ };
88
+ return { driver, created, specs, destroyed, revoked, kill: (id) => closers.get(id)?.() };
89
+ }
90
+ const echoEngine = () => ({
91
+ id: 'e', displayName: 'E',
92
+ async stream(message, _o, emitter) { await emitter.text(`ECHO:${message}`); },
93
+ onMaintenance() { }, dispose() { },
94
+ });
95
+ function baseOpts(driver) {
96
+ return {
97
+ driver, image: 'img@sha256:' + 'a'.repeat(64), tier: 0,
98
+ routes: [], runtime: RUNTIME,
99
+ };
100
+ }
101
+ suite('sandbox runner — session affinity and recovery', () => {
102
+ test('one sandbox per session, reused across turns', async () => {
103
+ const { driver, created } = fakeDriver(echoEngine);
104
+ const runner = (0, sandboxRunner_1.createSandboxRunner)(baseOpts(driver));
105
+ await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1' });
106
+ await runner.run({ engineId: 'e', modelId: 'm', message: 'b', workspaceRoot: '/w', sessionId: 's1' });
107
+ assert.deepStrictEqual(created, ['s1'], 'second turn on the same session reuses its sandbox');
108
+ await runner.run({ engineId: 'e', modelId: 'm', message: 'c', workspaceRoot: '/w', sessionId: 's2' });
109
+ assert.deepStrictEqual(created, ['s1', 's2'], 'a different session gets its own sandbox — never cohabitation');
110
+ assert.strictEqual(runner.sandboxes().length, 2);
111
+ });
112
+ test('turn output flows back through the boundary', async () => {
113
+ const { driver } = fakeDriver(echoEngine);
114
+ const runner = (0, sandboxRunner_1.createSandboxRunner)(baseOpts(driver));
115
+ const res = await runner.run({ engineId: 'e', modelId: 'm', message: 'hi', workspaceRoot: '/w', sessionId: 's1' });
116
+ assert.strictEqual(res.success, true);
117
+ assert.strictEqual(res.text, 'ECHO:hi');
118
+ });
119
+ test('a dead sandbox is dropped and the next turn recovers transparently', async () => {
120
+ const { driver, created, kill } = fakeDriver(echoEngine);
121
+ const runner = (0, sandboxRunner_1.createSandboxRunner)(baseOpts(driver));
122
+ await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1' });
123
+ // TTL reap / OOM / node loss.
124
+ kill(runner.sandboxes()[0].sandboxId);
125
+ await new Promise((r) => setTimeout(r, 10));
126
+ const res = await runner.run({ engineId: 'e', modelId: 'm', message: 'b', workspaceRoot: '/w', sessionId: 's1' });
127
+ assert.strictEqual(res.success, true, 'the turn after a sandbox death must succeed, not fail once first');
128
+ assert.strictEqual(res.text, 'ECHO:b');
129
+ assert.deepStrictEqual(created, ['s1', 's1'], 'a fresh sandbox was created for the same session');
130
+ });
131
+ test('a sandbox whose channel dies is torn down, not left running', async () => {
132
+ // The container survives a dead channel — a refused dial or a dropped socket kills only
133
+ // serve's end. Dropping the map entry without destroying the sandbox leaked one container
134
+ // per failed turn (observed live: four orphans, healthy and unreachable, hours later).
135
+ const { driver, destroyed, kill } = fakeDriver(echoEngine);
136
+ const runner = (0, sandboxRunner_1.createSandboxRunner)(baseOpts(driver));
137
+ await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1' });
138
+ const sandboxId = runner.sandboxes()[0].sandboxId;
139
+ kill(sandboxId);
140
+ await new Promise((r) => setTimeout(r, 10));
141
+ assert.deepStrictEqual(destroyed, [sandboxId], 'the abandoned sandbox is reaped');
142
+ });
143
+ test('a mid-turn retry never duplicates output a streaming client already saw', async () => {
144
+ // An engine that emits, then hangs — the channel dies after output exists, so retrying
145
+ // would double the client's stream. The runner must surface the failure instead.
146
+ const { driver, kill } = fakeDriver(() => ({
147
+ id: 'e', displayName: 'E',
148
+ async stream(_m, _o, emitter) {
149
+ await emitter.text('partial');
150
+ await new Promise(() => undefined); // never settles
151
+ },
152
+ onMaintenance() { }, dispose() { },
153
+ }));
154
+ const runner = (0, sandboxRunner_1.createSandboxRunner)(baseOpts(driver));
155
+ const seen = [];
156
+ const turn = runner.run({
157
+ engineId: 'e', modelId: 'm', message: 'x', workspaceRoot: '/w', sessionId: 's1',
158
+ emitter: {
159
+ async thinking() { }, async text(d) { seen.push(d); }, async toolStart() { },
160
+ async toolEnd() { }, async progress() { },
161
+ async question() { return { selected: [], freeText: null, declined: true }; },
162
+ async error() { }, async done() { },
163
+ },
164
+ });
165
+ await new Promise((r) => setTimeout(r, 20));
166
+ kill(runner.sandboxes()[0].sandboxId);
167
+ const res = await turn;
168
+ assert.strictEqual(res.success, false, 'output already streamed ⇒ fail rather than silently retry');
169
+ assert.deepStrictEqual(seen, ['partial'], 'the client saw the text exactly once');
170
+ });
171
+ });
172
+ suite('sandbox runner — admission is the kill switch', () => {
173
+ test('a refused lease prevents the sandbox from ever existing', async () => {
174
+ const { driver, created } = fakeDriver(echoEngine);
175
+ const runner = (0, sandboxRunner_1.createSandboxRunner)({
176
+ ...baseOpts(driver),
177
+ admit: () => { throw new Error('lease revoked for pack analyst@1.4.2'); },
178
+ });
179
+ const res = await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1' });
180
+ assert.strictEqual(res.success, false);
181
+ assert.match(res.error ?? '', /lease revoked/);
182
+ assert.strictEqual(created.length, 0, 'no sandbox is created when admission fails — new work stops immediately');
183
+ });
184
+ test('destroySession and revokeRoute reach the driver', async () => {
185
+ const { driver, destroyed, revoked } = fakeDriver(echoEngine);
186
+ const runner = (0, sandboxRunner_1.createSandboxRunner)(baseOpts(driver));
187
+ await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1' });
188
+ const sandboxId = runner.sandboxes()[0].sandboxId;
189
+ await runner.revokeRoute('s1', 'inference');
190
+ assert.deepStrictEqual(revoked, [{ id: sandboxId, what: 'inference' }], 'cap breach revokes the inference route');
191
+ assert.strictEqual(runner.sandboxes().length, 1, 'revoking a route does not kill the sandbox');
192
+ await runner.destroySession('s1');
193
+ assert.deepStrictEqual(destroyed, [sandboxId]);
194
+ assert.strictEqual(runner.sandboxes().length, 0);
195
+ });
196
+ });
197
+ suite('sandbox filesystem is per session, not per instance', () => {
198
+ /**
199
+ * The container boundary does not extend to a host directory two containers both mount. With a
200
+ * single static `fs`, every session's sandbox mounted the SAME writable workspace — so one
201
+ * user's file output was the next user's readable input, one turn later.
202
+ */
203
+ test('fsFor gives each session its own writable mount', async () => {
204
+ const { driver, specs } = fakeDriver(echoEngine);
205
+ const runner = (0, sandboxRunner_1.createSandboxRunner)({
206
+ ...baseOpts(driver),
207
+ fs: { readonly: ['/packs/a:/app/pack'], writable: ['/shared:/state/workspace'] },
208
+ fsFor: async (sessionKey) => ({
209
+ readonly: ['/packs/a:/app/pack'],
210
+ writable: [`/state/ws/${sessionKey}:/state/workspace`],
211
+ }),
212
+ });
213
+ await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1' });
214
+ await runner.run({ engineId: 'e', modelId: 'm', message: 'b', workspaceRoot: '/w', sessionId: 's2' });
215
+ assert.deepStrictEqual(specs[0].fs.writable, ['/state/ws/s1:/state/workspace']);
216
+ assert.deepStrictEqual(specs[1].fs.writable, ['/state/ws/s2:/state/workspace']);
217
+ assert.notStrictEqual(specs[0].fs.writable[0], specs[1].fs.writable[0], 'two sessions never share a writable host directory');
218
+ // The pack is the same for everyone — read-only, and the whole point of pinning it.
219
+ assert.deepStrictEqual(specs[0].fs.readonly, specs[1].fs.readonly);
220
+ });
221
+ test('without fsFor the static fs still applies — --shared-workspace keeps working', async () => {
222
+ const { driver, specs } = fakeDriver(echoEngine);
223
+ const runner = (0, sandboxRunner_1.createSandboxRunner)({
224
+ ...baseOpts(driver),
225
+ fs: { readonly: [], writable: ['/shared:/state/workspace'] },
226
+ });
227
+ await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1' });
228
+ await runner.run({ engineId: 'e', modelId: 'm', message: 'b', workspaceRoot: '/w', sessionId: 's2' });
229
+ assert.deepStrictEqual(specs[0].fs.writable, specs[1].fs.writable);
230
+ });
231
+ });
232
+ suite('per-sandbox model token (blocker C)', () => {
233
+ /**
234
+ * A warrant is hard-capped at turn length (300s) because nothing revokes one; a sandbox lives
235
+ * for its TTL (30 min by default). Minting once at create handed the sandbox a credential that
236
+ * spent most of its life expired — invisible only because the proxy does not check `exp` yet.
237
+ */
238
+ test('the model warrant is re-minted per turn, not once per sandbox', async () => {
239
+ const { driver } = fakeDriver(echoEngine);
240
+ const minted = [];
241
+ let n = 0;
242
+ const runner = (0, sandboxRunner_1.createSandboxRunner)({
243
+ ...baseOpts(driver),
244
+ mintModelToken: (sandboxId) => {
245
+ const token = `warrant-${sandboxId}-${++n}`;
246
+ minted.push(token);
247
+ return token;
248
+ },
249
+ });
250
+ await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1' });
251
+ await runner.run({ engineId: 'e', modelId: 'm', message: 'b', workspaceRoot: '/w', sessionId: 's1' });
252
+ // One sandbox, three mints: one at create plus one per turn.
253
+ assert.strictEqual(runner.sandboxes().length, 1, 'the sandbox is reused across turns');
254
+ assert.ok(minted.length >= 3, `expected a mint per turn, got ${minted.length}`);
255
+ assert.strictEqual(new Set(minted).size, minted.length, 'each turn gets its own warrant');
256
+ // Still bound to the one sandbox — a fresh lifetime must not mean a fresh identity.
257
+ assert.ok(minted.every((t) => t.startsWith('warrant-sandbox-1-')), 'every warrant names its sandbox');
258
+ });
259
+ /**
260
+ * The warrant's `user` claim is the ONLY attribution that survives the gateway (it strips
261
+ * caller-supplied user metadata and re-stamps from the verified claim). serve reads the acting
262
+ * user via the ambient credential context at mint time, so what this pins is the plumbing that
263
+ * makes that correct: AsyncLocalStorage must reach mintModelToken through sandbox creation AND
264
+ * through the per-turn re-mint inside RemoteEngine.stream. serve minted `user: packId` for a
265
+ * long time precisely because nothing asserted what the mint callback could see.
266
+ */
267
+ test('mintModelToken sees each turn\'s ambient credential and the session key', async () => {
268
+ const { driver } = fakeDriver(echoEngine);
269
+ const seen = [];
270
+ const runner = (0, sandboxRunner_1.createSandboxRunner)({
271
+ ...baseOpts(driver),
272
+ mintModelToken: (sandboxId, sessionKey) => {
273
+ seen.push({ user: (0, credential_context_1.currentCredential)()?.userId, sessionKey });
274
+ return `warrant-${sandboxId}`;
275
+ },
276
+ });
277
+ await (0, credential_context_1.runWithCredential)({ userId: 'alice@corp.com' }, () => runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1' }));
278
+ await (0, credential_context_1.runWithCredential)({ userId: 'bob@corp.com' }, () => runner.run({ engineId: 'e', modelId: 'm', message: 'b', workspaceRoot: '/w', sessionId: 's1' }));
279
+ await runner.run({ engineId: 'e', modelId: 'm', message: 'c', workspaceRoot: '/w', sessionId: 's1' });
280
+ // Turn 1: create-time mint + per-turn re-mint, both inside alice's credential scope.
281
+ assert.ok(seen.length >= 4, `expected create + per-turn mints, got ${seen.length}`);
282
+ assert.ok(seen.slice(0, 2).every((m) => m.user === 'alice@corp.com'), 'create-time and first-turn mints carry the first turn\'s user');
283
+ // Turn 2 reuses the sandbox but re-mints under bob — attribution is per turn, not per sandbox.
284
+ assert.strictEqual(seen[2].user, 'bob@corp.com', 're-mint carries the current turn\'s user');
285
+ // Outside any credential scope the mint sees no user — the caller's anon fallback, never a
286
+ // stale identity from an earlier turn.
287
+ assert.strictEqual(seen[3].user, undefined, 'no ambient credential leaks across turns');
288
+ assert.ok(seen.every((m) => m.sessionKey === 's1'), 'every mint is handed the session key');
289
+ });
290
+ test('the per-sandbox model credential is a signed warrant, not the provider key', () => {
291
+ // The HMAC-of-the-provider-key token this suite used to cover was removed: nothing shipped
292
+ // used it, and its own docstring conceded the cap it advertised was unenforced. The credential
293
+ // serve actually mints is an ES256 warrant, and these are the properties that matter.
294
+ const provider = 'sk-live-provider-key-must-never-leave';
295
+ const key = (0, warrant_1.generateWarrantKeypair)('test');
296
+ const pubs = { [key.kid]: key.publicKeyPem };
297
+ const token = (0, support_1.mintSandboxInferenceWarrant)(key, {
298
+ user: 'u@example.com', pack: 'p', packVersion: '1.0.0', sandboxId: 'sandbox-1', tier: 2,
299
+ });
300
+ assert.ok(!token.includes(provider), 'the token must not contain the provider key');
301
+ assert.ok((0, warrant_1.verifyWarrant)(token, pubs, { audience: 'inference', channelSandboxId: 'sandbox-1' }).ok, 'verifies for its own sandbox');
302
+ assert.ok(!(0, warrant_1.verifyWarrant)(token, pubs, { audience: 'inference', channelSandboxId: 'sandbox-2' }).ok, 'a token lifted into another sandbox FAILS — this is what makes the cap non-evadable');
303
+ assert.ok(!(0, warrant_1.verifyWarrant)(token, pubs, { audience: 'data', channelSandboxId: 'sandbox-1' }).ok, 'an inference warrant is not a data-plane warrant');
304
+ });
305
+ test('a tampered warrant is refused', () => {
306
+ const key = (0, warrant_1.generateWarrantKeypair)('test');
307
+ const pubs = { [key.kid]: key.publicKeyPem };
308
+ const token = (0, support_1.mintSandboxInferenceWarrant)(key, {
309
+ user: 'u@example.com', pack: 'p', packVersion: '1.0.0', sandboxId: 'sandbox-1', tier: 2,
310
+ });
311
+ // Flip a character in the MIDDLE of the signature segment. The final base64url
312
+ // character encodes only padding bits, so flipping it decodes to the same bytes
313
+ // roughly a quarter of the time and the token still verifies.
314
+ const dot = token.lastIndexOf('.');
315
+ const sigMid = dot + 1 + Math.floor((token.length - dot - 1) / 2);
316
+ const tampered = token.slice(0, sigMid) + (token[sigMid] === 'A' ? 'B' : 'A') + token.slice(sigMid + 1);
317
+ assert.ok(!(0, warrant_1.verifyWarrant)(tampered, pubs, { audience: 'inference', channelSandboxId: 'sandbox-1' }).ok, 'a bad signature is refused');
318
+ assert.ok(!(0, warrant_1.verifyWarrant)('sk-provider', pubs, { audience: 'inference', channelSandboxId: 'sandbox-1' }).ok, 'a raw provider key is not a valid sandbox credential');
319
+ });
320
+ test('two sandboxes get different warrants from the same key', () => {
321
+ const key = (0, warrant_1.generateWarrantKeypair)('test');
322
+ const base = { user: 'u@example.com', pack: 'p', packVersion: '1.0.0', tier: 2 };
323
+ const a = (0, support_1.mintSandboxInferenceWarrant)(key, { ...base, sandboxId: 'sandbox-1' });
324
+ const b = (0, support_1.mintSandboxInferenceWarrant)(key, { ...base, sandboxId: 'sandbox-2' });
325
+ assert.notStrictEqual(a, b);
326
+ });
327
+ });
328
+ suite('sandbox workspace authority', () => {
329
+ test('the wire never redirects a sandbox out of its own workspace', () => {
330
+ // The "codex discards the write" bug: serve sends its HOST workspace path on every run
331
+ // frame, and the sandbox used to mkdir + chdir into it. Inside a container that path is
332
+ // container-private, so the engine's files were visible via ls/cat in the sandbox and absent
333
+ // on the host — and the engine ran with none of the synced pack content around it.
334
+ assert.strictEqual((0, sandbox_1.resolveSandboxWorkspace)('/Users/someone/host/ws', '/state/workspace'), '/state/workspace', 'a host path from the wire must never win');
335
+ assert.strictEqual((0, sandbox_1.resolveSandboxWorkspace)(undefined, '/state/workspace'), '/state/workspace', 'no wire path — the sandbox workspace still governs');
336
+ });
337
+ test('tier 0 sees no mismatch, because serve passes the same path', () => {
338
+ assert.ok(!(0, sandbox_1.isForeignWorkspace)('/tmp/ws', '/tmp/ws'));
339
+ assert.ok(!(0, sandbox_1.isForeignWorkspace)('/tmp/ws/', '/tmp/ws'), 'trailing slash is the same directory');
340
+ assert.ok(!(0, sandbox_1.isForeignWorkspace)(undefined, '/tmp/ws'), 'nothing asked for — nothing to warn about');
341
+ assert.ok((0, sandbox_1.isForeignWorkspace)('/tmp/other', '/tmp/ws'), 'a genuinely different root is worth a warning');
342
+ });
343
+ });
@@ -0,0 +1,267 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Copyright (c) AgentHippo.ai. All rights reserved.
4
+ *--------------------------------------------------------------------------------------------*/
5
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
+ if (k2 === undefined) k2 = k;
7
+ var desc = Object.getOwnPropertyDescriptor(m, k);
8
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
+ desc = { enumerable: true, get: function() { return m[k]; } };
10
+ }
11
+ Object.defineProperty(o, k2, desc);
12
+ }) : (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ o[k2] = m[k];
15
+ }));
16
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
17
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
18
+ }) : function(o, v) {
19
+ o["default"] = v;
20
+ });
21
+ var __importStar = (this && this.__importStar) || (function () {
22
+ var ownKeys = function(o) {
23
+ ownKeys = Object.getOwnPropertyNames || function (o) {
24
+ var ar = [];
25
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
26
+ return ar;
27
+ };
28
+ return ownKeys(o);
29
+ };
30
+ return function (mod) {
31
+ if (mod && mod.__esModule) return mod;
32
+ var result = {};
33
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
34
+ __setModuleDefault(result, mod);
35
+ return result;
36
+ };
37
+ })();
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ /**
40
+ * runGatewayTurn Test Suite
41
+ *
42
+ * Gateway/channel-originated turns (Slack, etc.) previously bypassed both the
43
+ * identity-keyed RateLimiter and the per-session TurnLimiter entirely — only the
44
+ * capacity gate (TurnGate) was ever checked. This suite exercises the extracted
45
+ * admission sequence directly, without a live gateway connection.
46
+ */
47
+ require("mocha");
48
+ const assert = __importStar(require("assert"));
49
+ const serve_1 = require("../cli/commands/serve");
50
+ const serve_hardening_1 = require("../cli/serve-hardening");
51
+ function fakeEmitter() {
52
+ const texts = [];
53
+ return {
54
+ texts,
55
+ doneCalled: false,
56
+ async thinking() { },
57
+ async text(delta) { texts.push(delta); },
58
+ async toolStart() { },
59
+ async toolEnd() { },
60
+ async progress() { },
61
+ async question() { return { selected: [], freeText: null }; },
62
+ async error() { },
63
+ async done() { this.doneCalled = true; },
64
+ };
65
+ }
66
+ function baseParams(overrides = {}) {
67
+ const noop = () => { };
68
+ return {
69
+ rateLimiter: new serve_hardening_1.RateLimiter(0), // disabled unless overridden
70
+ turnLimiter: new serve_hardening_1.TurnLimiter(0), // disabled unless overridden
71
+ turnGate: new serve_hardening_1.TurnGate(10, 10),
72
+ warnGateReject: noop,
73
+ rlKey: 'user-1',
74
+ sessionKey: 'session-1',
75
+ emitter: undefined,
76
+ run: async () => ({ success: true, error: undefined }),
77
+ ...overrides,
78
+ };
79
+ }
80
+ suite('runGatewayTurn: admission order and rejection messaging', () => {
81
+ test('rate limiter rejects: run() never called, channel gets RATE_LIMIT_BUSY_MESSAGE', async () => {
82
+ const rl = new serve_hardening_1.RateLimiter(1);
83
+ rl.tryConsume('user-1'); // pre-exhaust the one slot for this key
84
+ let ran = false;
85
+ const emitter = fakeEmitter();
86
+ const result = await (0, serve_1.runGatewayTurn)(baseParams({
87
+ rateLimiter: rl,
88
+ emitter,
89
+ run: async () => { ran = true; return { success: true }; },
90
+ }));
91
+ assert.strictEqual(result.success, false);
92
+ assert.strictEqual(result.error, 'rate_limited');
93
+ assert.strictEqual(ran, false, 'run() must not be called when the rate limiter rejects');
94
+ assert.deepStrictEqual(emitter.texts, [serve_hardening_1.RATE_LIMIT_BUSY_MESSAGE]);
95
+ assert.strictEqual(emitter.doneCalled, true);
96
+ });
97
+ test('rate limiter allows a fresh key through to run()', async () => {
98
+ let ran = false;
99
+ const result = await (0, serve_1.runGatewayTurn)(baseParams({
100
+ rateLimiter: new serve_hardening_1.RateLimiter(1),
101
+ run: async () => { ran = true; return { success: true }; },
102
+ }));
103
+ assert.strictEqual(result.success, true);
104
+ assert.strictEqual(ran, true);
105
+ });
106
+ test('turn gate at capacity rejects: run() never called, channel gets TURN_GATE_BUSY_MESSAGE', async () => {
107
+ const gate = new serve_hardening_1.TurnGate(1, 0); // one slot, no queue room
108
+ const held = await gate.acquire();
109
+ assert.ok(held, 'setup: hold the only slot');
110
+ let ran = false;
111
+ const emitter = fakeEmitter();
112
+ const result = await (0, serve_1.runGatewayTurn)(baseParams({
113
+ turnGate: gate,
114
+ emitter,
115
+ run: async () => { ran = true; return { success: true }; },
116
+ }));
117
+ assert.strictEqual(result.success, false);
118
+ assert.strictEqual(result.error, 'busy');
119
+ assert.strictEqual(ran, false);
120
+ assert.deepStrictEqual(emitter.texts, [serve_hardening_1.TURN_GATE_BUSY_MESSAGE]);
121
+ held();
122
+ });
123
+ test('per-session turn limiter rejects: run() never called, but the turn-gate slot is released', async () => {
124
+ const limiter = new serve_hardening_1.TurnLimiter(1);
125
+ limiter.tryConsume('session-1'); // exhaust the one slot for this session
126
+ const gate = new serve_hardening_1.TurnGate(1, 1);
127
+ let ran = false;
128
+ const emitter = fakeEmitter();
129
+ const result = await (0, serve_1.runGatewayTurn)(baseParams({
130
+ turnLimiter: limiter,
131
+ turnGate: gate,
132
+ emitter,
133
+ run: async () => { ran = true; return { success: true }; },
134
+ }));
135
+ assert.strictEqual(result.success, false);
136
+ assert.strictEqual(result.error, 'turn_limit');
137
+ assert.strictEqual(ran, false);
138
+ assert.deepStrictEqual(emitter.texts, [serve_hardening_1.TURN_LIMIT_BUSY_MESSAGE]);
139
+ // The turn-gate slot must be released even though the turn never ran.
140
+ assert.strictEqual(gate.inflight, 0);
141
+ });
142
+ test('all checks pass: run() executes and its result is returned; the gate slot releases after', async () => {
143
+ const gate = new serve_hardening_1.TurnGate(1, 1);
144
+ const result = await (0, serve_1.runGatewayTurn)(baseParams({
145
+ rateLimiter: new serve_hardening_1.RateLimiter(5),
146
+ turnLimiter: new serve_hardening_1.TurnLimiter(5),
147
+ turnGate: gate,
148
+ run: async () => ({ success: true, error: undefined }),
149
+ }));
150
+ assert.strictEqual(result.success, true);
151
+ assert.strictEqual(gate.inflight, 0, 'slot must be released after a successful turn');
152
+ });
153
+ test('run() throwing still releases the turn-gate slot', async () => {
154
+ const gate = new serve_hardening_1.TurnGate(1, 1);
155
+ await assert.rejects(() => (0, serve_1.runGatewayTurn)(baseParams({
156
+ rateLimiter: new serve_hardening_1.RateLimiter(5),
157
+ turnLimiter: new serve_hardening_1.TurnLimiter(5),
158
+ turnGate: gate,
159
+ run: async () => { throw new Error('boom'); },
160
+ })));
161
+ assert.strictEqual(gate.inflight, 0);
162
+ });
163
+ test('rate limit key and turn-limiter session key are independent buckets', async () => {
164
+ const rateLimiter = new serve_hardening_1.RateLimiter(1);
165
+ const turnLimiter = new serve_hardening_1.TurnLimiter(5);
166
+ // Exhaust the rate limiter for 'user-A' only.
167
+ rateLimiter.tryConsume('user-A');
168
+ const rejectedForA = await (0, serve_1.runGatewayTurn)(baseParams({
169
+ rateLimiter, turnLimiter, rlKey: 'user-A', sessionKey: 'sess-A',
170
+ run: async () => ({ success: true }),
171
+ }));
172
+ const okForB = await (0, serve_1.runGatewayTurn)(baseParams({
173
+ rateLimiter, turnLimiter, rlKey: 'user-B', sessionKey: 'sess-B',
174
+ run: async () => ({ success: true }),
175
+ }));
176
+ assert.strictEqual(rejectedForA.error, 'rate_limited', 'user-A already consumed its one slot in setup');
177
+ assert.strictEqual(okForB.success, true, 'user-B has an independent rate-limit bucket');
178
+ });
179
+ test('a missing emitter does not throw on rejection (channel-less callers are safe)', async () => {
180
+ const rl = new serve_hardening_1.RateLimiter(1);
181
+ rl.tryConsume('user-1');
182
+ const rejected = await (0, serve_1.runGatewayTurn)(baseParams({ rateLimiter: rl, emitter: undefined }));
183
+ assert.strictEqual(rejected.success, false);
184
+ assert.strictEqual(rejected.error, 'rate_limited');
185
+ });
186
+ });
187
+ suite('resolveGatewaySessionKey: never collapses two unidentified senders onto one key', () => {
188
+ test('prefers an explicit sessionId over everything else — durable, user-namespaced', () => {
189
+ const r = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42', gatewaySessionKey: 'gw-1' }, 'alice');
190
+ assert.strictEqual(r.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice')}--chat-42`);
191
+ assert.strictEqual(r.outboundKey, 'chat-42', 'reply-handle routing must keep the raw gateway key');
192
+ assert.strictEqual(r.durable, true);
193
+ });
194
+ test('falls back to gatewaySessionKey when sessionId is absent — durable, user-namespaced', () => {
195
+ const r = (0, serve_1.resolveGatewaySessionKey)({ gatewaySessionKey: 'gw-1' }, 'alice');
196
+ assert.strictEqual(r.sessionKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice')}--gw-1`);
197
+ assert.strictEqual(r.outboundKey, 'gw-1');
198
+ assert.strictEqual(r.durable, true);
199
+ });
200
+ test('the same explicit session id from two different senders never attaches to one engine session', () => {
201
+ const alice = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42' }, 'alice');
202
+ const bob = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42' }, 'bob');
203
+ assert.notStrictEqual(alice.sessionKey, bob.sessionKey);
204
+ });
205
+ test('an explicit session id with no resolved identity stays raw (dev/unidentified path)', () => {
206
+ const r = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42' }, undefined);
207
+ assert.strictEqual(r.sessionKey, 'chat-42');
208
+ assert.strictEqual(r.outboundKey, 'chat-42');
209
+ assert.strictEqual(r.durable, true);
210
+ });
211
+ test('with a resolved identity but no session id, keys off that identity — stable, durable, no collision risk', () => {
212
+ const r1 = (0, serve_1.resolveGatewaySessionKey)({}, 'slack:U0B6');
213
+ const r2 = (0, serve_1.resolveGatewaySessionKey)({}, 'slack:U0B6');
214
+ assert.strictEqual(r1.sessionKey, 'gateway-slack:U0B6');
215
+ assert.strictEqual(r1.sessionKey, r2.sessionKey, 'the same identified sender must get the same key across messages');
216
+ assert.strictEqual(r1.durable, true);
217
+ });
218
+ test('two different identified senders with no session id never collide', () => {
219
+ const alice = (0, serve_1.resolveGatewaySessionKey)({}, 'slack:alice');
220
+ const bob = (0, serve_1.resolveGatewaySessionKey)({}, 'slack:bob');
221
+ assert.notStrictEqual(alice.sessionKey, bob.sessionKey);
222
+ });
223
+ test('two genuinely unidentified senders (no userId, no session id) never collide — no shared "gateway-anon" bucket, and both flagged non-durable', () => {
224
+ const first = (0, serve_1.resolveGatewaySessionKey)({}, undefined);
225
+ const second = (0, serve_1.resolveGatewaySessionKey)({}, undefined);
226
+ assert.notStrictEqual(first.sessionKey, second.sessionKey, 'each unidentified message must get its own key, not a shared bucket');
227
+ assert.ok(first.sessionKey.startsWith('gateway-anon-'));
228
+ assert.ok(second.sessionKey.startsWith('gateway-anon-'));
229
+ assert.strictEqual(first.durable, false);
230
+ assert.strictEqual(second.durable, false);
231
+ });
232
+ test('whitespace-only sessionId/gatewaySessionKey are treated as absent', () => {
233
+ const r = (0, serve_1.resolveGatewaySessionKey)({ sessionId: ' ', gatewaySessionKey: ' ' }, 'alice');
234
+ assert.strictEqual(r.sessionKey, 'gateway-alice');
235
+ assert.strictEqual(r.durable, true);
236
+ });
237
+ });
238
+ suite('runGatewayTurn: trackSession=false skips the TurnLimiter for one-off keys', () => {
239
+ test('a never-repeated key would never hit its own cap anyway — trackSession:false just skips the dead-weight tracking', async () => {
240
+ const limiter = new serve_hardening_1.TurnLimiter(1);
241
+ let ran = false;
242
+ const result = await (0, serve_1.runGatewayTurn)(baseParams({
243
+ turnLimiter: limiter,
244
+ sessionKey: 'gateway-anon-unique-1',
245
+ trackSession: false,
246
+ run: async () => { ran = true; return { success: true }; },
247
+ }));
248
+ assert.strictEqual(result.success, true);
249
+ assert.strictEqual(ran, true);
250
+ assert.strictEqual(limiter.tryConsume('gateway-anon-unique-1'), true, 'trackSession:false must never call tryConsume — the key should still have its full budget');
251
+ });
252
+ test('trackSession:true (default) still enforces the cap as before', async () => {
253
+ const limiter = new serve_hardening_1.TurnLimiter(1);
254
+ limiter.tryConsume('sess-durable'); // exhaust the one slot
255
+ let ran = false;
256
+ const emitter = fakeEmitter();
257
+ const result = await (0, serve_1.runGatewayTurn)(baseParams({
258
+ turnLimiter: limiter,
259
+ sessionKey: 'sess-durable',
260
+ emitter,
261
+ run: async () => { ran = true; return { success: true }; },
262
+ }));
263
+ assert.strictEqual(result.success, false);
264
+ assert.strictEqual(result.error, 'turn_limit');
265
+ assert.strictEqual(ran, false);
266
+ });
267
+ });