@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.
- package/out/agentpack/index.js +3 -1
- package/out/agentpack/paths.js +25 -2
- package/out/analytics/dashboard/localFleetAdapter.js +82 -2
- package/out/analytics/dashboard/provider.js +13 -2
- package/out/analytics/dashboard/webview/panel.js +3 -2
- package/out/analytics/identity.js +0 -0
- package/out/analytics/service.js +29 -25
- package/out/analytics/sessionEvents.js +100 -24
- package/out/auth/authBaseUrl.js +59 -0
- package/out/auth/bootstrapApply.js +9 -10
- package/out/auth/brokerClient.js +48 -14
- package/out/auth/provider.js +8 -21
- package/out/auth/sessionStore.js +3 -40
- package/out/auth/signInFlow.js +2 -1
- package/out/auth-broker/config.js +50 -5
- package/out/auth-broker/server.js +126 -26
- package/out/auth-broker/store.js +25 -24
- package/out/authority/warrant.js +206 -0
- package/out/branding.js +27 -1
- package/out/cli/commands/conformance.js +249 -0
- package/out/cli/commands/data-plane.js +151 -0
- package/out/cli/commands/deploy.js +1 -0
- package/out/cli/commands/gateway.js +4 -0
- package/out/cli/commands/pack.js +182 -3
- package/out/cli/commands/sandbox.js +351 -0
- package/out/cli/commands/serve.js +1145 -130
- package/out/cli/commands/store.js +12 -4
- package/out/cli/credential-context.js +17 -0
- package/out/cli/data-plane/gateway.js +28 -5
- package/out/cli/data-plane/index.js +18 -2
- package/out/cli/data-plane/registry.js +9 -0
- package/out/cli/data-plane/remote.js +106 -0
- package/out/cli/data-plane/sidecar.js +225 -0
- package/out/cli/deploy/layers/identity.js +88 -4
- package/out/cli/deploy-init.js +89 -24
- package/out/cli/deps.js +106 -2
- package/out/cli/idp-init.js +35 -0
- package/out/cli/index.js +171 -17
- package/out/cli/pack-install-ref.js +15 -0
- package/out/cli/pack-loader.js +8 -1
- package/out/cli/pack-runtime.js +61 -0
- package/out/cli/runtime.js +35 -1
- package/out/cli/serve-hardening.js +260 -14
- package/out/cli/utils.js +12 -1
- package/out/engines/base.js +32 -3
- package/out/engines/claude.js +33 -15
- package/out/engines/codex.js +111 -27
- package/out/engines/custom.js +15 -1
- package/out/evidence/sessionShip.js +643 -0
- package/out/extension.js +11 -2
- package/out/gateway/bridge.js +40 -2
- package/out/gateway/client.js +34 -1
- package/out/gateway/installSource.js +68 -0
- package/out/gateway/lifecycle.js +185 -51
- package/out/gateway/outboundPaths.js +4 -1
- package/out/gateway/secretsPreflight.js +90 -0
- package/out/gateway/service.js +7 -2
- package/out/headless/index.js +25 -2
- package/out/live-e2e/mcp-demo/server.mjs +32 -0
- package/out/mcp.js +30 -0
- package/out/pack/caps.js +271 -0
- package/out/pack/governor.js +326 -0
- package/out/pack/lease-schema.js +273 -0
- package/out/pack/lease.js +682 -0
- package/out/pack/reconcile.js +217 -0
- package/out/pack/signature.js +839 -0
- package/out/remote/handleRemoteChat.js +44 -3
- package/out/remote/remoteTrust.js +277 -0
- package/out/remote/serveClient.js +52 -5
- package/out/runner/host.js +141 -0
- package/out/runner/protocol.js +128 -0
- package/out/runner/remoteEngine.js +166 -0
- package/out/runtime/interfaces/edit-tracker.js +2 -0
- package/out/runtime/vscode/vscode-edit-tracker.js +2 -0
- package/out/sandbox/containerDriver.js +572 -0
- package/out/sandbox/processDriver.js +318 -0
- package/out/sandbox/sandboxRunner.js +303 -0
- package/out/sandbox/support.js +301 -0
- package/out/sandbox/types.js +55 -0
- package/out/scripts/agentide-litellm.sh +67 -5
- package/out/scripts/agentide_litellm_hooks_v1.py +266 -21
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +98 -132
- package/out/scripts/analytics/setup-analytics.ps1 +45 -14
- package/out/scripts/analytics/setup-analytics.sh +106 -15
- package/out/scripts/analytics/start-analytics.sh +86 -14
- package/out/scripts/gateway/setup-gateway.sh +72 -32
- package/out/shared/models.js +11 -110
- package/out/spotlight/install.js +7 -1
- package/out/store/index.js +41 -10
- package/out/sync.js +3 -1
- package/out/test/analytics.test.js +162 -23
- package/out/test/auth-baseurl.test.js +132 -0
- package/out/test/auth-bootstrapApply.test.js +27 -6
- package/out/test/auth-broker-hardening.test.js +227 -0
- package/out/test/auth-broker.test.js +59 -9
- package/out/test/branding.test.js +13 -0
- package/out/test/caps-governor.test.js +460 -0
- package/out/test/codex-new-file-survival.test.js +119 -0
- package/out/test/configuration-scopes.test.js +131 -0
- package/out/test/data-plane-engine-wiring.test.js +32 -0
- package/out/test/data-plane.test.js +380 -0
- package/out/test/deploy-init.test.js +47 -1
- package/out/test/gateway-client.test.js +28 -0
- package/out/test/gateway-install-source.test.js +111 -0
- package/out/test/gateway-lifecycle.test.js +1 -1
- package/out/test/gateway-outbound.test.js +7 -0
- package/out/test/gateway-secrets-preflight.test.js +127 -0
- package/out/test/gateway-session-routing.test.js +18 -0
- package/out/test/governor-hardening.test.js +222 -0
- package/out/test/headless-sync.test.js +22 -0
- package/out/test/lease-signature.test.js +357 -0
- package/out/test/lease-trust-root.test.js +273 -0
- package/out/test/lease-validation.test.js +224 -0
- package/out/test/lease.test.js +114 -0
- package/out/test/localFleetAdapter.test.js +91 -0
- package/out/test/mcp.test.js +27 -1
- package/out/test/pack-admission.test.js +216 -0
- package/out/test/pack-digest-parity.test.js +140 -0
- package/out/test/pack-reconcile.test.js +183 -0
- package/out/test/pack-signature-artifact.test.js +103 -0
- package/out/test/pack-signature.test.js +176 -0
- package/out/test/pack-trust-root.test.js +202 -0
- package/out/test/remote-trust.test.js +242 -0
- package/out/test/runner-protocol.test.js +249 -0
- package/out/test/sandbox-driver.test.js +553 -0
- package/out/test/sandbox-hardening.test.js +406 -0
- package/out/test/sandbox-runner.test.js +343 -0
- package/out/test/sandbox-scope.test.js +449 -0
- package/out/test/serve-cron-acceptance.test.js +370 -0
- package/out/test/serve-gateway-turn.test.js +122 -0
- package/out/test/serve-hardening.test.js +227 -15
- package/out/test/serveClient.test.js +6 -5
- package/out/test/sessionShip.test.js +618 -0
- package/out/test/spotlightPublish.test.js +28 -0
- package/out/test/store-install-safe.test.js +69 -0
- package/out/test/tls-bypass.test.js +90 -0
- package/out/test/warrant.test.js +138 -0
- package/out/utils.js +46 -9
- package/package.json +1 -1
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Sandbox scope + stream demux.
|
|
4
|
+
*
|
|
5
|
+
* Three defects are pinned here, in the order they bite:
|
|
6
|
+
* - streamed events carried no run id, so two turns on one sandbox wrote into each other's
|
|
7
|
+
* transcript. Deterministic, not a race — the old dispatch broadcast every event.
|
|
8
|
+
* - `sandboxFor` checked the map and then awaited twice before writing it back, so two turns
|
|
9
|
+
* arriving together each built a sandbox. The loser stayed LIVE and unreachable: destroy,
|
|
10
|
+
* revoke and TTL all missed it.
|
|
11
|
+
* - one sandbox may now hold several of a user's sessions, and they must stay distinct inside
|
|
12
|
+
* it (the run frame's sessionId) while destroy/revoke still work when named by either key.
|
|
13
|
+
*--------------------------------------------------------------------------------------------*/
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
require("mocha");
|
|
49
|
+
const assert = __importStar(require("assert"));
|
|
50
|
+
const node_stream_1 = require("node:stream");
|
|
51
|
+
const sandboxRunner_1 = require("../sandbox/sandboxRunner");
|
|
52
|
+
const remoteEngine_1 = require("../runner/remoteEngine");
|
|
53
|
+
const protocol_1 = require("../runner/protocol");
|
|
54
|
+
const host_1 = require("../runner/host");
|
|
55
|
+
const RUNTIME = { logger: { info() { }, warn() { }, error() { }, debug() { } } };
|
|
56
|
+
/** Collects text deltas so a turn's own output can be told from its neighbour's. */
|
|
57
|
+
function collector() {
|
|
58
|
+
const text_ = [];
|
|
59
|
+
return {
|
|
60
|
+
text_,
|
|
61
|
+
async thinking() { }, async text(d) { text_.push(d); },
|
|
62
|
+
async toolStart() { }, async toolEnd() { }, async progress() { },
|
|
63
|
+
async question() { return { selected: [], freeText: null, declined: true }; },
|
|
64
|
+
async error() { }, async done() { },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const tick = () => new Promise((r) => setTimeout(r, 5));
|
|
68
|
+
async function waitFor(cond, what, ms = 2000) {
|
|
69
|
+
const deadline = Date.now() + ms;
|
|
70
|
+
while (!cond()) {
|
|
71
|
+
if (Date.now() > deadline) {
|
|
72
|
+
throw new Error(`timed out waiting for ${what}`);
|
|
73
|
+
}
|
|
74
|
+
await tick();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
suite('runner protocol — tagged streamed events', () => {
|
|
78
|
+
test('a tagged event round-trips and is recognised as tagged', () => {
|
|
79
|
+
const line = (0, protocol_1.encodeRunnerFrame)({ id: 'r7', event: { type: 'text', delta: 'hi' } });
|
|
80
|
+
const parsed = (0, protocol_1.parseRunnerFrame)(line);
|
|
81
|
+
assert.ok(parsed && (0, protocol_1.isTaggedEvent)(parsed), 'must parse back as a tagged event');
|
|
82
|
+
assert.strictEqual(parsed.id, 'r7');
|
|
83
|
+
assert.deepStrictEqual(parsed.event, { type: 'text', delta: 'hi' });
|
|
84
|
+
});
|
|
85
|
+
test('an UNTAGGED event still parses — a pinned older image keeps working', () => {
|
|
86
|
+
const parsed = (0, protocol_1.parseRunnerFrame)(JSON.stringify({ type: 'text', delta: 'legacy' }));
|
|
87
|
+
assert.ok(parsed && !(0, protocol_1.isTaggedEvent)(parsed), 'legacy events stay untagged, not rejected');
|
|
88
|
+
});
|
|
89
|
+
test('an envelope whose inner event is not a real event type is rejected', () => {
|
|
90
|
+
// Accepting it would hand applyParsedRunnerEvent something its switch ignores, and the turn
|
|
91
|
+
// would look like it streamed nothing at all.
|
|
92
|
+
assert.strictEqual((0, protocol_1.parseRunnerFrame)(JSON.stringify({ id: 'r1', event: { type: 'nope' } })), undefined);
|
|
93
|
+
assert.strictEqual((0, protocol_1.parseRunnerFrame)(JSON.stringify({ id: 'r1', event: 'text' })), undefined);
|
|
94
|
+
});
|
|
95
|
+
test('the demux capability rides `ready`, and its absence is not an error', () => {
|
|
96
|
+
assert.deepStrictEqual((0, protocol_1.parseRunnerFrame)(JSON.stringify({ type: 'ready', demux: true })), { type: 'ready', demux: true });
|
|
97
|
+
assert.deepStrictEqual((0, protocol_1.parseRunnerFrame)(JSON.stringify({ type: 'ready' })), { type: 'ready' });
|
|
98
|
+
});
|
|
99
|
+
test('terminal frames are still terminal, and a tagged event is not', () => {
|
|
100
|
+
const result = (0, protocol_1.parseRunnerFrame)(JSON.stringify({ type: 'result', id: 'r1' }));
|
|
101
|
+
const event = (0, protocol_1.parseRunnerFrame)(JSON.stringify({ id: 'r1', event: { type: 'done' } }));
|
|
102
|
+
assert.ok(result && (0, protocol_1.isTerminalFrame)(result));
|
|
103
|
+
assert.ok(event && !(0, protocol_1.isTerminalFrame)(event), 'a streamed event must never settle a turn');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
/** A channel whose runner side is driven frame by frame from the test. */
|
|
107
|
+
function scriptedChannel() {
|
|
108
|
+
const toServe = new node_stream_1.PassThrough();
|
|
109
|
+
const toRunner = new node_stream_1.PassThrough();
|
|
110
|
+
const sent = [];
|
|
111
|
+
let buffer = '';
|
|
112
|
+
toRunner.on('data', (chunk) => {
|
|
113
|
+
buffer += chunk.toString('utf8');
|
|
114
|
+
const lines = buffer.split('\n');
|
|
115
|
+
buffer = lines.pop() ?? '';
|
|
116
|
+
for (const line of lines) {
|
|
117
|
+
if (line.trim()) {
|
|
118
|
+
sent.push(JSON.parse(line));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
sent,
|
|
124
|
+
channel: {
|
|
125
|
+
input: toServe,
|
|
126
|
+
output: { write: (c) => toRunner.write(c) },
|
|
127
|
+
closed: new Promise(() => undefined),
|
|
128
|
+
},
|
|
129
|
+
emit: (frame) => { toServe.write(`${JSON.stringify(frame)}\n`); },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
suite('RemoteEngine — one turn\'s output reaches only that turn', () => {
|
|
133
|
+
test('tagged events route to their own turn; neither transcript sees the other', async () => {
|
|
134
|
+
const { channel, emit } = scriptedChannel();
|
|
135
|
+
const engine = new remoteEngine_1.RemoteEngine({ id: 's', displayName: 'S', channel });
|
|
136
|
+
const a = collector();
|
|
137
|
+
const b = collector();
|
|
138
|
+
// Run ids are assigned in order: r1, r2. `pending` is populated synchronously by stream().
|
|
139
|
+
const pa = engine.stream('a', { sessionId: 's1' }, a, RUNTIME);
|
|
140
|
+
const pb = engine.stream('b', { sessionId: 's2' }, b, RUNTIME);
|
|
141
|
+
emit({ id: 'r1', event: { type: 'text', delta: 'A1' } });
|
|
142
|
+
emit({ id: 'r2', event: { type: 'text', delta: 'B1' } });
|
|
143
|
+
emit({ id: 'r1', event: { type: 'text', delta: 'A2' } });
|
|
144
|
+
emit({ type: 'result', id: 'r1' });
|
|
145
|
+
emit({ type: 'result', id: 'r2' });
|
|
146
|
+
await Promise.all([pa, pb]);
|
|
147
|
+
assert.deepStrictEqual(a.text_, ['A1', 'A2'], 'turn A sees only its own deltas');
|
|
148
|
+
assert.deepStrictEqual(b.text_, ['B1'], 'turn B sees only its own deltas');
|
|
149
|
+
});
|
|
150
|
+
test('an UNTAGGED event still broadcasts — the legacy peer contract is unchanged', async () => {
|
|
151
|
+
const { channel, emit } = scriptedChannel();
|
|
152
|
+
const engine = new remoteEngine_1.RemoteEngine({ id: 's', displayName: 'S', channel });
|
|
153
|
+
const a = collector();
|
|
154
|
+
const b = collector();
|
|
155
|
+
const pa = engine.stream('a', {}, a, RUNTIME);
|
|
156
|
+
const pb = engine.stream('b', {}, b, RUNTIME);
|
|
157
|
+
emit({ type: 'text', delta: 'shared' });
|
|
158
|
+
emit({ type: 'result', id: 'r1' });
|
|
159
|
+
emit({ type: 'result', id: 'r2' });
|
|
160
|
+
await Promise.all([pa, pb]);
|
|
161
|
+
// This is exactly why an untagged peer must be given one turn at a time.
|
|
162
|
+
assert.deepStrictEqual(a.text_, ['shared']);
|
|
163
|
+
assert.deepStrictEqual(b.text_, ['shared']);
|
|
164
|
+
});
|
|
165
|
+
test('demux is assumed OFF until the peer proves otherwise', async () => {
|
|
166
|
+
const { channel, emit } = scriptedChannel();
|
|
167
|
+
const engine = new remoteEngine_1.RemoteEngine({ id: 's', displayName: 'S', channel });
|
|
168
|
+
assert.strictEqual(engine.supportsDemux(), false, 'a run frame can be sent before `ready` arrives');
|
|
169
|
+
const p = engine.stream('a', {}, collector(), RUNTIME);
|
|
170
|
+
emit({ type: 'ready', demux: true });
|
|
171
|
+
await waitFor(() => engine.supportsDemux(), 'the ready handshake');
|
|
172
|
+
emit({ type: 'result', id: 'r1' });
|
|
173
|
+
await p;
|
|
174
|
+
});
|
|
175
|
+
test('a legacy `ready` leaves demux off', async () => {
|
|
176
|
+
const { channel, emit } = scriptedChannel();
|
|
177
|
+
const engine = new remoteEngine_1.RemoteEngine({ id: 's', displayName: 'S', channel });
|
|
178
|
+
const p = engine.stream('a', {}, collector(), RUNTIME);
|
|
179
|
+
emit({ type: 'ready' });
|
|
180
|
+
await tick();
|
|
181
|
+
assert.strictEqual(engine.supportsDemux(), false);
|
|
182
|
+
emit({ type: 'result', id: 'r1' });
|
|
183
|
+
await p;
|
|
184
|
+
});
|
|
185
|
+
test('an event arriving after its turn settled is dropped, not thrown', async () => {
|
|
186
|
+
const { channel, emit } = scriptedChannel();
|
|
187
|
+
const engine = new remoteEngine_1.RemoteEngine({ id: 's', displayName: 'S', channel });
|
|
188
|
+
const a = collector();
|
|
189
|
+
const p = engine.stream('a', {}, a, RUNTIME);
|
|
190
|
+
emit({ type: 'result', id: 'r1' });
|
|
191
|
+
await p;
|
|
192
|
+
emit({ id: 'r1', event: { type: 'text', delta: 'late' } });
|
|
193
|
+
await tick();
|
|
194
|
+
assert.deepStrictEqual(a.text_, [], 'a late event names a turn that no longer exists');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
/** Driver over real runner hosts, with a create that can be held open to force a race. */
|
|
198
|
+
function raceableDriver(engineFor, opts = {}) {
|
|
199
|
+
const created = [];
|
|
200
|
+
const destroyed = [];
|
|
201
|
+
let seq = 0;
|
|
202
|
+
const driver = {
|
|
203
|
+
kind: 'process', tier: 0,
|
|
204
|
+
async create(spec) {
|
|
205
|
+
created.push(spec);
|
|
206
|
+
if (opts.createDelayMs) {
|
|
207
|
+
await new Promise((r) => setTimeout(r, opts.createDelayMs));
|
|
208
|
+
}
|
|
209
|
+
const id = `sandbox-${++seq}`;
|
|
210
|
+
const toRunner = new node_stream_1.PassThrough();
|
|
211
|
+
const toServe = new node_stream_1.PassThrough();
|
|
212
|
+
(0, host_1.startRunnerHost)({
|
|
213
|
+
engine: engineFor(), runtime: RUNTIME, input: toRunner,
|
|
214
|
+
output: { write: (c) => toServe.write(c) },
|
|
215
|
+
maintenanceIntervalMs: 60_000,
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
id, tier: 0, endpoint: 'stdio',
|
|
219
|
+
connect: () => ({
|
|
220
|
+
input: toServe,
|
|
221
|
+
output: { write: (c) => toRunner.write(c) },
|
|
222
|
+
closed: new Promise(() => undefined),
|
|
223
|
+
}),
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
async revoke() { },
|
|
227
|
+
async destroy(handle) { destroyed.push(handle.id); },
|
|
228
|
+
};
|
|
229
|
+
return { driver, created, destroyed };
|
|
230
|
+
}
|
|
231
|
+
const base = (driver) => ({
|
|
232
|
+
driver, image: `img@sha256:${'a'.repeat(64)}`, tier: 0, routes: [], runtime: RUNTIME,
|
|
233
|
+
});
|
|
234
|
+
/** Records the sessionId each turn arrived with, and how many ran at once. */
|
|
235
|
+
function recordingEngine(state) {
|
|
236
|
+
return {
|
|
237
|
+
id: 'r', displayName: 'R',
|
|
238
|
+
async stream(message, options, emitter) {
|
|
239
|
+
state.seen.push(options.sessionId ?? '<none>');
|
|
240
|
+
state.active++;
|
|
241
|
+
state.max = Math.max(state.max, state.active);
|
|
242
|
+
if (state.hold) {
|
|
243
|
+
await state.hold;
|
|
244
|
+
}
|
|
245
|
+
await emitter.text(`T:${message}`);
|
|
246
|
+
state.active--;
|
|
247
|
+
},
|
|
248
|
+
onMaintenance() { }, dispose() { },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
suite('sandbox creation is single-flight', () => {
|
|
252
|
+
test('two turns arriving together build ONE sandbox, not two', async () => {
|
|
253
|
+
const state = { seen: [], active: 0, max: 0 };
|
|
254
|
+
const { driver, created } = raceableDriver(() => recordingEngine(state), { createDelayMs: 25 });
|
|
255
|
+
const runner = (0, sandboxRunner_1.createSandboxRunner)(base(driver));
|
|
256
|
+
// Both miss the map, and creation spans two awaits — the window the single-flight closes.
|
|
257
|
+
await Promise.all([
|
|
258
|
+
runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1', sandboxKey: 'u1' }),
|
|
259
|
+
runner.run({ engineId: 'e', modelId: 'm', message: 'b', workspaceRoot: '/w', sessionId: 's2', sandboxKey: 'u1' }),
|
|
260
|
+
]);
|
|
261
|
+
assert.strictEqual(created.length, 1, 'the loser of the race used to stay live and unreachable');
|
|
262
|
+
assert.strictEqual(runner.sandboxes().length, 1);
|
|
263
|
+
});
|
|
264
|
+
test('a failed creation is not cached — the next turn retries', async () => {
|
|
265
|
+
const state = { seen: [], active: 0, max: 0 };
|
|
266
|
+
const { driver } = raceableDriver(() => recordingEngine(state));
|
|
267
|
+
let allow = false;
|
|
268
|
+
const runner = (0, sandboxRunner_1.createSandboxRunner)({
|
|
269
|
+
...base(driver),
|
|
270
|
+
admit: () => { if (!allow) {
|
|
271
|
+
throw new Error('lease revoked');
|
|
272
|
+
} },
|
|
273
|
+
});
|
|
274
|
+
const denied = await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1' });
|
|
275
|
+
assert.strictEqual(denied.success, false);
|
|
276
|
+
assert.match(denied.error ?? '', /lease revoked/);
|
|
277
|
+
allow = true;
|
|
278
|
+
const ok = await runner.run({ engineId: 'e', modelId: 'm', message: 'b', workspaceRoot: '/w', sessionId: 's1' });
|
|
279
|
+
assert.strictEqual(ok.success, true, 'a cached rejected promise would refuse this forever');
|
|
280
|
+
});
|
|
281
|
+
test('admission sees the session that triggered the creation, not just the sandbox key', async () => {
|
|
282
|
+
const state = { seen: [], active: 0, max: 0 };
|
|
283
|
+
const { driver } = raceableDriver(() => recordingEngine(state));
|
|
284
|
+
const admitted = [];
|
|
285
|
+
const runner = (0, sandboxRunner_1.createSandboxRunner)({ ...base(driver), admit: (ctx) => { admitted.push(ctx); } });
|
|
286
|
+
await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1', sandboxKey: 'u1' });
|
|
287
|
+
// A deny rule can name a SESSION; under user scope the sandbox key names the user instead.
|
|
288
|
+
assert.deepStrictEqual(admitted, [{ sandboxKey: 'u1', sessionKey: 's1' }]);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
suite('one sandbox, several of a user\'s sessions', () => {
|
|
292
|
+
test('sessions sharing a sandbox stay distinct on the wire', async () => {
|
|
293
|
+
const state = { seen: [], active: 0, max: 0 };
|
|
294
|
+
const { driver, created } = raceableDriver(() => recordingEngine(state));
|
|
295
|
+
const runner = (0, sandboxRunner_1.createSandboxRunner)(base(driver));
|
|
296
|
+
await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 'u1--s1', sandboxKey: 'u1' });
|
|
297
|
+
await runner.run({ engineId: 'e', modelId: 'm', message: 'b', workspaceRoot: '/w', sessionId: 'u1--s2', sandboxKey: 'u1' });
|
|
298
|
+
assert.strictEqual(created.length, 1, 'one user, one sandbox');
|
|
299
|
+
// The whole mechanism: the frame still carries sessionId, so the in-sandbox runner keys
|
|
300
|
+
// engine state per conversation even though the container is shared.
|
|
301
|
+
assert.deepStrictEqual(state.seen, ['u1--s1', 'u1--s2']);
|
|
302
|
+
});
|
|
303
|
+
test('the sandbox key, not the session key, names the state directory and mounts', async () => {
|
|
304
|
+
const state = { seen: [], active: 0, max: 0 };
|
|
305
|
+
const { driver, created } = raceableDriver(() => recordingEngine(state));
|
|
306
|
+
const runner = (0, sandboxRunner_1.createSandboxRunner)({
|
|
307
|
+
...base(driver),
|
|
308
|
+
stateDirFor: (key) => `/state/${key}`,
|
|
309
|
+
fsFor: async (key) => ({ readonly: [], writable: [`/ws/${key}:/state/workspace`] }),
|
|
310
|
+
});
|
|
311
|
+
await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 'u1--s1', sandboxKey: 'u1' });
|
|
312
|
+
assert.strictEqual(created[0].stateDir, '/state/u1');
|
|
313
|
+
assert.deepStrictEqual(created[0].fs.writable, ['/ws/u1:/state/workspace']);
|
|
314
|
+
assert.strictEqual(created[0].sandboxKey, 'u1');
|
|
315
|
+
});
|
|
316
|
+
test('sandboxes() reports every session on a sandbox', async () => {
|
|
317
|
+
const state = { seen: [], active: 0, max: 0 };
|
|
318
|
+
const { driver } = raceableDriver(() => recordingEngine(state));
|
|
319
|
+
const runner = (0, sandboxRunner_1.createSandboxRunner)(base(driver));
|
|
320
|
+
await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1', sandboxKey: 'u1' });
|
|
321
|
+
await runner.run({ engineId: 'e', modelId: 'm', message: 'b', workspaceRoot: '/w', sessionId: 's2', sandboxKey: 'u1' });
|
|
322
|
+
const live = runner.sandboxes();
|
|
323
|
+
assert.strictEqual(live.length, 1);
|
|
324
|
+
assert.strictEqual(live[0].sandboxKey, 'u1');
|
|
325
|
+
// The governor matches deny rules per session, so it needs all of them.
|
|
326
|
+
assert.deepStrictEqual([...live[0].sessionKeys].sort(), ['s1', 's2']);
|
|
327
|
+
});
|
|
328
|
+
test('destroy works when named by a session OR by the sandbox key', async () => {
|
|
329
|
+
const state = { seen: [], active: 0, max: 0 };
|
|
330
|
+
const { driver, destroyed } = raceableDriver(() => recordingEngine(state));
|
|
331
|
+
const runner = (0, sandboxRunner_1.createSandboxRunner)(base(driver));
|
|
332
|
+
await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1', sandboxKey: 'u1' });
|
|
333
|
+
await runner.run({ engineId: 'e', modelId: 'm', message: 'b', workspaceRoot: '/w', sessionId: 's2', sandboxKey: 'u1' });
|
|
334
|
+
// A cap breach or deny rule names a session; the sandbox it shares is what dies.
|
|
335
|
+
await runner.destroySession('s2');
|
|
336
|
+
assert.strictEqual(destroyed.length, 1);
|
|
337
|
+
assert.strictEqual(runner.sandboxes().length, 0);
|
|
338
|
+
await runner.run({ engineId: 'e', modelId: 'm', message: 'c', workspaceRoot: '/w', sessionId: 's1', sandboxKey: 'u1' });
|
|
339
|
+
// killAll names the sandbox itself.
|
|
340
|
+
await runner.destroySession('u1');
|
|
341
|
+
assert.strictEqual(destroyed.length, 2);
|
|
342
|
+
assert.strictEqual(runner.sandboxes().length, 0);
|
|
343
|
+
});
|
|
344
|
+
test('an unknown key destroys nothing', async () => {
|
|
345
|
+
const state = { seen: [], active: 0, max: 0 };
|
|
346
|
+
const { driver, destroyed } = raceableDriver(() => recordingEngine(state));
|
|
347
|
+
const runner = (0, sandboxRunner_1.createSandboxRunner)(base(driver));
|
|
348
|
+
await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1' });
|
|
349
|
+
await runner.destroySession('never-seen');
|
|
350
|
+
assert.strictEqual(destroyed.length, 0);
|
|
351
|
+
assert.strictEqual(runner.sandboxes().length, 1);
|
|
352
|
+
});
|
|
353
|
+
test('without a sandboxKey the behaviour is exactly one sandbox per session', async () => {
|
|
354
|
+
const state = { seen: [], active: 0, max: 0 };
|
|
355
|
+
const { driver, created } = raceableDriver(() => recordingEngine(state));
|
|
356
|
+
const runner = (0, sandboxRunner_1.createSandboxRunner)(base(driver));
|
|
357
|
+
await runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1' });
|
|
358
|
+
await runner.run({ engineId: 'e', modelId: 'm', message: 'b', workspaceRoot: '/w', sessionId: 's2' });
|
|
359
|
+
assert.deepStrictEqual(created.map((c) => c.sandboxKey), ['s1', 's2']);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
/** A pre-demux sandbox: `ready` without the flag, and streamed events with no run id. */
|
|
363
|
+
function legacyHostDriver(state) {
|
|
364
|
+
const created = [];
|
|
365
|
+
let seq = 0;
|
|
366
|
+
const driver = {
|
|
367
|
+
kind: 'process', tier: 0,
|
|
368
|
+
async create(spec) {
|
|
369
|
+
created.push(spec);
|
|
370
|
+
const id = `sandbox-${++seq}`;
|
|
371
|
+
const toRunner = new node_stream_1.PassThrough();
|
|
372
|
+
const toServe = new node_stream_1.PassThrough();
|
|
373
|
+
const write = (o) => toServe.write(`${JSON.stringify(o)}\n`);
|
|
374
|
+
write({ type: 'ready' });
|
|
375
|
+
let buffer = '';
|
|
376
|
+
toRunner.on('data', (chunk) => {
|
|
377
|
+
buffer += chunk.toString('utf8');
|
|
378
|
+
const lines = buffer.split('\n');
|
|
379
|
+
buffer = lines.pop() ?? '';
|
|
380
|
+
for (const line of lines) {
|
|
381
|
+
if (!line.trim()) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const frame = JSON.parse(line);
|
|
385
|
+
if (frame.type !== 'run') {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
void (async () => {
|
|
389
|
+
state.active++;
|
|
390
|
+
state.max = Math.max(state.max, state.active);
|
|
391
|
+
if (state.hold) {
|
|
392
|
+
await state.hold;
|
|
393
|
+
}
|
|
394
|
+
write({ type: 'text', delta: `T:${frame.message}` });
|
|
395
|
+
state.active--;
|
|
396
|
+
write({ type: 'result', id: frame.id });
|
|
397
|
+
})();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
return {
|
|
401
|
+
id, tier: 0, endpoint: 'stdio',
|
|
402
|
+
connect: () => ({
|
|
403
|
+
input: toServe,
|
|
404
|
+
output: { write: (c) => toRunner.write(c) },
|
|
405
|
+
closed: new Promise(() => undefined),
|
|
406
|
+
}),
|
|
407
|
+
};
|
|
408
|
+
},
|
|
409
|
+
async revoke() { },
|
|
410
|
+
async destroy() { },
|
|
411
|
+
};
|
|
412
|
+
return { driver, created };
|
|
413
|
+
}
|
|
414
|
+
suite('turns serialize when the peer cannot tag its events', () => {
|
|
415
|
+
test('a legacy sandbox runs one turn at a time', async () => {
|
|
416
|
+
const state = { active: 0, max: 0 };
|
|
417
|
+
const { driver, created } = legacyHostDriver(state);
|
|
418
|
+
const runner = (0, sandboxRunner_1.createSandboxRunner)(base(driver));
|
|
419
|
+
// Warm up so the `ready` handshake has certainly been seen before the concurrent pair.
|
|
420
|
+
await runner.run({ engineId: 'e', modelId: 'm', message: 'w', workspaceRoot: '/w', sessionId: 's0', sandboxKey: 'u1' });
|
|
421
|
+
const results = await Promise.all([
|
|
422
|
+
runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1', sandboxKey: 'u1' }),
|
|
423
|
+
runner.run({ engineId: 'e', modelId: 'm', message: 'b', workspaceRoot: '/w', sessionId: 's2', sandboxKey: 'u1' }),
|
|
424
|
+
]);
|
|
425
|
+
assert.strictEqual(created.length, 1);
|
|
426
|
+
assert.strictEqual(state.max, 1, 'overlapping turns on an untagged peer would cross-talk');
|
|
427
|
+
// Serialized, so each turn still gets exactly its own output despite the broadcast.
|
|
428
|
+
assert.deepStrictEqual(results.map((r) => r.text), ['T:a', 'T:b']);
|
|
429
|
+
});
|
|
430
|
+
test('a demux-capable sandbox runs them concurrently', async () => {
|
|
431
|
+
let release;
|
|
432
|
+
const hold = new Promise((r) => { release = r; });
|
|
433
|
+
const state = { seen: [], active: 0, max: 0, hold: undefined };
|
|
434
|
+
const { driver } = raceableDriver(() => recordingEngine(state));
|
|
435
|
+
const runner = (0, sandboxRunner_1.createSandboxRunner)(base(driver));
|
|
436
|
+
await runner.run({ engineId: 'e', modelId: 'm', message: 'w', workspaceRoot: '/w', sessionId: 's0', sandboxKey: 'u1' });
|
|
437
|
+
state.hold = hold;
|
|
438
|
+
const pair = Promise.all([
|
|
439
|
+
runner.run({ engineId: 'e', modelId: 'm', message: 'a', workspaceRoot: '/w', sessionId: 's1', sandboxKey: 'u1' }),
|
|
440
|
+
runner.run({ engineId: 'e', modelId: 'm', message: 'b', workspaceRoot: '/w', sessionId: 's2', sandboxKey: 'u1' }),
|
|
441
|
+
]);
|
|
442
|
+
await waitFor(() => state.max >= 2, 'both turns to be in flight at once');
|
|
443
|
+
release();
|
|
444
|
+
const results = await pair;
|
|
445
|
+
assert.strictEqual(state.max, 2, 'demux is what buys the concurrency');
|
|
446
|
+
// And with both in flight, neither transcript picked up the other's text.
|
|
447
|
+
assert.deepStrictEqual(results.map((r) => r.text), ['T:a', 'T:b']);
|
|
448
|
+
});
|
|
449
|
+
});
|