@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,460 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* Caps + kill switch — the enforcement half of the control plane.
|
|
4
|
+
*
|
|
5
|
+
* These pin the properties an enterprise operator is actually buying:
|
|
6
|
+
* - a cap is enforced at the boundary, not requested of the agent;
|
|
7
|
+
* - a local flag may tighten the control plane's cap, never loosen it;
|
|
8
|
+
* - "kill" ends RUNNING work, not just new work;
|
|
9
|
+
* - a control-plane outage does not take the fleet down.
|
|
10
|
+
*--------------------------------------------------------------------------------------------*/
|
|
11
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
14
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
15
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
16
|
+
}
|
|
17
|
+
Object.defineProperty(o, k2, desc);
|
|
18
|
+
}) : (function(o, m, k, k2) {
|
|
19
|
+
if (k2 === undefined) k2 = k;
|
|
20
|
+
o[k2] = m[k];
|
|
21
|
+
}));
|
|
22
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
23
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
24
|
+
}) : function(o, v) {
|
|
25
|
+
o["default"] = v;
|
|
26
|
+
});
|
|
27
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
28
|
+
var ownKeys = function(o) {
|
|
29
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
30
|
+
var ar = [];
|
|
31
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
32
|
+
return ar;
|
|
33
|
+
};
|
|
34
|
+
return ownKeys(o);
|
|
35
|
+
};
|
|
36
|
+
return function (mod) {
|
|
37
|
+
if (mod && mod.__esModule) return mod;
|
|
38
|
+
var result = {};
|
|
39
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
40
|
+
__setModuleDefault(result, mod);
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
})();
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
require("mocha");
|
|
46
|
+
const assert = __importStar(require("assert"));
|
|
47
|
+
const caps_1 = require("../pack/caps");
|
|
48
|
+
const governor_1 = require("../pack/governor");
|
|
49
|
+
const lease_1 = require("../pack/lease");
|
|
50
|
+
/** Records what the governor did to which session. */
|
|
51
|
+
function fakeSandboxes(initial = []) {
|
|
52
|
+
const state = {
|
|
53
|
+
live: [...initial],
|
|
54
|
+
destroyed: [],
|
|
55
|
+
revoked: [],
|
|
56
|
+
failRevoke: false,
|
|
57
|
+
// Session scope: one sandbox per session, so its key IS the session key.
|
|
58
|
+
sandboxes() {
|
|
59
|
+
return state.live.map((sessionKey) => ({
|
|
60
|
+
sandboxKey: sessionKey, sandboxId: `sandbox-${sessionKey}`, sessionKeys: [sessionKey],
|
|
61
|
+
}));
|
|
62
|
+
},
|
|
63
|
+
async destroySession(sessionKey) {
|
|
64
|
+
state.destroyed.push(sessionKey);
|
|
65
|
+
state.live = state.live.filter((s) => s !== sessionKey);
|
|
66
|
+
},
|
|
67
|
+
async revokeRoute(sessionKey, what) {
|
|
68
|
+
if (state.failRevoke) {
|
|
69
|
+
throw new Error('tier 0 cannot revoke one route');
|
|
70
|
+
}
|
|
71
|
+
state.revoked.push({ sessionKey, what });
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
const liveLease = (over = {}) => ({
|
|
77
|
+
pack: 'joker1',
|
|
78
|
+
enabled: true,
|
|
79
|
+
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
80
|
+
...over,
|
|
81
|
+
});
|
|
82
|
+
suite('caps policy', () => {
|
|
83
|
+
test('local flags may only TIGHTEN the control plane, never loosen', () => {
|
|
84
|
+
const merged = (0, caps_1.mergeCaps)({ maxTurnsPerSession: 10, maxSessionSeconds: 600 }, { maxTurnsPerSession: 3, maxSessionSeconds: 9999 });
|
|
85
|
+
assert.strictEqual(merged.maxTurnsPerSession, 3, 'a lower local bound wins');
|
|
86
|
+
assert.strictEqual(merged.maxSessionSeconds, 600, 'a higher local bound must NOT raise the ceiling');
|
|
87
|
+
});
|
|
88
|
+
test('either side may escalate the breach action, neither may soften it', () => {
|
|
89
|
+
assert.strictEqual((0, caps_1.mergeCaps)({ onBreach: 'revoke-inference' }, { onBreach: 'destroy-sandbox' }).onBreach, 'destroy-sandbox');
|
|
90
|
+
assert.strictEqual((0, caps_1.mergeCaps)({ onBreach: 'destroy-sandbox' }, { onBreach: 'revoke-inference' }).onBreach, 'destroy-sandbox');
|
|
91
|
+
});
|
|
92
|
+
test('no caps anywhere is unbounded — self-hosted and dev keep working', () => {
|
|
93
|
+
assert.ok((0, caps_1.capsAreUnbounded)((0, caps_1.mergeCaps)(undefined, undefined)));
|
|
94
|
+
assert.ok(!(0, caps_1.capsAreUnbounded)((0, caps_1.mergeCaps)(undefined, { maxTurnsPerSession: 1 })));
|
|
95
|
+
});
|
|
96
|
+
test('env parsing ignores blanks and nonsense rather than guessing', () => {
|
|
97
|
+
const caps = (0, caps_1.capsFromEnv)({
|
|
98
|
+
AGENTHIPPO_MAX_TURNS_PER_SESSION: '5',
|
|
99
|
+
AGENTHIPPO_MAX_SESSION_SECONDS: '',
|
|
100
|
+
AGENTHIPPO_MAX_CONCURRENT_SANDBOXES: 'lots',
|
|
101
|
+
AGENTHIPPO_CAP_ON_BREACH: 'destroy-sandbox',
|
|
102
|
+
});
|
|
103
|
+
assert.strictEqual(caps.maxTurnsPerSession, 5);
|
|
104
|
+
assert.strictEqual(caps.maxSessionSeconds, undefined);
|
|
105
|
+
assert.strictEqual(caps.maxConcurrentSandboxes, undefined);
|
|
106
|
+
assert.strictEqual(caps.onBreach, 'destroy-sandbox');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
suite('cap meter', () => {
|
|
110
|
+
test('the turn cap is checked BEFORE the turn that would exceed it', () => {
|
|
111
|
+
const meter = new caps_1.CapMeter({ maxTurnsPerSession: 2 });
|
|
112
|
+
assert.ok(meter.check('s1', 0).allowed);
|
|
113
|
+
meter.record('s1');
|
|
114
|
+
assert.ok(meter.check('s1', 0).allowed);
|
|
115
|
+
meter.record('s1');
|
|
116
|
+
const third = meter.check('s1', 0);
|
|
117
|
+
assert.ok(!third.allowed, 'the third turn must be refused, not merely reported after');
|
|
118
|
+
assert.strictEqual(third.cap, 'turns');
|
|
119
|
+
});
|
|
120
|
+
test('the duration cap runs from the first turn', () => {
|
|
121
|
+
const meter = new caps_1.CapMeter({ maxSessionSeconds: 60 });
|
|
122
|
+
const t0 = 1_000_000;
|
|
123
|
+
meter.record('s1', t0);
|
|
124
|
+
assert.ok(meter.check('s1', 0, t0 + 59_000).allowed);
|
|
125
|
+
const late = meter.check('s1', 0, t0 + 60_000);
|
|
126
|
+
assert.ok(!late.allowed);
|
|
127
|
+
assert.strictEqual(late.cap, 'duration');
|
|
128
|
+
});
|
|
129
|
+
test('concurrency bounds NEW sessions but never strands an established one', () => {
|
|
130
|
+
const meter = new caps_1.CapMeter({ maxConcurrentSandboxes: 2 });
|
|
131
|
+
meter.record('existing');
|
|
132
|
+
assert.ok(!meter.check('brand-new', 2).allowed, 'a new session is refused at the cap');
|
|
133
|
+
assert.ok(meter.check('existing', 2).allowed, 'a session that already has a sandbox keeps going');
|
|
134
|
+
});
|
|
135
|
+
test('forgetting a session resets its meter', () => {
|
|
136
|
+
const meter = new caps_1.CapMeter({ maxTurnsPerSession: 1 });
|
|
137
|
+
meter.record('s1');
|
|
138
|
+
assert.ok(!meter.check('s1', 0).allowed);
|
|
139
|
+
meter.forget('s1');
|
|
140
|
+
assert.ok(meter.check('s1', 0).allowed);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
suite('governor enforcement', () => {
|
|
144
|
+
test('a breach takes the model route away — it does not ask the agent to stop', async () => {
|
|
145
|
+
const sandboxes = fakeSandboxes(['s1']);
|
|
146
|
+
const gov = (0, governor_1.createGovernor)({ caps: { maxTurnsPerSession: 1, onBreach: 'revoke-inference' }, sandboxes });
|
|
147
|
+
assert.ok((await gov.admitTurn('s1')).allowed);
|
|
148
|
+
gov.recordTurn('s1');
|
|
149
|
+
const second = await gov.admitTurn('s1');
|
|
150
|
+
assert.ok(!second.allowed);
|
|
151
|
+
assert.deepStrictEqual(sandboxes.revoked, [{ sessionKey: 's1', what: 'inference' }]);
|
|
152
|
+
});
|
|
153
|
+
test('destroy-sandbox ends the session outright', async () => {
|
|
154
|
+
const sandboxes = fakeSandboxes(['s1']);
|
|
155
|
+
const gov = (0, governor_1.createGovernor)({ caps: { maxTurnsPerSession: 1, onBreach: 'destroy-sandbox' }, sandboxes });
|
|
156
|
+
await gov.admitTurn('s1');
|
|
157
|
+
gov.recordTurn('s1');
|
|
158
|
+
await gov.admitTurn('s1');
|
|
159
|
+
assert.deepStrictEqual(sandboxes.destroyed, ['s1']);
|
|
160
|
+
});
|
|
161
|
+
test('a tier that cannot revoke one route falls back to destroying the sandbox', async () => {
|
|
162
|
+
// Tier 0 throws RevocationUnsupportedError; the cap must still hold.
|
|
163
|
+
const sandboxes = fakeSandboxes(['s1']);
|
|
164
|
+
sandboxes.failRevoke = true;
|
|
165
|
+
const gov = (0, governor_1.createGovernor)({ caps: { maxTurnsPerSession: 1, onBreach: 'revoke-inference' }, sandboxes });
|
|
166
|
+
await gov.admitTurn('s1');
|
|
167
|
+
gov.recordTurn('s1');
|
|
168
|
+
await gov.admitTurn('s1');
|
|
169
|
+
assert.deepStrictEqual(sandboxes.destroyed, ['s1'], 'the session must not survive a failed revoke');
|
|
170
|
+
});
|
|
171
|
+
test('hitting the instance concurrency cap does not punish the caller', async () => {
|
|
172
|
+
const sandboxes = fakeSandboxes(['a', 'b']);
|
|
173
|
+
const gov = (0, governor_1.createGovernor)({ caps: { maxConcurrentSandboxes: 2, onBreach: 'destroy-sandbox' }, sandboxes });
|
|
174
|
+
const decision = await gov.admitTurn('c');
|
|
175
|
+
assert.ok(!decision.allowed);
|
|
176
|
+
assert.strictEqual(decision.cap, 'concurrency');
|
|
177
|
+
assert.deepStrictEqual(sandboxes.destroyed, [], 'nobody else’s sandbox may be destroyed to make room');
|
|
178
|
+
});
|
|
179
|
+
test('unbounded caps add no gate at all', async () => {
|
|
180
|
+
const sandboxes = fakeSandboxes(['s1']);
|
|
181
|
+
const gov = (0, governor_1.createGovernor)({ caps: {}, sandboxes });
|
|
182
|
+
for (let i = 0; i < 50; i++) {
|
|
183
|
+
assert.ok((await gov.admitTurn('s1')).allowed);
|
|
184
|
+
gov.recordTurn('s1');
|
|
185
|
+
}
|
|
186
|
+
assert.deepStrictEqual(sandboxes.destroyed, []);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
suite('scoped stop orders (deny rules)', () => {
|
|
190
|
+
// Hash stand-in: the real one is userNamespaceHash, but the property under test is only that
|
|
191
|
+
// serve compares a HASHED user id, so the control plane can name a person by email.
|
|
192
|
+
const hash = (u) => `H${u}`;
|
|
193
|
+
test('a session-scoped kill refuses new turns and names the operator reason', () => {
|
|
194
|
+
const lease = liveLease({ deny: [{ scope: 'session', match: 's1', mode: 'kill', reason: 'runaway' }] });
|
|
195
|
+
const v = (0, lease_1.denyVerdict)(lease, { sessionKey: 's1', pack: 'joker1' });
|
|
196
|
+
assert.strictEqual(v.denied, true);
|
|
197
|
+
assert.strictEqual(v.mode, 'kill');
|
|
198
|
+
assert.match(v.reason ?? '', /runaway/);
|
|
199
|
+
assert.strictEqual((0, lease_1.denyVerdict)(lease, { sessionKey: 's2', pack: 'joker1' }).denied, false, 'other sessions untouched');
|
|
200
|
+
});
|
|
201
|
+
test('the bare session_id matches a user-namespaced key — the id an operator actually has', () => {
|
|
202
|
+
const lease = liveLease({ deny: [{ scope: 'session', match: 'ticket-42', mode: 'drain' }] });
|
|
203
|
+
assert.strictEqual((0, lease_1.denyVerdict)(lease, { sessionKey: 'uABC--ticket-42', pack: 'p' }).denied, true);
|
|
204
|
+
});
|
|
205
|
+
test('a user-scoped rule matches every session of that user, by hash', () => {
|
|
206
|
+
const lease = liveLease({ deny: [{ scope: 'user', match: 'evil@x.com', mode: 'kill' }] });
|
|
207
|
+
const req = { pack: 'p', userHash: hash };
|
|
208
|
+
assert.strictEqual((0, lease_1.denyVerdict)(lease, { ...req, sessionKey: 'uHevil@x.com--a' }).denied, true);
|
|
209
|
+
assert.strictEqual((0, lease_1.denyVerdict)(lease, { ...req, sessionKey: 'uHevil@x.com--b' }).denied, true);
|
|
210
|
+
assert.strictEqual((0, lease_1.denyVerdict)(lease, { ...req, sessionKey: 'uHok@x.com--a' }).denied, false);
|
|
211
|
+
});
|
|
212
|
+
test('kill outranks drain when both match — an emergency is never softened', () => {
|
|
213
|
+
const lease = liveLease({ deny: [
|
|
214
|
+
{ scope: 'pack', match: 'p', mode: 'drain' },
|
|
215
|
+
{ scope: 'session', match: 's1', mode: 'kill' },
|
|
216
|
+
] });
|
|
217
|
+
assert.strictEqual((0, lease_1.denyVerdict)(lease, { sessionKey: 's1', pack: 'p' }).mode, 'kill');
|
|
218
|
+
});
|
|
219
|
+
test('a spent rule is ignored, so a bounded stop lifts itself', () => {
|
|
220
|
+
const past = Math.floor(Date.now() / 1000) - 1;
|
|
221
|
+
const lease = liveLease({ deny: [{ scope: 'session', match: 's1', mode: 'kill', until: past }] });
|
|
222
|
+
assert.strictEqual((0, lease_1.denyVerdict)(lease, { sessionKey: 's1', pack: 'p' }).denied, false);
|
|
223
|
+
});
|
|
224
|
+
test('admission refuses a sandbox for a denied session', () => {
|
|
225
|
+
const lease = liveLease({ deny: [{ scope: 'session', match: 's1', mode: 'drain', reason: 'scale-in' }] });
|
|
226
|
+
assert.throws(() => (0, lease_1.assertAdmitted)(lease, { pack: 'joker1', version: '1.0.0', sessionKey: 's1' }), /scale-in/);
|
|
227
|
+
// And leaves everyone else alone.
|
|
228
|
+
(0, lease_1.assertAdmitted)(lease, { pack: 'joker1', version: '1.0.0', sessionKey: 's2' });
|
|
229
|
+
});
|
|
230
|
+
test('kill ends a running sandbox on the next poll; drain leaves it to finish', async () => {
|
|
231
|
+
const killSandboxes = fakeSandboxes(['s1']);
|
|
232
|
+
const killGov = (0, governor_1.createGovernor)({
|
|
233
|
+
caps: {}, sandboxes: killSandboxes, pack: 'joker1',
|
|
234
|
+
getLease: async () => liveLease({ deny: [{ scope: 'session', match: 's1', mode: 'kill' }] }),
|
|
235
|
+
});
|
|
236
|
+
await killGov.refresh();
|
|
237
|
+
assert.deepStrictEqual(killSandboxes.destroyed, ['s1'], 'kill destroys the live sandbox');
|
|
238
|
+
assert.strictEqual((await killGov.admitTurn('s1')).allowed, false, 'and refuses the next turn');
|
|
239
|
+
const drainSandboxes = fakeSandboxes(['s1']);
|
|
240
|
+
const drainGov = (0, governor_1.createGovernor)({
|
|
241
|
+
caps: {}, sandboxes: drainSandboxes, pack: 'joker1',
|
|
242
|
+
getLease: async () => liveLease({ deny: [{ scope: 'session', match: 's1', mode: 'drain' }] }),
|
|
243
|
+
});
|
|
244
|
+
await drainGov.refresh();
|
|
245
|
+
assert.deepStrictEqual(drainSandboxes.destroyed, [], 'drain lets the in-flight turn finish');
|
|
246
|
+
assert.strictEqual((await drainGov.admitTurn('s1')).allowed, false, 'but admits no new turn');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
suite('a cap is a ceiling, not a cycle', () => {
|
|
250
|
+
/**
|
|
251
|
+
* `enforce` used to call `meter.forget` on breach, handing the session a brand-new budget the
|
|
252
|
+
* instant it exceeded the old one. `maxTurnsPerSession: 10` then meant "one refused turn in
|
|
253
|
+
* every eleven, forever" — the session ran unbounded while the console showed a cap in force.
|
|
254
|
+
*/
|
|
255
|
+
test('a breached session stays breached instead of getting a fresh budget', async () => {
|
|
256
|
+
const sandboxes = fakeSandboxes(['s1']);
|
|
257
|
+
const gov = (0, governor_1.createGovernor)({
|
|
258
|
+
caps: { maxTurnsPerSession: 2, onBreach: 'destroy-sandbox' },
|
|
259
|
+
sandboxes,
|
|
260
|
+
});
|
|
261
|
+
assert.strictEqual((await gov.admitTurn('s1')).allowed, true);
|
|
262
|
+
gov.recordTurn('s1');
|
|
263
|
+
assert.strictEqual((await gov.admitTurn('s1')).allowed, true);
|
|
264
|
+
gov.recordTurn('s1');
|
|
265
|
+
const breach = await gov.admitTurn('s1');
|
|
266
|
+
assert.strictEqual(breach.allowed, false, 'the cap bites on the turn past it');
|
|
267
|
+
assert.deepStrictEqual(sandboxes.destroyed, ['s1'], 'the sandbox is destroyed on breach');
|
|
268
|
+
// The turn AFTER the breach is the one that used to be admitted again.
|
|
269
|
+
const next = await gov.admitTurn('s1');
|
|
270
|
+
assert.strictEqual(next.allowed, false, 'still refused — the meter outlived the sandbox');
|
|
271
|
+
assert.match(next.reason ?? '', /turn cap reached/);
|
|
272
|
+
});
|
|
273
|
+
test('the revoke→destroy fallback does not hand the budget back either', async () => {
|
|
274
|
+
const sandboxes = fakeSandboxes(['s1']);
|
|
275
|
+
sandboxes.failRevoke = true; // tier 0: cannot revoke one route, falls back to destroy
|
|
276
|
+
const gov = (0, governor_1.createGovernor)({ caps: { maxTurnsPerSession: 1 }, sandboxes });
|
|
277
|
+
assert.strictEqual((await gov.admitTurn('s1')).allowed, true);
|
|
278
|
+
gov.recordTurn('s1');
|
|
279
|
+
assert.strictEqual((await gov.admitTurn('s1')).allowed, false);
|
|
280
|
+
assert.deepStrictEqual(sandboxes.destroyed, ['s1'], 'fell back to destroy');
|
|
281
|
+
assert.strictEqual((await gov.admitTurn('s1')).allowed, false, 'and the cap still holds');
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
suite('user-scoped stop orders reach gateway sessions', () => {
|
|
285
|
+
/**
|
|
286
|
+
* A gateway sender with no explicit session id gets the key `gateway-<userId>`, which carries
|
|
287
|
+
* no `u<hash>--` prefix — so matching a `user` rule by key prefix alone silently covered none
|
|
288
|
+
* of a Telegram/Slack user's conversations while the console reported the rule applied.
|
|
289
|
+
*/
|
|
290
|
+
const stopUser = (match) => liveLease({ deny: [{ scope: 'user', match, mode: 'kill', reason: 'abuse' }] });
|
|
291
|
+
test('the resolved user id matches, whatever shape the session key has', () => {
|
|
292
|
+
const lease = stopUser('telegram:12345');
|
|
293
|
+
const v = (0, lease_1.denyVerdict)(lease, {
|
|
294
|
+
sessionKey: 'gateway-telegram:12345',
|
|
295
|
+
pack: 'joker1',
|
|
296
|
+
userId: 'telegram:12345',
|
|
297
|
+
});
|
|
298
|
+
assert.ok(v.denied);
|
|
299
|
+
assert.strictEqual(v.mode, 'kill');
|
|
300
|
+
});
|
|
301
|
+
test('the namespaced-key prefix still matches when no user id is supplied', () => {
|
|
302
|
+
const hash = (u) => `h(${u})`;
|
|
303
|
+
const v = (0, lease_1.denyVerdict)(stopUser('alice@corp.com'), {
|
|
304
|
+
sessionKey: `uh(alice@corp.com)--ticket-42`,
|
|
305
|
+
pack: 'joker1',
|
|
306
|
+
userHash: hash,
|
|
307
|
+
});
|
|
308
|
+
assert.ok(v.denied, 'the sandbox-sweep path, which holds only a session key, still works');
|
|
309
|
+
});
|
|
310
|
+
test('a different user is untouched', () => {
|
|
311
|
+
const v = (0, lease_1.denyVerdict)(stopUser('telegram:12345'), {
|
|
312
|
+
sessionKey: 'gateway-telegram:99999',
|
|
313
|
+
pack: 'joker1',
|
|
314
|
+
userId: 'telegram:99999',
|
|
315
|
+
});
|
|
316
|
+
assert.ok(!v.denied);
|
|
317
|
+
});
|
|
318
|
+
test('the governor remembers the user, so the watcher kills the running sandbox too', async () => {
|
|
319
|
+
const sandboxes = fakeSandboxes(['gateway-telegram:12345']);
|
|
320
|
+
let lease = liveLease();
|
|
321
|
+
const gov = (0, governor_1.createGovernor)({ caps: {}, sandboxes, getLease: async () => lease });
|
|
322
|
+
// A turn teaches the governor which user this session belongs to.
|
|
323
|
+
await gov.admitTurn('gateway-telegram:12345', 'telegram:12345');
|
|
324
|
+
lease = stopUser('telegram:12345');
|
|
325
|
+
await gov.refresh();
|
|
326
|
+
assert.deepStrictEqual(sandboxes.destroyed, ['gateway-telegram:12345'], 'the live sandbox is destroyed, not merely refused its next turn');
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
suite('pack admission on the no-sandbox path', () => {
|
|
330
|
+
/**
|
|
331
|
+
* The enterprise stack runs serve WITHOUT --sandbox, and signature/pin enforcement lived only
|
|
332
|
+
* in the sandbox `admit` hook. So an operator could set requireSignedPack (or pin a version) in
|
|
333
|
+
* the console, watch the API report success, and have every instance keep serving the unsigned
|
|
334
|
+
* or unpinned pack. A control that reports success and enforces nothing is the worst outcome
|
|
335
|
+
* available — worse than not shipping the control.
|
|
336
|
+
*/
|
|
337
|
+
test('an unsigned pack is refused per turn when the lease requires signing', async () => {
|
|
338
|
+
const sandboxes = fakeSandboxes();
|
|
339
|
+
const lease = liveLease({ requireSignedPack: true });
|
|
340
|
+
const gov = (0, governor_1.createGovernor)({
|
|
341
|
+
caps: {}, sandboxes, getLease: async () => lease,
|
|
342
|
+
admitPack: (_s, l) => l?.requireSignedPack
|
|
343
|
+
? { allowed: false, reason: 'refusing to run probe: probe is not signed' }
|
|
344
|
+
: { allowed: true },
|
|
345
|
+
});
|
|
346
|
+
await gov.refresh();
|
|
347
|
+
const decision = await gov.admitTurn('s1');
|
|
348
|
+
assert.strictEqual(decision.allowed, false, 'the turn is refused with no sandbox in play');
|
|
349
|
+
assert.match(decision.reason ?? '', /not signed/, 'the operator’s reason reaches the caller');
|
|
350
|
+
});
|
|
351
|
+
test('the pack gate sees the same lease the kill and deny checks used', async () => {
|
|
352
|
+
const sandboxes = fakeSandboxes();
|
|
353
|
+
let lease = liveLease();
|
|
354
|
+
const seen = [];
|
|
355
|
+
const gov = (0, governor_1.createGovernor)({
|
|
356
|
+
caps: {}, sandboxes, getLease: async () => lease,
|
|
357
|
+
admitPack: (_s, l) => { seen.push(l?.version); return { allowed: true }; },
|
|
358
|
+
});
|
|
359
|
+
await gov.refresh();
|
|
360
|
+
await gov.admitTurn('s1');
|
|
361
|
+
// A pin published between polls must not be read by one gate and missed by another.
|
|
362
|
+
lease = liveLease({ version: '2.0.0' });
|
|
363
|
+
await gov.refresh();
|
|
364
|
+
await gov.admitTurn('s1');
|
|
365
|
+
assert.deepStrictEqual(seen, [undefined, '2.0.0']);
|
|
366
|
+
});
|
|
367
|
+
test('without admitPack the governor is unchanged — sandbox mode keeps its drain semantics', async () => {
|
|
368
|
+
const sandboxes = fakeSandboxes();
|
|
369
|
+
const gov = (0, governor_1.createGovernor)({ caps: {}, sandboxes, getLease: async () => liveLease({ version: '1.0.0' }) });
|
|
370
|
+
await gov.refresh();
|
|
371
|
+
// A pinned version does NOT refuse turns here: with a sandbox, the pin gates a sandbox's
|
|
372
|
+
// birth and running ones drain on TTL.
|
|
373
|
+
assert.strictEqual((await gov.admitTurn('s1')).allowed, true);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
suite('kill switch', () => {
|
|
377
|
+
test('caps pushed by the control plane take effect on the next poll, not the next restart', async () => {
|
|
378
|
+
// Caps were read from the lease exactly once, at construction, so tightening a cap in the
|
|
379
|
+
// console did nothing until every instance restarted — the fleet reported the new policy
|
|
380
|
+
// while enforcing the old one. Caught by the live control-plane acceptance test.
|
|
381
|
+
const sandboxes = fakeSandboxes();
|
|
382
|
+
let lease = liveLease({ caps: {} });
|
|
383
|
+
const gov = (0, governor_1.createGovernor)({ caps: {}, sandboxes, getLease: async () => lease });
|
|
384
|
+
assert.strictEqual((await gov.admitTurn('s1')).allowed, true, 'unbounded to begin with');
|
|
385
|
+
gov.recordTurn('s1');
|
|
386
|
+
lease = liveLease({ caps: { maxTurnsPerSession: 1 } });
|
|
387
|
+
await gov.refresh();
|
|
388
|
+
assert.strictEqual(gov.policy().maxTurnsPerSession, 1, 'the tightened cap is now in force');
|
|
389
|
+
const after = await gov.admitTurn('s1');
|
|
390
|
+
assert.strictEqual(after.allowed, false, 'the session already over the new cap is refused');
|
|
391
|
+
assert.match(after.reason ?? '', /turn cap reached/);
|
|
392
|
+
});
|
|
393
|
+
test('leaseIsDead recognises revoked and expired, and ignores unmanaged deployments', () => {
|
|
394
|
+
assert.ok(!(0, lease_1.leaseIsDead)(undefined).dead, 'no lease ⇒ unmanaged, nothing to kill');
|
|
395
|
+
assert.ok(!(0, lease_1.leaseIsDead)(liveLease()).dead);
|
|
396
|
+
assert.ok((0, lease_1.leaseIsDead)(liveLease({ enabled: false })).dead);
|
|
397
|
+
assert.ok((0, lease_1.leaseIsDead)(liveLease({ expiresAt: Math.floor(Date.now() / 1000) - 1 })).dead);
|
|
398
|
+
});
|
|
399
|
+
test('kill ends RUNNING sandboxes, not just new ones', async () => {
|
|
400
|
+
const sandboxes = fakeSandboxes(['s1', 's2', 's3']);
|
|
401
|
+
const gov = (0, governor_1.createGovernor)({ caps: {}, sandboxes });
|
|
402
|
+
const n = await gov.killAll('operator stop');
|
|
403
|
+
assert.strictEqual(n, 3);
|
|
404
|
+
assert.deepStrictEqual(sandboxes.destroyed.sort(), ['s1', 's2', 's3']);
|
|
405
|
+
});
|
|
406
|
+
test('once the lease is dead, no further turn is admitted', async () => {
|
|
407
|
+
const sandboxes = fakeSandboxes(['s1']);
|
|
408
|
+
let lease = liveLease();
|
|
409
|
+
const gov = (0, governor_1.createGovernor)({ caps: {}, sandboxes, getLease: async () => lease, pollSeconds: 5 });
|
|
410
|
+
assert.ok((await gov.admitTurn('s1')).allowed);
|
|
411
|
+
lease = liveLease({ enabled: false, reason: 'billing hold' });
|
|
412
|
+
gov.start();
|
|
413
|
+
await gov.refresh(); // an operator hitting stop must not wait for the next poll
|
|
414
|
+
gov.stop();
|
|
415
|
+
assert.deepStrictEqual(sandboxes.destroyed, ['s1'], 'the live sandbox is gone');
|
|
416
|
+
const after = await gov.admitTurn('s1');
|
|
417
|
+
assert.ok(!after.allowed);
|
|
418
|
+
assert.match(after.reason ?? '', /billing hold/, 'the operator’s reason reaches the caller');
|
|
419
|
+
});
|
|
420
|
+
test('a control-plane outage does not kill the fleet (fail open on transport)', async () => {
|
|
421
|
+
const sandboxes = fakeSandboxes(['s1']);
|
|
422
|
+
const gov = (0, governor_1.createGovernor)({
|
|
423
|
+
caps: {}, sandboxes,
|
|
424
|
+
getLease: async () => { throw new Error('control plane unreachable'); },
|
|
425
|
+
pollSeconds: 5,
|
|
426
|
+
});
|
|
427
|
+
gov.start();
|
|
428
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
429
|
+
gov.stop();
|
|
430
|
+
assert.deepStrictEqual(sandboxes.destroyed, [], 'an unreachable control plane must not stop the customer');
|
|
431
|
+
assert.ok((await gov.admitTurn('s1')).allowed);
|
|
432
|
+
});
|
|
433
|
+
test('a restored lease lifts the kill without a restart', async () => {
|
|
434
|
+
const sandboxes = fakeSandboxes(['s1']);
|
|
435
|
+
let lease = liveLease({ enabled: false });
|
|
436
|
+
const gov = (0, governor_1.createGovernor)({ caps: {}, sandboxes, getLease: async () => lease, pollSeconds: 5 });
|
|
437
|
+
gov.start();
|
|
438
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
439
|
+
assert.ok(!(await gov.admitTurn('s1')).allowed);
|
|
440
|
+
lease = liveLease();
|
|
441
|
+
await gov.refresh(); // what a control-plane webhook triggers — no waiting for the poll
|
|
442
|
+
gov.stop();
|
|
443
|
+
assert.ok((await gov.admitTurn('s1')).allowed, 'the fleet comes back without a restart');
|
|
444
|
+
});
|
|
445
|
+
test('every enforcement action is auditable', async () => {
|
|
446
|
+
const events = [];
|
|
447
|
+
const sandboxes = fakeSandboxes(['s1']);
|
|
448
|
+
const gov = (0, governor_1.createGovernor)({
|
|
449
|
+
caps: { maxTurnsPerSession: 1, onBreach: 'destroy-sandbox' },
|
|
450
|
+
sandboxes,
|
|
451
|
+
audit: (event) => events.push(event),
|
|
452
|
+
});
|
|
453
|
+
await gov.admitTurn('s1');
|
|
454
|
+
gov.recordTurn('s1');
|
|
455
|
+
await gov.admitTurn('s1');
|
|
456
|
+
await gov.killAll('drill');
|
|
457
|
+
assert.ok(events.includes('cap_breach'));
|
|
458
|
+
assert.ok(events.includes('kill_switch'));
|
|
459
|
+
});
|
|
460
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*---------------------------------------------------------------------------------------------
|
|
3
|
+
* The invariant that broke live: a file the agent creates still exists after the turn.
|
|
4
|
+
*
|
|
5
|
+
* codex reports shell commands only AFTER they run, so to render a new file against an empty
|
|
6
|
+
* original the engine did read → delete → applyEdit. Headless (`serve`/`ask`/`chat` and every
|
|
7
|
+
* sandbox) gets NoOpEditTracker, whose applyEdit is a no-op — so the delete was permanent and every
|
|
8
|
+
* new file codex created was silently destroyed. `printf > proof.txt` exited 0, the next
|
|
9
|
+
* `cat proof.txt` exited 1, and the file existed nowhere on disk.
|
|
10
|
+
*
|
|
11
|
+
* Nothing asserted file survival, which is why it shipped. These tests do.
|
|
12
|
+
*--------------------------------------------------------------------------------------------*/
|
|
13
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
16
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
17
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
18
|
+
}
|
|
19
|
+
Object.defineProperty(o, k2, desc);
|
|
20
|
+
}) : (function(o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
o[k2] = m[k];
|
|
23
|
+
}));
|
|
24
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
25
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
26
|
+
}) : function(o, v) {
|
|
27
|
+
o["default"] = v;
|
|
28
|
+
});
|
|
29
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
30
|
+
var ownKeys = function(o) {
|
|
31
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
32
|
+
var ar = [];
|
|
33
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
34
|
+
return ar;
|
|
35
|
+
};
|
|
36
|
+
return ownKeys(o);
|
|
37
|
+
};
|
|
38
|
+
return function (mod) {
|
|
39
|
+
if (mod && mod.__esModule) return mod;
|
|
40
|
+
var result = {};
|
|
41
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
42
|
+
__setModuleDefault(result, mod);
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
})();
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
require("mocha");
|
|
48
|
+
const assert = __importStar(require("assert"));
|
|
49
|
+
const fs = __importStar(require("fs/promises"));
|
|
50
|
+
const os = __importStar(require("os"));
|
|
51
|
+
const path = __importStar(require("path"));
|
|
52
|
+
const codex_1 = require("../engines/codex");
|
|
53
|
+
const interfaces_1 = require("../runtime/interfaces");
|
|
54
|
+
/** Stands in for the IDE tracker: records the call AND writes the file back, as VSCodeEditTracker does. */
|
|
55
|
+
class ApplyingEditTracker {
|
|
56
|
+
appliesEdits = true;
|
|
57
|
+
applied = [];
|
|
58
|
+
async applyEdit(_sessionId, filePath, content, isNewFile) {
|
|
59
|
+
this.applied.push({ filePath, content, isNewFile });
|
|
60
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Claims it applies edits, then throws — the file must still survive. */
|
|
64
|
+
class ThrowingEditTracker {
|
|
65
|
+
appliesEdits = true;
|
|
66
|
+
async applyEdit() {
|
|
67
|
+
throw new Error('IDE workspace edit failed');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
suite('codex new-file survival', () => {
|
|
71
|
+
let dir;
|
|
72
|
+
setup(async () => {
|
|
73
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'codex-newfile-'));
|
|
74
|
+
});
|
|
75
|
+
teardown(async () => {
|
|
76
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
const writeFixture = async (name, content) => {
|
|
79
|
+
const p = path.join(dir, name);
|
|
80
|
+
await fs.writeFile(p, content, 'utf-8');
|
|
81
|
+
return p;
|
|
82
|
+
};
|
|
83
|
+
test('a no-op tracker never destroys the file (the live data-loss bug)', async () => {
|
|
84
|
+
const file = await writeFixture('proof.txt', 'SANDBOXWRITE');
|
|
85
|
+
await (0, codex_1.registerNewFilesWithTracker)([file], new interfaces_1.NoOpEditTracker(), 'session-1');
|
|
86
|
+
assert.strictEqual(await fs.readFile(file, 'utf-8'), 'SANDBOXWRITE', 'headless runs must keep the agent\'s file byte-for-byte');
|
|
87
|
+
});
|
|
88
|
+
test('an applying tracker still gets its empty-original diff', async () => {
|
|
89
|
+
const file = await writeFixture('new.ts', 'export const x = 1;\n');
|
|
90
|
+
const tracker = new ApplyingEditTracker();
|
|
91
|
+
await (0, codex_1.registerNewFilesWithTracker)([file], tracker, 'session-1');
|
|
92
|
+
assert.strictEqual(tracker.applied.length, 1, 'the IDE path must still be told about the new file');
|
|
93
|
+
assert.strictEqual(tracker.applied[0].content, 'export const x = 1;\n');
|
|
94
|
+
assert.strictEqual(tracker.applied[0].isNewFile, true);
|
|
95
|
+
assert.strictEqual(await fs.readFile(file, 'utf-8'), 'export const x = 1;\n');
|
|
96
|
+
});
|
|
97
|
+
test('a tracker that throws mid-apply does not eat the file', async () => {
|
|
98
|
+
const file = await writeFixture('fragile.txt', 'IMPORTANT');
|
|
99
|
+
await (0, codex_1.registerNewFilesWithTracker)([file], new ThrowingEditTracker(), 'session-1');
|
|
100
|
+
assert.strictEqual(await fs.readFile(file, 'utf-8'), 'IMPORTANT', 'a failed workspace edit must not leave the file deleted');
|
|
101
|
+
});
|
|
102
|
+
test('binary files are skipped, not round-tripped through UTF-8', async () => {
|
|
103
|
+
const file = path.join(dir, 'logo.png');
|
|
104
|
+
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe]);
|
|
105
|
+
await fs.writeFile(file, bytes);
|
|
106
|
+
const tracker = new ApplyingEditTracker();
|
|
107
|
+
await (0, codex_1.registerNewFilesWithTracker)([file], tracker, 'session-1');
|
|
108
|
+
assert.strictEqual(tracker.applied.length, 0, 'binary must not be handed to a text-based applyEdit');
|
|
109
|
+
assert.deepStrictEqual(await fs.readFile(file), bytes, 'and must be byte-identical afterwards');
|
|
110
|
+
});
|
|
111
|
+
test('every file in the batch survives, not just the first', async () => {
|
|
112
|
+
const a = await writeFixture('a.txt', 'AAA');
|
|
113
|
+
const b = await writeFixture('b.txt', 'BBB');
|
|
114
|
+
const missing = path.join(dir, 'never-created.txt');
|
|
115
|
+
await (0, codex_1.registerNewFilesWithTracker)([a, missing, b], new interfaces_1.NoOpEditTracker(), 'session-1');
|
|
116
|
+
assert.strictEqual(await fs.readFile(a, 'utf-8'), 'AAA');
|
|
117
|
+
assert.strictEqual(await fs.readFile(b, 'utf-8'), 'BBB');
|
|
118
|
+
});
|
|
119
|
+
});
|