@agenthippo-ai/agenthippo 0.2.3-dataplane → 1.110.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/out/agentpack/index.js +3 -1
  2. package/out/agentpack/paths.js +25 -2
  3. package/out/analytics/dashboard/localFleetAdapter.js +82 -2
  4. package/out/analytics/dashboard/provider.js +13 -2
  5. package/out/analytics/dashboard/webview/panel.js +3 -2
  6. package/out/analytics/identity.js +0 -0
  7. package/out/analytics/service.js +29 -25
  8. package/out/analytics/sessionEvents.js +100 -24
  9. package/out/auth/authBaseUrl.js +59 -0
  10. package/out/auth/bootstrapApply.js +9 -10
  11. package/out/auth/brokerClient.js +48 -14
  12. package/out/auth/provider.js +8 -21
  13. package/out/auth/sessionStore.js +3 -40
  14. package/out/auth/signInFlow.js +2 -1
  15. package/out/auth-broker/config.js +50 -5
  16. package/out/auth-broker/server.js +126 -26
  17. package/out/auth-broker/store.js +25 -24
  18. package/out/authority/warrant.js +206 -0
  19. package/out/branding.js +27 -1
  20. package/out/cli/commands/conformance.js +249 -0
  21. package/out/cli/commands/data-plane.js +151 -0
  22. package/out/cli/commands/deploy.js +1 -0
  23. package/out/cli/commands/gateway.js +4 -0
  24. package/out/cli/commands/pack.js +182 -3
  25. package/out/cli/commands/sandbox.js +351 -0
  26. package/out/cli/commands/serve.js +1145 -130
  27. package/out/cli/commands/store.js +12 -4
  28. package/out/cli/credential-context.js +17 -0
  29. package/out/cli/data-plane/gateway.js +28 -5
  30. package/out/cli/data-plane/index.js +18 -2
  31. package/out/cli/data-plane/registry.js +9 -0
  32. package/out/cli/data-plane/remote.js +106 -0
  33. package/out/cli/data-plane/sidecar.js +225 -0
  34. package/out/cli/deploy/layers/identity.js +88 -4
  35. package/out/cli/deploy-init.js +89 -24
  36. package/out/cli/deps.js +106 -2
  37. package/out/cli/idp-init.js +35 -0
  38. package/out/cli/index.js +171 -17
  39. package/out/cli/pack-install-ref.js +15 -0
  40. package/out/cli/pack-loader.js +8 -1
  41. package/out/cli/pack-runtime.js +61 -0
  42. package/out/cli/runtime.js +35 -1
  43. package/out/cli/serve-hardening.js +260 -14
  44. package/out/cli/utils.js +12 -1
  45. package/out/engines/base.js +32 -3
  46. package/out/engines/claude.js +33 -15
  47. package/out/engines/codex.js +111 -27
  48. package/out/engines/custom.js +15 -1
  49. package/out/evidence/sessionShip.js +643 -0
  50. package/out/extension.js +11 -2
  51. package/out/gateway/bridge.js +40 -2
  52. package/out/gateway/client.js +34 -1
  53. package/out/gateway/installSource.js +68 -0
  54. package/out/gateway/lifecycle.js +185 -51
  55. package/out/gateway/outboundPaths.js +4 -1
  56. package/out/gateway/secretsPreflight.js +90 -0
  57. package/out/gateway/service.js +7 -2
  58. package/out/headless/index.js +25 -2
  59. package/out/live-e2e/mcp-demo/server.mjs +32 -0
  60. package/out/mcp.js +30 -0
  61. package/out/pack/caps.js +271 -0
  62. package/out/pack/governor.js +326 -0
  63. package/out/pack/lease-schema.js +273 -0
  64. package/out/pack/lease.js +682 -0
  65. package/out/pack/reconcile.js +217 -0
  66. package/out/pack/signature.js +839 -0
  67. package/out/remote/handleRemoteChat.js +44 -3
  68. package/out/remote/remoteTrust.js +277 -0
  69. package/out/remote/serveClient.js +52 -5
  70. package/out/runner/host.js +141 -0
  71. package/out/runner/protocol.js +128 -0
  72. package/out/runner/remoteEngine.js +166 -0
  73. package/out/runtime/interfaces/edit-tracker.js +2 -0
  74. package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
  75. package/out/sandbox/containerDriver.js +572 -0
  76. package/out/sandbox/processDriver.js +318 -0
  77. package/out/sandbox/sandboxRunner.js +303 -0
  78. package/out/sandbox/support.js +301 -0
  79. package/out/sandbox/types.js +55 -0
  80. package/out/scripts/agentide-litellm.sh +67 -5
  81. package/out/scripts/agentide_litellm_hooks_v1.py +266 -21
  82. package/out/scripts/analytics/agentide-litellm-otel.config.yaml +98 -132
  83. package/out/scripts/analytics/setup-analytics.ps1 +45 -14
  84. package/out/scripts/analytics/setup-analytics.sh +106 -15
  85. package/out/scripts/analytics/start-analytics.sh +86 -14
  86. package/out/scripts/gateway/setup-gateway.sh +72 -32
  87. package/out/shared/models.js +11 -110
  88. package/out/spotlight/install.js +7 -1
  89. package/out/store/index.js +41 -10
  90. package/out/sync.js +3 -1
  91. package/out/test/analytics.test.js +162 -23
  92. package/out/test/auth-baseurl.test.js +132 -0
  93. package/out/test/auth-bootstrapApply.test.js +27 -6
  94. package/out/test/auth-broker-hardening.test.js +227 -0
  95. package/out/test/auth-broker.test.js +59 -9
  96. package/out/test/branding.test.js +13 -0
  97. package/out/test/caps-governor.test.js +460 -0
  98. package/out/test/codex-new-file-survival.test.js +119 -0
  99. package/out/test/configuration-scopes.test.js +131 -0
  100. package/out/test/data-plane-engine-wiring.test.js +32 -0
  101. package/out/test/data-plane.test.js +380 -0
  102. package/out/test/deploy-init.test.js +47 -1
  103. package/out/test/gateway-client.test.js +28 -0
  104. package/out/test/gateway-install-source.test.js +111 -0
  105. package/out/test/gateway-lifecycle.test.js +1 -1
  106. package/out/test/gateway-outbound.test.js +7 -0
  107. package/out/test/gateway-secrets-preflight.test.js +127 -0
  108. package/out/test/gateway-session-routing.test.js +18 -0
  109. package/out/test/governor-hardening.test.js +222 -0
  110. package/out/test/headless-sync.test.js +22 -0
  111. package/out/test/lease-signature.test.js +357 -0
  112. package/out/test/lease-trust-root.test.js +273 -0
  113. package/out/test/lease-validation.test.js +224 -0
  114. package/out/test/lease.test.js +114 -0
  115. package/out/test/localFleetAdapter.test.js +91 -0
  116. package/out/test/mcp.test.js +27 -1
  117. package/out/test/pack-admission.test.js +216 -0
  118. package/out/test/pack-digest-parity.test.js +140 -0
  119. package/out/test/pack-reconcile.test.js +183 -0
  120. package/out/test/pack-signature-artifact.test.js +103 -0
  121. package/out/test/pack-signature.test.js +176 -0
  122. package/out/test/pack-trust-root.test.js +202 -0
  123. package/out/test/remote-trust.test.js +242 -0
  124. package/out/test/runner-protocol.test.js +249 -0
  125. package/out/test/sandbox-driver.test.js +553 -0
  126. package/out/test/sandbox-hardening.test.js +406 -0
  127. package/out/test/sandbox-runner.test.js +343 -0
  128. package/out/test/sandbox-scope.test.js +449 -0
  129. package/out/test/serve-cron-acceptance.test.js +370 -0
  130. package/out/test/serve-gateway-turn.test.js +122 -0
  131. package/out/test/serve-hardening.test.js +227 -15
  132. package/out/test/serveClient.test.js +6 -5
  133. package/out/test/sessionShip.test.js +618 -0
  134. package/out/test/spotlightPublish.test.js +28 -0
  135. package/out/test/store-install-safe.test.js +69 -0
  136. package/out/test/tls-bypass.test.js +90 -0
  137. package/out/test/warrant.test.js +138 -0
  138. package/out/utils.js +46 -9
  139. package/package.json +1 -1
@@ -0,0 +1,370 @@
1
+ "use strict";
2
+ /*---------------------------------------------------------------------------------------------
3
+ * Cron acceptance — PLAN/agent-identity.md §5, build record §10.4.
4
+ *
5
+ * THE GOAL, stated as a claim an operator would hold us to:
6
+ *
7
+ * "A nightly job runs unattended, indefinitely, reaching data as the deployment's own
8
+ * identity — in one container, with its spend attributed to that identity, and it stops
9
+ * the moment the control plane says so."
10
+ *
11
+ * Every clause of that is a separate way the mechanism can be wrong, and the previous tests
12
+ * covered the pieces in isolation: the limiter arithmetic in one, the key derivation in another,
13
+ * the lease gate in a third. None of them ran the COMPOSITION, because the composition lived in a
14
+ * closure reachable only through a live OpenClaw websocket.
15
+ *
16
+ * So this drives `runBridgeTurn` — the exact function the bridge's messageHandler calls — with a
17
+ * real TurnLimiter, a real TurnRegistry, a real credential scope and a real warrant minter. The
18
+ * only fakes are the things outside the mechanism under test: the engine runner, and the clock of
19
+ * "which night is it". No OpenClaw, no gateway socket, no cron daemon.
20
+ *--------------------------------------------------------------------------------------------*/
21
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ var desc = Object.getOwnPropertyDescriptor(m, k);
24
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
25
+ desc = { enumerable: true, get: function() { return m[k]; } };
26
+ }
27
+ Object.defineProperty(o, k2, desc);
28
+ }) : (function(o, m, k, k2) {
29
+ if (k2 === undefined) k2 = k;
30
+ o[k2] = m[k];
31
+ }));
32
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
33
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
34
+ }) : function(o, v) {
35
+ o["default"] = v;
36
+ });
37
+ var __importStar = (this && this.__importStar) || (function () {
38
+ var ownKeys = function(o) {
39
+ ownKeys = Object.getOwnPropertyNames || function (o) {
40
+ var ar = [];
41
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
42
+ return ar;
43
+ };
44
+ return ownKeys(o);
45
+ };
46
+ return function (mod) {
47
+ if (mod && mod.__esModule) return mod;
48
+ var result = {};
49
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
50
+ __setModuleDefault(result, mod);
51
+ return result;
52
+ };
53
+ })();
54
+ Object.defineProperty(exports, "__esModule", { value: true });
55
+ require("mocha");
56
+ const assert = __importStar(require("assert"));
57
+ const serve_1 = require("../cli/commands/serve");
58
+ const serve_hardening_1 = require("../cli/serve-hardening");
59
+ const data_plane_1 = require("../cli/data-plane");
60
+ const credential_context_1 = require("../cli/credential-context");
61
+ const lease_1 = require("../pack/lease");
62
+ const node_stream_1 = require("node:stream");
63
+ const sandboxRunner_1 = require("../sandbox/sandboxRunner");
64
+ const host_1 = require("../runner/host");
65
+ const SERVICE_IDENTITY = 'svc:invoice-agent';
66
+ /** Deliberately small, so "past the cap" is reachable in a test the way it is over a month. */
67
+ const TURN_CAP = 3;
68
+ /** One nightly cron firing: a bridge message with NO peer, which is what "no human" looks like. */
69
+ function cronFiring() {
70
+ return { peerInfo: undefined, emitter: undefined, engineId: undefined };
71
+ }
72
+ function harness(over = {}) {
73
+ const runs = [];
74
+ const prepared = [];
75
+ const statePushes = [];
76
+ const registry = over.registry ?? new data_plane_1.TurnRegistry();
77
+ const releases = [];
78
+ const runner = {
79
+ run: async (req) => {
80
+ runs.push({
81
+ sessionId: req.sessionId,
82
+ sandboxKey: req.sandboxKey,
83
+ analyticsUserId: req.analyticsUserId,
84
+ gatewaySessionKey: req.gatewaySessionKey,
85
+ // Read the AMBIENT credential exactly as mintSandboxInferenceWarrant does
86
+ // (`currentCredential()?.userId ?? anon:<sandboxKey>`), so this observes the real
87
+ // attribution rather than asserting on a parameter we passed in ourselves.
88
+ credentialUserId: (0, credential_context_1.currentCredential)()?.userId,
89
+ turnEnv: req.turnEnv,
90
+ });
91
+ return { success: true };
92
+ },
93
+ };
94
+ const deps = {
95
+ engine: 'claude',
96
+ defaultModel: 'model-x',
97
+ runner,
98
+ permissions: { fileAccess: 'read-only' },
99
+ workspace: '/tmp/cron-acceptance',
100
+ turnGate: new serve_hardening_1.TurnGate(10, 10),
101
+ rateLimiter: new serve_hardening_1.RateLimiter(0),
102
+ turnLimiter: new serve_hardening_1.TurnLimiter(TURN_CAP),
103
+ warnGateReject: () => undefined,
104
+ prepareSession: async (k) => { prepared.push(k); },
105
+ sandboxScope: 'session',
106
+ onTurnComplete: (k) => { statePushes.push(k); },
107
+ logWarrantDenied: () => () => undefined,
108
+ registerTurn: async (reg) => {
109
+ const { nonce, release } = registry.register({
110
+ userId: reg.userId, sessionKey: reg.sessionKey, egressStoreKey: reg.egressStoreKey,
111
+ });
112
+ return {
113
+ nonce,
114
+ env: { AGENTHIPPO_TURN_NONCE: nonce },
115
+ release: () => { releases.push(nonce); release(); },
116
+ };
117
+ },
118
+ serviceUserIfPermitted: async () => {
119
+ const identity = over.identity ?? SERVICE_IDENTITY;
120
+ const gate = (0, lease_1.resolveAllowServiceTurns)({
121
+ lease: over.leaseAllows,
122
+ managed: over.managed ?? true,
123
+ });
124
+ return gate.allowed ? identity : undefined;
125
+ },
126
+ };
127
+ return { deps, runs, prepared, statePushes, registry, releases };
128
+ }
129
+ suite('cron acceptance: an unattended job runs indefinitely, in one sandbox, as itself', function () {
130
+ this.timeout(20_000);
131
+ test(`${TURN_CAP + 2} consecutive nightly runs ALL succeed — past a cap that never resets`, async () => {
132
+ const h = harness({ leaseAllows: true });
133
+ const outcomes = [];
134
+ for (let night = 1; night <= TURN_CAP + 2; night++) {
135
+ const r = await (0, serve_1.runBridgeTurn)(h.deps, `nightly run ${night}`, cronFiring());
136
+ outcomes.push(r.success);
137
+ }
138
+ assert.deepStrictEqual(outcomes, [true, true, true, true, true], 'a false here is the nightly-job death: TurnLimiter is monotonic with no window, so a '
139
+ + 'stable session key would exhaust a LIFETIME budget one night at a time');
140
+ assert.strictEqual(h.runs.length, TURN_CAP + 2, 'every run must have reached the engine');
141
+ });
142
+ test('…in ONE sandbox, not one per night', async () => {
143
+ const h = harness({ leaseAllows: true });
144
+ for (let night = 1; night <= TURN_CAP + 2; night++) {
145
+ await (0, serve_1.runBridgeTurn)(h.deps, `night ${night}`, cronFiring());
146
+ }
147
+ const sandboxes = new Set(h.runs.map((r) => r.sandboxKey));
148
+ assert.strictEqual(sandboxes.size, 1, `expected one warm sandbox across all runs, got ${sandboxes.size}: ${[...sandboxes].join(', ')}`);
149
+ assert.strictEqual([...sandboxes][0], `gateway-${SERVICE_IDENTITY}`);
150
+ // …while the conversation itself is fresh each night. Both halves matter: a shared sandbox
151
+ // with a shared session key would re-introduce the limiter defect.
152
+ const sessions = new Set(h.runs.map((r) => r.sessionId));
153
+ assert.strictEqual(sessions.size, TURN_CAP + 2, 'each run needs its own session key');
154
+ });
155
+ test('…with no session state restored or accumulated per run', async () => {
156
+ const h = harness({ leaseAllows: true });
157
+ for (let night = 1; night <= 3; night++) {
158
+ await (0, serve_1.runBridgeTurn)(h.deps, `night ${night}`, cronFiring());
159
+ }
160
+ assert.deepStrictEqual(h.prepared, [], 'a never-reused key has no prior state to restore');
161
+ assert.deepStrictEqual(h.statePushes, [], 'and nothing will ever look it up again');
162
+ });
163
+ test('…reaching data as the service identity, with a live nonce for the turn only', async () => {
164
+ const registry = new data_plane_1.TurnRegistry();
165
+ const h = harness({ leaseAllows: true, registry });
166
+ await (0, serve_1.runBridgeTurn)(h.deps, 'nightly', cronFiring());
167
+ const nonce = h.runs[0].turnEnv?.AGENTHIPPO_TURN_NONCE;
168
+ assert.ok(nonce, 'the engine must receive a per-turn nonce');
169
+ // The turn is released in the handler's `finally`, so by now the capability must be dead.
170
+ assert.strictEqual(registry.resolve(nonce), undefined, 'the nonce must not outlive the run that minted it');
171
+ assert.strictEqual(h.releases.length, 1);
172
+ });
173
+ test('…with SPEND attributed to the service identity, never to anon:', async () => {
174
+ const h = harness({ leaseAllows: true });
175
+ await (0, serve_1.runBridgeTurn)(h.deps, 'nightly', cronFiring());
176
+ // This is what mintSandboxInferenceWarrant reads. Registering the turn without ALSO
177
+ // scoping the credential would give the bot data access while its spend escaped the
178
+ // per-user meter — the failure the design calls out as easy to miss and expensive.
179
+ assert.strictEqual(h.runs[0].credentialUserId, SERVICE_IDENTITY);
180
+ });
181
+ test('…and named in analytics, so the two attribution channels agree', async () => {
182
+ const h = harness({ leaseAllows: true });
183
+ await (0, serve_1.runBridgeTurn)(h.deps, 'nightly', cronFiring());
184
+ assert.strictEqual(h.runs[0].analyticsUserId, SERVICE_IDENTITY);
185
+ assert.strictEqual(h.runs[0].analyticsUserId, h.runs[0].credentialUserId, 'Spotlight and the warrant must name the same principal');
186
+ });
187
+ test('…replying to the same channel every night, though the session moves', async () => {
188
+ const h = harness({ leaseAllows: true });
189
+ const firing = { ...cronFiring(), gatewaySessionKey: 'slack-C0FEED' };
190
+ await (0, serve_1.runBridgeTurn)(h.deps, 'night 1', firing);
191
+ await (0, serve_1.runBridgeTurn)(h.deps, 'night 2', firing);
192
+ assert.deepStrictEqual(h.runs.map((r) => r.gatewaySessionKey), ['slack-C0FEED', 'slack-C0FEED'], 'the egress broker resolves reply destinations from this key');
193
+ assert.notStrictEqual(h.runs[0].sessionId, h.runs[1].sessionId);
194
+ });
195
+ test('…and stopping the moment the control plane revokes it — mid-schedule, no restart', async () => {
196
+ let allowed = true;
197
+ const h = harness({ leaseAllows: true });
198
+ // Re-point the gate at a value the "control plane" can flip between polls.
199
+ h.deps.serviceUserIfPermitted = async () => ((0, lease_1.resolveAllowServiceTurns)({ lease: allowed, managed: true }).allowed ? SERVICE_IDENTITY : undefined);
200
+ await (0, serve_1.runBridgeTurn)(h.deps, 'night 1', cronFiring());
201
+ assert.strictEqual(h.runs.length, 1);
202
+ allowed = false; // operator clicks "stop allowing unattended runs"
203
+ await (0, serve_1.runBridgeTurn)(h.deps, 'night 2', cronFiring());
204
+ assert.strictEqual(h.runs.length, 2, 'the turn still runs — revoking the identity is not a kill switch');
205
+ assert.strictEqual(h.runs[1].credentialUserId, undefined, 'but it runs as NOBODY: no identity, so no data authority');
206
+ assert.strictEqual(h.runs[1].turnEnv, undefined, 'and no nonce is minted, so the data gateway fails it closed');
207
+ });
208
+ });
209
+ suite('cron acceptance: the gates are real, and human traffic is untouched', function () {
210
+ this.timeout(20_000);
211
+ test('managed deployment, lease silent ⇒ the job gets no identity and no nonce', async () => {
212
+ const h = harness({ leaseAllows: undefined, managed: true });
213
+ await (0, serve_1.runBridgeTurn)(h.deps, 'nightly', cronFiring());
214
+ assert.strictEqual(h.runs[0].credentialUserId, undefined);
215
+ assert.strictEqual(h.runs[0].turnEnv, undefined, 'absent means deny — a plane that never learned the field must not authorise the fleet');
216
+ });
217
+ test('unmanaged deployment ⇒ the env var is the only gate, and it suffices', async () => {
218
+ const h = harness({ leaseAllows: undefined, managed: false });
219
+ await (0, serve_1.runBridgeTurn)(h.deps, 'nightly', cronFiring());
220
+ assert.strictEqual(h.runs[0].credentialUserId, SERVICE_IDENTITY);
221
+ });
222
+ test('no AGENTHIPPO_SERVICE_USER ⇒ today\'s behaviour exactly: no identity, no data', async () => {
223
+ const h = harness({ leaseAllows: true });
224
+ h.deps.serviceUserIfPermitted = undefined; // as when the env var is unset
225
+ await (0, serve_1.runBridgeTurn)(h.deps, 'nightly', cronFiring());
226
+ assert.strictEqual(h.runs[0].credentialUserId, undefined);
227
+ assert.strictEqual(h.runs[0].turnEnv, undefined);
228
+ });
229
+ test('a HUMAN turn never takes the service branch, even with everything enabled', async () => {
230
+ const h = harness({ leaseAllows: true });
231
+ const alice = { ...cronFiring(), peerInfo: { channel: 'slack', senderId: 'U-ALICE' } };
232
+ await (0, serve_1.runBridgeTurn)(h.deps, 'hello', alice);
233
+ await (0, serve_1.runBridgeTurn)(h.deps, 'again', alice);
234
+ assert.notStrictEqual(h.runs[0].credentialUserId, SERVICE_IDENTITY, 'a real sender must never be promoted to the service identity');
235
+ assert.strictEqual(h.runs[0].sessionId, h.runs[1].sessionId, 'a human keeps ONE durable session across turns — the fresh-key branch is service-only');
236
+ assert.strictEqual(h.prepared.length, 2, 'and their state is restored each turn, as before');
237
+ });
238
+ test('a human hitting the per-session cap is still refused — the fix did not disable the cap', async () => {
239
+ const h = harness({ leaseAllows: true });
240
+ const alice = { ...cronFiring(), peerInfo: { channel: 'slack', senderId: 'U-ALICE' } };
241
+ const outcomes = [];
242
+ for (let i = 0; i < TURN_CAP + 2; i++) {
243
+ outcomes.push((await (0, serve_1.runBridgeTurn)(h.deps, `msg ${i}`, alice)).success);
244
+ }
245
+ assert.deepStrictEqual(outcomes, [true, true, true, false, false], 'the durable human path must keep enforcing maxTurnsPerSession');
246
+ });
247
+ });
248
+ /**
249
+ * "…in ONE container" — observed, not inferred.
250
+ *
251
+ * The suite above asserts the sandbox KEY is stable across runs. That is strong evidence but it is
252
+ * still inference: `sandboxKey` is what the sandbox runner keys on, so a stable key SHOULD mean one
253
+ * sandbox — unless the runner churns for a reason the key cannot see.
254
+ *
255
+ * These drive `runBridgeTurn` into a REAL `createSandboxRunner`, and count `driver.create` calls.
256
+ * That is the actual question ("how many sandboxes came into existence?") rather than a proxy for
257
+ * it, and it exercises the whole chain: bridge turn → session-key derivation → runner → driver.
258
+ *
259
+ * Tier 0 (process). Tier 2 (container) needs Linux — see the note at the end of this file.
260
+ */
261
+ suite('cron acceptance: one sandbox across the whole schedule (observed)', function () {
262
+ this.timeout(20_000);
263
+ const RUNTIME = { logger: { info() { }, warn() { }, error() { }, debug() { } } };
264
+ /** Records every sandbox the runner actually builds, keyed as the driver saw it. */
265
+ function countingDriver() {
266
+ const created = [];
267
+ const destroyed = [];
268
+ let seq = 0;
269
+ const driver = {
270
+ kind: 'process', tier: 0,
271
+ async create(spec) {
272
+ const id = `sandbox-${++seq}`;
273
+ created.push(spec.sandboxKey);
274
+ const toRunner = new node_stream_1.PassThrough();
275
+ const toServe = new node_stream_1.PassThrough();
276
+ (0, host_1.startRunnerHost)({
277
+ engine: {
278
+ id: 'e', displayName: 'E',
279
+ async stream(message, _o, emitter) {
280
+ await emitter.text(`ECHO:${message}`);
281
+ },
282
+ onMaintenance() { }, dispose() { },
283
+ },
284
+ runtime: RUNTIME, input: toRunner,
285
+ output: { write: (c) => toServe.write(c) },
286
+ maintenanceIntervalMs: 60_000,
287
+ });
288
+ return {
289
+ id, tier: 0, endpoint: 'stdio',
290
+ connect: () => ({
291
+ input: toServe,
292
+ output: { write: (c) => toRunner.write(c) },
293
+ closed: new Promise(() => undefined),
294
+ }),
295
+ };
296
+ },
297
+ async revoke() { },
298
+ async destroy(handle) { destroyed.push(handle.id); },
299
+ };
300
+ return { driver, created, destroyed };
301
+ }
302
+ function sandboxHarness(scope = 'session') {
303
+ const { driver, created } = countingDriver();
304
+ const runner = (0, sandboxRunner_1.createSandboxRunner)({
305
+ driver, image: `img@sha256:${'a'.repeat(64)}`, tier: 0, routes: [], runtime: RUNTIME,
306
+ });
307
+ const base = harness({ leaseAllows: true });
308
+ base.deps.runner = runner;
309
+ base.deps.sandboxScope = scope;
310
+ return { deps: base.deps, runner, created };
311
+ }
312
+ test('THE CLAIM: five nightly runs build ONE sandbox, and reuse it', async () => {
313
+ const h = sandboxHarness();
314
+ for (let night = 1; night <= 5; night++) {
315
+ const r = await (0, serve_1.runBridgeTurn)(h.deps, `night ${night}`, cronFiring());
316
+ assert.strictEqual(r.success, true, `night ${night} must succeed`);
317
+ }
318
+ assert.strictEqual(h.created.length, 1, `the driver built ${h.created.length} sandboxes across 5 runs; the schedule must reuse one`);
319
+ assert.strictEqual(h.created[0], `gateway-${SERVICE_IDENTITY}`);
320
+ const live = h.runner.sandboxes();
321
+ assert.strictEqual(live.length, 1, 'exactly one live sandbox');
322
+ assert.strictEqual(live[0].sessionKeys.length, 5, 'all five conversations ran on it — fresh session, warm sandbox');
323
+ });
324
+ test('and the kill switch still has ONE handle for the whole schedule', async () => {
325
+ const h = sandboxHarness();
326
+ await (0, serve_1.runBridgeTurn)(h.deps, 'night 1', cronFiring());
327
+ await (0, serve_1.runBridgeTurn)(h.deps, 'night 2', cronFiring());
328
+ // An operator kills the bot by its stable key, not by guessing last night's session id.
329
+ await h.runner.destroySession(`gateway-${SERVICE_IDENTITY}`);
330
+ assert.strictEqual(h.runner.sandboxes().length, 0, 'one destroy must end the whole schedule, not one night of it');
331
+ });
332
+ test('CONTRAST: without the stable key, five runs would build five sandboxes', async () => {
333
+ // The same driver, driven with a fresh key per run — what a naive "fresh session per run"
334
+ // fix produces. This is the cost the sandboxKey/sessionKey split exists to avoid, and it is
335
+ // executed rather than described so the number is real.
336
+ const { driver, created } = countingDriver();
337
+ const runner = (0, sandboxRunner_1.createSandboxRunner)({
338
+ driver, image: `img@sha256:${'a'.repeat(64)}`, tier: 0, routes: [], runtime: RUNTIME,
339
+ });
340
+ for (let night = 1; night <= 5; night++) {
341
+ await runner.run({
342
+ engineId: 'e', modelId: 'm', message: `night ${night}`, workspaceRoot: '/w',
343
+ sessionId: `gateway-svc--run-${night}`, sandboxKey: `gateway-svc--run-${night}`,
344
+ });
345
+ }
346
+ assert.strictEqual(created.length, 5, 'five cold starts — the outcome the split avoids');
347
+ assert.strictEqual(runner.sandboxes().length, 5, 'and five live sandboxes to account for');
348
+ });
349
+ test('human traffic keeps one sandbox per conversation, as before', async () => {
350
+ const h = sandboxHarness();
351
+ const alice = { ...cronFiring(), peerInfo: { channel: 'slack', senderId: 'U-ALICE' } };
352
+ await (0, serve_1.runBridgeTurn)(h.deps, 'hi', alice);
353
+ await (0, serve_1.runBridgeTurn)(h.deps, 'again', alice);
354
+ assert.strictEqual(h.created.length, 1, 'a durable human session reuses its sandbox');
355
+ const bob = { ...cronFiring(), peerInfo: { channel: 'slack', senderId: 'U-BOB' } };
356
+ await (0, serve_1.runBridgeTurn)(h.deps, 'hi', bob);
357
+ assert.strictEqual(h.created.length, 2, 'and a different person never cohabits');
358
+ });
359
+ });
360
+ /*
361
+ * NOT COVERED HERE, deliberately:
362
+ *
363
+ * tier 2 (container) — needs Linux; this host is arm64 macOS and the runtime images are amd64.
364
+ * The runner logic above is tier-independent (it keys on sandboxKey before
365
+ * the driver is consulted), so the arithmetic holds, but `docker ps`
366
+ * showing one container is a Linux-host check.
367
+ * the gateway socket — these call runBridgeTurn directly; a bridge that never routes messages
368
+ * to it would still pass.
369
+ * the cron daemon — "night N" is a loop, not a scheduler.
370
+ */
@@ -265,3 +265,125 @@ suite('runGatewayTurn: trackSession=false skips the TurnLimiter for one-off keys
265
265
  assert.strictEqual(ran, false);
266
266
  });
267
267
  });
268
+ suite('resolveGatewaySessionKey: sandbox scope agrees with the HTTP path', () => {
269
+ test('session scope leaves the sandbox key equal to the session key', () => {
270
+ const r = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42' }, 'alice', 'session');
271
+ assert.strictEqual(r.sandboxKey, r.sessionKey);
272
+ });
273
+ /**
274
+ * The two resolvers must derive the SAME sandbox key for one person. If they disagreed, a user
275
+ * reaching this serve over HTTP and over a channel would get two sandboxes under user scope —
276
+ * neither the old behaviour nor the new one.
277
+ */
278
+ test('user scope: HTTP and gateway put one person in one sandbox', () => {
279
+ const gateway = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42' }, 'alice', 'user');
280
+ const http = (0, serve_hardening_1.resolveServeSessionKey)('chat-42', 'alice', 'user', {});
281
+ assert.strictEqual(gateway.sandboxKey, http.sandboxKey);
282
+ assert.strictEqual(gateway.sandboxKey, `u${(0, serve_hardening_1.userNamespaceHash)('alice')}`);
283
+ });
284
+ test('user scope: a sender with no session id lands in the same sandbox as one with', () => {
285
+ const withId = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42' }, 'slack:U0B6', 'user');
286
+ const withoutId = (0, serve_1.resolveGatewaySessionKey)({}, 'slack:U0B6', 'user');
287
+ assert.strictEqual(withId.sandboxKey, withoutId.sandboxKey);
288
+ assert.notStrictEqual(withId.sessionKey, withoutId.sessionKey, 'still two conversations');
289
+ });
290
+ test('an unidentified sender keeps its own sandbox under either scope', () => {
291
+ const a = (0, serve_1.resolveGatewaySessionKey)({}, undefined, 'user');
292
+ const b = (0, serve_1.resolveGatewaySessionKey)({}, undefined, 'user');
293
+ assert.strictEqual(a.sandboxKey, a.sessionKey);
294
+ assert.notStrictEqual(a.sandboxKey, b.sandboxKey);
295
+ assert.strictEqual(a.durable, false);
296
+ });
297
+ });
298
+ /**
299
+ * PLAN/agent-identity.md §5.3–§5.4 — service turns (build record §10.4).
300
+ *
301
+ * The defect these pin is not hypothetical: `TurnLimiter` is a monotonic per-session counter with
302
+ * no window, no reset and no decay, and `resolveGatewaySessionKey` returns a STABLE key for any
303
+ * identified sender. Composed, a nightly cron job burns one turn per night from a counter that
304
+ * never resets and, on night `maxTurnsPerSession + 1`, is rejected with `turn_limit` forever —
305
+ * recovering only on a serve restart. A job that dies after N nights, where N is a cap nobody
306
+ * associates with cron.
307
+ */
308
+ suite('resolveGatewaySessionKey: service turns (PR-3 — fresh conversation, warm sandbox)', () => {
309
+ const SVC = 'svc:invoice-agent';
310
+ test('THE REGRESSION: N runs past the cap are all admitted, because the key is fresh per run', async () => {
311
+ const cap = 3;
312
+ const limiter = new serve_hardening_1.TurnLimiter(cap);
313
+ const admitted = [];
314
+ for (let run = 0; run < cap + 2; run++) {
315
+ const keys = (0, serve_1.resolveGatewaySessionKey)({}, SVC, 'session', true);
316
+ assert.strictEqual(keys.durable, false, 'a service run must not take the durable branch');
317
+ // trackSession mirrors `durable` at the call site, so this is what the bridge does.
318
+ const r = await (0, serve_1.runGatewayTurn)(baseParams({
319
+ turnLimiter: limiter,
320
+ sessionKey: keys.sessionKey,
321
+ trackSession: keys.durable,
322
+ run: async () => ({ success: true }),
323
+ }));
324
+ admitted.push(r.success);
325
+ }
326
+ assert.deepStrictEqual(admitted, [true, true, true, true, true], 'every run must be admitted; a false here is the nightly-job death returning');
327
+ });
328
+ test('contrast: the SAME identity on the durable path dies on run cap+1 — this is what was shipping', async () => {
329
+ const limiter = new serve_hardening_1.TurnLimiter(3);
330
+ const durable = (0, serve_1.resolveGatewaySessionKey)({}, SVC, 'session', false);
331
+ assert.strictEqual(durable.durable, true);
332
+ const admitted = [];
333
+ for (let run = 0; run < 5; run++) {
334
+ const r = await (0, serve_1.runGatewayTurn)(baseParams({
335
+ turnLimiter: limiter,
336
+ sessionKey: durable.sessionKey,
337
+ trackSession: durable.durable,
338
+ emitter: fakeEmitter(),
339
+ run: async () => ({ success: true }),
340
+ }));
341
+ admitted.push(r.success);
342
+ }
343
+ assert.deepStrictEqual(admitted, [true, true, true, false, false], 'demonstrating the defect the fresh key exists to avoid');
344
+ });
345
+ test('sessionKey is fresh per run but sandboxKey is STABLE — one warm container, not one per night', () => {
346
+ const a = (0, serve_1.resolveGatewaySessionKey)({}, SVC, 'session', true);
347
+ const b = (0, serve_1.resolveGatewaySessionKey)({}, SVC, 'session', true);
348
+ assert.notStrictEqual(a.sessionKey, b.sessionKey, 'a reused session key re-introduces the limiter defect');
349
+ assert.strictEqual(a.sandboxKey, b.sandboxKey, 'a fresh sandbox key per run means a new container every night: cold start, a new governor '
350
+ + 'entry per run, and maxSessionSeconds that never accumulates');
351
+ assert.strictEqual(a.sandboxKey, `gateway-${SVC}`);
352
+ });
353
+ test('the stable sandbox key is also the one handle caps and the kill switch act on', () => {
354
+ // Both scopes must agree, or a kill aimed at the bot would miss under one of them.
355
+ assert.strictEqual((0, serve_1.resolveGatewaySessionKey)({}, SVC, 'session', true).sandboxKey, (0, serve_1.resolveGatewaySessionKey)({}, SVC, 'user', true).sandboxKey);
356
+ });
357
+ test('outboundKey is preserved, so a cron job that messages Slack keeps replying', () => {
358
+ const withChannel = (0, serve_1.resolveGatewaySessionKey)({ gatewaySessionKey: 'slack-C123' }, SVC, 'session', true);
359
+ assert.strictEqual(withChannel.outboundKey, 'slack-C123');
360
+ // Across runs the reply destination must not move, even though the session key does.
361
+ const next = (0, serve_1.resolveGatewaySessionKey)({ gatewaySessionKey: 'slack-C123' }, SVC, 'session', true);
362
+ assert.strictEqual(next.outboundKey, withChannel.outboundKey);
363
+ assert.notStrictEqual(next.sessionKey, withChannel.sessionKey);
364
+ });
365
+ test('with no channel key the outbound key still resolves to something stable', () => {
366
+ const r = (0, serve_1.resolveGatewaySessionKey)({}, SVC, 'session', true);
367
+ assert.strictEqual(r.outboundKey, `gateway-${SVC}`);
368
+ });
369
+ test('the flag is explicit — an identity that merely LOOKS like a service id takes the human path', () => {
370
+ // Behaviour is never derived from the shape of an identity string: §5.2 deliberately allows
371
+ // identities with no recognisable prefix (a SPIFFE ID, an Entra GUID), so prefix-sniffing
372
+ // would be both wrong and unfixable.
373
+ const sniffable = (0, serve_1.resolveGatewaySessionKey)({}, SVC, 'session', false);
374
+ assert.strictEqual(sniffable.durable, true, 'the svc: prefix alone must not change behaviour');
375
+ assert.strictEqual(sniffable.sessionKey, `gateway-${SVC}`);
376
+ });
377
+ test('human traffic is untouched by the new parameter', () => {
378
+ const before = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42' }, 'alice', 'session');
379
+ const after = (0, serve_1.resolveGatewaySessionKey)({ sessionId: 'chat-42' }, 'alice', 'session', false);
380
+ assert.deepStrictEqual(before, after);
381
+ });
382
+ test('an unidentified sender cannot reach the service branch even if the flag is set', () => {
383
+ // Defence against a future caller that passes the flag without an identity: the anonymous
384
+ // one-off path is still the right answer, not a `gateway-undefined` bucket.
385
+ const r = (0, serve_1.resolveGatewaySessionKey)({}, undefined, 'session', true);
386
+ assert.strictEqual(r.durable, false);
387
+ assert.match(r.sessionKey, /^gateway-anon-/);
388
+ });
389
+ });