@yeaft/webchat-agent 1.0.372 → 1.0.374

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 (34) hide show
  1. package/cli.js +8 -0
  2. package/connection/index.js +11 -1
  3. package/index.js +4 -0
  4. package/local-runtime/server/api.js +2 -0
  5. package/local-runtime/server/auth/login.js +1 -0
  6. package/local-runtime/server/auth/oauth-flow.js +2 -2
  7. package/local-runtime/server/auth/token.js +4 -0
  8. package/local-runtime/server/config.js +47 -1
  9. package/local-runtime/server/database.js +1 -0
  10. package/local-runtime/server/db/connection.js +346 -4
  11. package/local-runtime/server/db/sandbox-db.js +673 -0
  12. package/local-runtime/server/db/user-db.js +137 -7
  13. package/local-runtime/server/index.js +60 -2
  14. package/local-runtime/server/routes/sandbox-routes.js +124 -0
  15. package/local-runtime/server/routes/user-routes.js +30 -15
  16. package/local-runtime/server/sandbox-agent-auth.js +66 -0
  17. package/local-runtime/server/sandbox-attestation-listener.js +128 -0
  18. package/local-runtime/server/sandbox-config.js +42 -0
  19. package/local-runtime/server/sandbox-host-attestation.js +175 -0
  20. package/local-runtime/server/sandbox-reconciler.js +355 -0
  21. package/local-runtime/server/ws-agent.js +34 -11
  22. package/local-runtime/server/ws-client.js +18 -5
  23. package/local-runtime/version.json +1 -1
  24. package/local-runtime/web/app.bundle.js +90 -29
  25. package/local-runtime/web/app.bundle.js.gz +0 -0
  26. package/local-runtime/web/index.html +2 -2
  27. package/local-runtime/web/style.bundle.css +1 -1
  28. package/local-runtime/web/style.bundle.css.gz +0 -0
  29. package/managed-sandbox/agent-runtime.js +73 -0
  30. package/managed-sandbox/controller.js +118 -0
  31. package/managed-sandbox/helper.js +437 -0
  32. package/managed-sandbox/identity-store.js +9 -0
  33. package/managed-sandbox/runtime-executor.js +387 -0
  34. package/package.json +1 -1
@@ -0,0 +1,673 @@
1
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'crypto';
2
+ import db, { transaction } from './connection.js';
3
+
4
+ export const SANDBOX_SIZES = Object.freeze({
5
+ small: Object.freeze({ id: 'small', cpuMillis: 500, memoryMiB: 1024, diskGiB: 10 }),
6
+ normal: Object.freeze({ id: 'normal', cpuMillis: 1000, memoryMiB: 2048, diskGiB: 20 })
7
+ });
8
+
9
+
10
+ const ACTIONS = Object.freeze({
11
+ start: { desiredState: 'running', allowed: ['stopped', 'failed'], stage: 'starting' },
12
+ stop: { desiredState: 'stopped', allowed: ['running', 'failed'], stage: 'stopping' },
13
+ retry: { desiredState: null, allowed: ['failed', 'remove_failed'], stage: 'retrying' },
14
+ remove: { desiredState: 'removed', allowed: ['reserving', 'provisioning', 'starting', 'waiting_for_agent', 'running', 'stopping', 'stopped', 'failed', 'remove_failed', 'recovery_required'], stage: 'removing' }
15
+ });
16
+
17
+ export class SandboxConflictError extends Error {
18
+ constructor(code) {
19
+ super(code);
20
+ this.code = code;
21
+ }
22
+ }
23
+
24
+ const getEntitlement = db.prepare('SELECT enabled FROM sandbox_entitlements WHERE user_id = ?');
25
+ const getSandbox = db.prepare('SELECT * FROM sandboxes WHERE user_id = ? AND reservation_held = 1');
26
+ const getSandboxById = db.prepare('SELECT * FROM sandboxes WHERE id = ?');
27
+ const getOperation = db.prepare('SELECT * FROM sandbox_operations WHERE user_id = ? AND idempotency_key = ?');
28
+ const getLatestOperation = db.prepare('SELECT * FROM sandbox_operations WHERE sandbox_id = ? ORDER BY created_at DESC LIMIT 1');
29
+ const getActiveOperation = db.prepare(`
30
+ SELECT * FROM sandbox_operations
31
+ WHERE sandbox_id = ? AND status IN ('pending', 'running')
32
+ LIMIT 1
33
+ `);
34
+ const reservedCount = db.prepare('SELECT COUNT(*) AS slots FROM sandboxes WHERE reservation_held = 1');
35
+ const reservedTotals = db.prepare(`
36
+ SELECT COALESCE(SUM(cpu_millis), 0) AS cpu,
37
+ COALESCE(SUM(memory_mib), 0) AS memory, COALESCE(SUM(disk_gib), 0) AS disk
38
+ FROM sandboxes WHERE host_id = ? AND reservation_held = 1
39
+ `);
40
+ const activeStartupMemory = db.prepare(`
41
+ SELECT COALESCE(SUM(s.memory_mib), 0) AS memory
42
+ FROM sandbox_operations o JOIN sandboxes s ON s.id = o.sandbox_id
43
+ WHERE s.host_id = ? AND s.reservation_held = 1
44
+ AND o.kind IN ('create', 'start', 'retry')
45
+ AND o.status = 'running'
46
+ `);
47
+ const insertSandbox = db.prepare(`
48
+ INSERT INTO sandboxes (
49
+ id, user_id, host_id, host_epoch, agent_name, size_id, cpu_millis, memory_mib, disk_gib,
50
+ desired_state, observed_state, generation, instance_id, image_digest,
51
+ reservation_held, created_at, updated_at
52
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', 'reserving', 1, ?, ?, 1, ?, ?)
53
+ `);
54
+ const insertOperation = db.prepare(`
55
+ INSERT INTO sandbox_operations (
56
+ id, sandbox_id, user_id, idempotency_key, request_digest, kind, status,
57
+ stage, generation, host_epoch, deadline_at, created_at, updated_at
58
+ ) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)
59
+ `);
60
+
61
+ function digestRequest(value) {
62
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
63
+ }
64
+
65
+ function hashSecret(secret) {
66
+ return createHash('sha256').update(secret).digest();
67
+ }
68
+
69
+ function deriveBootstrapSecret(operationId, seed) {
70
+ return createHmac('sha256', seed).update(operationId).digest('base64url');
71
+ }
72
+
73
+ function secretMatches(secret, expectedHex) {
74
+ const actual = hashSecret(secret);
75
+ const expected = Buffer.from(expectedHex, 'hex');
76
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
77
+ }
78
+
79
+ function assertBoundClaims(record, claims) {
80
+ if (!record || claims.sandboxId !== record.sandbox_id || claims.instanceId !== record.instance_id
81
+ || claims.generation !== record.generation || claims.imageDigest !== record.image_digest) {
82
+ throw new SandboxConflictError('SANDBOX_CREDENTIAL_SCOPE_MISMATCH');
83
+ }
84
+ }
85
+
86
+ function credentialMatchesSandbox(credential, sandbox) {
87
+ return sandbox && credential.sandbox_id === sandbox.id
88
+ && credential.instance_id === sandbox.instance_id
89
+ && credential.generation === sandbox.generation
90
+ && credential.image_digest === sandbox.image_digest;
91
+ }
92
+
93
+ function publicSnapshot(sandbox) {
94
+ if (!sandbox) return null;
95
+ const operation = getLatestOperation.get(sandbox.id) || null;
96
+ return {
97
+ id: sandbox.id,
98
+ agentName: sandbox.agent_name,
99
+ sizeId: sandbox.size_id,
100
+ desiredState: sandbox.desired_state,
101
+ observedState: sandbox.observed_state,
102
+ generation: sandbox.generation,
103
+ reservationHeld: !!sandbox.reservation_held,
104
+ lastErrorCode: sandbox.last_error_code,
105
+ operation: operation && {
106
+ id: operation.id,
107
+ kind: operation.kind,
108
+ status: operation.status,
109
+ stage: operation.stage,
110
+ errorCode: operation.error_code,
111
+ updatedAt: operation.updated_at
112
+ }
113
+ };
114
+ }
115
+
116
+ function candidateHosts(config, now = Date.now()) {
117
+ const freshnessMs = config.hostFreshnessMs || 30_000;
118
+ if (!config.imageDigest) return [];
119
+ return db.prepare(`
120
+ SELECT * FROM sandbox_hosts
121
+ WHERE qualified = 1 AND controller_healthy = 1 AND helper_healthy = 1
122
+ AND runtime_healthy = 1 AND quota_healthy = 1 AND network_healthy = 1
123
+ AND image_digest = ? AND updated_at >= ?
124
+ ORDER BY id
125
+ `).all(config.imageDigest, now - freshnessMs);
126
+ }
127
+
128
+ function memoryReserveMiB(config) {
129
+ const reserve = Number(config.hostMemoryReserveMiB);
130
+ return Number.isSafeInteger(reserve) && reserve > 0 ? reserve : null;
131
+ }
132
+
133
+ function hostCanFit(host, size, config) {
134
+ const reserve = memoryReserveMiB(config);
135
+ if (reserve === null || !Number.isSafeInteger(host.memory_mib_available)) return false;
136
+ const used = reservedTotals.get(host.id);
137
+ const startingMemory = activeStartupMemory.get(host.id).memory;
138
+ return reservedCount.get().slots < config.maxReservedSandboxes
139
+ && used.cpu + size.cpuMillis <= host.cpu_millis_total
140
+ && used.memory + size.memoryMiB <= host.memory_mib_total
141
+ && host.memory_mib_available - reserve - startingMemory >= size.memoryMiB
142
+ && used.disk + size.diskGiB <= host.disk_gib_total;
143
+ }
144
+
145
+ function assertIdempotency(userId, key, requestDigest) {
146
+ if (!key) throw new SandboxConflictError('SANDBOX_IDEMPOTENCY_KEY_REQUIRED');
147
+ const prior = getOperation.get(userId, key);
148
+ if (!prior) return null;
149
+ if (prior.request_digest !== requestDigest) throw new SandboxConflictError('SANDBOX_IDEMPOTENCY_CONFLICT');
150
+ return { snapshot: publicSnapshot(getSandboxById.get(prior.sandbox_id)), replayed: true };
151
+ }
152
+
153
+ function operationDeadline(config, now) {
154
+ return now + (config.operationTimeoutMs || 10 * 60_000);
155
+ }
156
+
157
+ function appendAuditEvent({ sandbox, operationId = null, eventType, actorKind, outcome, errorCode = null, now = Date.now() }) {
158
+ db.prepare(`
159
+ INSERT INTO sandbox_audit_events (
160
+ sandbox_id, user_id, operation_id, event_type, actor_kind,
161
+ generation, host_epoch, outcome, error_code, created_at
162
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
163
+ `).run(
164
+ sandbox.id, sandbox.user_id, operationId, eventType, actorKind,
165
+ sandbox.generation, sandbox.host_epoch, outcome, errorCode, now
166
+ );
167
+ }
168
+
169
+ export const sandboxDb = {
170
+ isEpochActivated(hostId, epoch, activationDigest) {
171
+ if (!Number.isSafeInteger(epoch) || epoch < 1 || !activationDigest) return false;
172
+ return Boolean(db.prepare(`
173
+ SELECT 1 FROM sandbox_host_epochs
174
+ WHERE host_id = ? AND epoch = ? AND activation_digest = ?
175
+ `).get(hostId, epoch, activationDigest));
176
+ },
177
+
178
+ recordEpochActivation(hostId, epoch, activationDigest, now = Date.now()) {
179
+ return transaction(() => {
180
+ if (!Number.isSafeInteger(epoch) || epoch < 1 || !activationDigest) {
181
+ throw new SandboxConflictError('SANDBOX_STALE_RESULT');
182
+ }
183
+ const updated = db.prepare(`
184
+ UPDATE sandbox_host_epochs
185
+ SET activation_digest = ?, activated_at = ?, updated_at = ?
186
+ WHERE host_id = ? AND epoch = ?
187
+ AND (activation_digest IS NULL OR activation_digest = ?)
188
+ `).run(activationDigest, now, now, hostId, epoch, activationDigest);
189
+ if (updated.changes !== 1) throw new SandboxConflictError('SANDBOX_STALE_RESULT');
190
+ db.prepare(`
191
+ INSERT INTO sandbox_host_audit_events
192
+ (host_id, epoch, event_type, outcome, error_code, created_at)
193
+ VALUES (?, ?, 'epoch_activation', 'succeeded', NULL, ?)
194
+ `).run(hostId, epoch, now);
195
+ return true;
196
+ })();
197
+ },
198
+
199
+ capability(userId, config) {
200
+ if (!config.enabled) return { available: false, reasonCode: 'SANDBOX_DISABLED', catalog: [] };
201
+ if (!getEntitlement.get(userId)?.enabled) {
202
+ return { available: false, reasonCode: 'SANDBOX_NOT_ENTITLED', catalog: [] };
203
+ }
204
+ const hosts = candidateHosts(config);
205
+ const availableSizes = Object.values(SANDBOX_SIZES).filter(size =>
206
+ hosts.some(host => hostCanFit(host, size, config))
207
+ );
208
+ if (availableSizes.length === 0) {
209
+ return { available: false, reasonCode: 'SANDBOX_CAPACITY_UNAVAILABLE', catalog: [] };
210
+ }
211
+ return { available: true, reasonCode: null, catalog: availableSizes };
212
+ },
213
+
214
+ snapshot(userId) {
215
+ return publicSnapshot(getSandbox.get(userId));
216
+ },
217
+
218
+ entitlement(userId) {
219
+ return { enabled: Boolean(getEntitlement.get(userId)?.enabled) };
220
+ },
221
+
222
+ setEntitlement(userId, enabled, actorUsername, now = Date.now()) {
223
+ return transaction(() => {
224
+ const user = db.prepare('SELECT id FROM users WHERE id = ?').get(userId);
225
+ if (!user) throw new SandboxConflictError('SANDBOX_USER_NOT_FOUND');
226
+ const normalizedEnabled = enabled === true ? 1 : enabled === false ? 0 : null;
227
+ if (normalizedEnabled === null) {
228
+ throw new SandboxConflictError('SANDBOX_ENTITLEMENT_INVALID');
229
+ }
230
+ db.prepare(`
231
+ INSERT INTO sandbox_entitlements (user_id, enabled, updated_at)
232
+ VALUES (?, ?, ?)
233
+ ON CONFLICT(user_id) DO UPDATE SET
234
+ enabled = excluded.enabled,
235
+ updated_at = excluded.updated_at
236
+ `).run(userId, normalizedEnabled, now);
237
+ db.prepare(`
238
+ INSERT INTO sandbox_entitlement_audit_events (
239
+ user_id, actor_username, enabled, created_at
240
+ ) VALUES (?, ?, ?, ?)
241
+ `).run(userId, String(actorUsername || 'unknown'), normalizedEnabled, now);
242
+ return { enabled: Boolean(normalizedEnabled), updatedAt: now };
243
+ })();
244
+ },
245
+
246
+ create(userId, request, config) {
247
+ const agentName = String(request.agentName || '').trim();
248
+ const size = SANDBOX_SIZES[request.sizeId];
249
+ if (!agentName || agentName.length > 64 || !/^[a-zA-Z0-9 ._-]+$/.test(agentName)) {
250
+ throw new SandboxConflictError('SANDBOX_INVALID_AGENT_NAME');
251
+ }
252
+ if (!size) throw new SandboxConflictError('SANDBOX_INVALID_SIZE');
253
+ const requestDigest = digestRequest({ kind: 'create', agentName, sizeId: size.id });
254
+
255
+ return transaction(() => {
256
+ const replay = assertIdempotency(userId, request.idempotencyKey, requestDigest);
257
+ if (replay) return replay;
258
+ if (getSandbox.get(userId)) throw new SandboxConflictError('SANDBOX_ALREADY_EXISTS');
259
+ const capability = this.capability(userId, config);
260
+ if (!capability.available) throw new SandboxConflictError(capability.reasonCode);
261
+ const host = candidateHosts(config).find(candidate => hostCanFit(candidate, size, config));
262
+ if (!host) throw new SandboxConflictError('SANDBOX_CAPACITY_UNAVAILABLE');
263
+
264
+ const now = Date.now();
265
+ const sandboxId = `sandbox_${randomUUID()}`;
266
+ insertSandbox.run(
267
+ sandboxId, userId, host.id, host.epoch, agentName, size.id, size.cpuMillis,
268
+ size.memoryMiB, size.diskGiB, `sandbox_instance_${randomUUID()}`,
269
+ host.image_digest, now, now
270
+ );
271
+ const operationId = `sandbox_op_${randomUUID()}`;
272
+ insertOperation.run(
273
+ operationId, sandboxId, userId, request.idempotencyKey,
274
+ requestDigest, 'create', 'reserving_capacity', 1, host.epoch,
275
+ operationDeadline(config, now), now, now
276
+ );
277
+ appendAuditEvent({
278
+ sandbox: getSandboxById.get(sandboxId), operationId,
279
+ eventType: 'operation_requested', actorKind: 'user', outcome: 'accepted', now
280
+ });
281
+ return { snapshot: publicSnapshot(getSandbox.get(userId)), replayed: false };
282
+ })();
283
+ },
284
+
285
+ requestAction(userId, kind, idempotencyKey, config) {
286
+ const action = ACTIONS[kind];
287
+ if (!action) throw new SandboxConflictError('SANDBOX_INVALID_ACTION');
288
+ const requestDigest = digestRequest({ kind });
289
+ return transaction(() => {
290
+ const replay = assertIdempotency(userId, idempotencyKey, requestDigest);
291
+ if (replay) return replay;
292
+ const sandbox = getSandbox.get(userId);
293
+ if (!sandbox) throw new SandboxConflictError('SANDBOX_NOT_FOUND');
294
+ if (!action.allowed.includes(sandbox.observed_state)) {
295
+ throw new SandboxConflictError('SANDBOX_ACTION_NOT_ALLOWED');
296
+ }
297
+ if (getActiveOperation.get(sandbox.id)) {
298
+ throw new SandboxConflictError('SANDBOX_OPERATION_IN_PROGRESS');
299
+ }
300
+ const retryingRemove = kind === 'retry' && sandbox.observed_state === 'remove_failed';
301
+ const operationKind = retryingRemove ? 'remove' : kind;
302
+ const generation = ['retry', 'remove'].includes(kind)
303
+ ? sandbox.generation + 1
304
+ : sandbox.generation;
305
+ const desiredState = retryingRemove
306
+ ? 'removed'
307
+ : kind === 'retry' ? 'running' : action.desiredState;
308
+ const operationStage = retryingRemove ? ACTIONS.remove.stage : action.stage;
309
+ const now = Date.now();
310
+ let hostEpoch = sandbox.host_epoch;
311
+ if (kind === 'remove' && sandbox.observed_state === 'recovery_required') {
312
+ const host = db.prepare('SELECT epoch FROM sandbox_hosts WHERE id = ?').get(sandbox.host_id);
313
+ // Removal must remain reachable after a Host record is lost. Preserve the
314
+ // last fenced epoch unless the same Host has reported a newer one.
315
+ if (host) hostEpoch = host.epoch;
316
+ }
317
+ if (operationKind === 'remove') {
318
+ const revoked = db.prepare(`
319
+ UPDATE sandbox_credentials SET revoked_at = ?
320
+ WHERE sandbox_id = ? AND revoked_at IS NULL
321
+ `).run(now, sandbox.id);
322
+ if (revoked.changes > 0) {
323
+ appendAuditEvent({
324
+ sandbox, eventType: 'credential_revoked', actorKind: 'server',
325
+ outcome: 'succeeded', now
326
+ });
327
+ }
328
+ }
329
+ db.prepare(`
330
+ UPDATE sandboxes SET desired_state = ?, generation = ?, host_epoch = ?,
331
+ last_error_code = NULL, updated_at = ?
332
+ WHERE id = ? AND generation = ? AND reservation_held = 1
333
+ `).run(desiredState, generation, hostEpoch, now, sandbox.id, sandbox.generation);
334
+ const operationId = `sandbox_op_${randomUUID()}`;
335
+ insertOperation.run(
336
+ operationId, sandbox.id, userId, idempotencyKey,
337
+ requestDigest, operationKind, operationStage, generation, hostEpoch,
338
+ operationDeadline(config, now), now, now
339
+ );
340
+ appendAuditEvent({
341
+ sandbox: getSandboxById.get(sandbox.id), operationId,
342
+ eventType: 'operation_requested', actorKind: 'user', outcome: 'accepted', now
343
+ });
344
+ return { snapshot: publicSnapshot(getSandbox.get(userId)), replayed: false };
345
+ })();
346
+ },
347
+
348
+ admitPendingOperation(operationId, config, now = Date.now()) {
349
+ return transaction(() => {
350
+ const operation = db.prepare(`
351
+ SELECT o.*, s.host_id, s.instance_id, s.image_digest, s.desired_state,
352
+ s.cpu_millis, s.memory_mib, s.disk_gib
353
+ FROM sandbox_operations o JOIN sandboxes s ON s.id = o.sandbox_id
354
+ WHERE o.id = ? AND o.status = 'pending' AND s.reservation_held = 1
355
+ `).get(operationId);
356
+ if (!operation || !['create', 'start', 'retry'].includes(operation.kind)) return operation || null;
357
+ const host = candidateHosts(config, now).find(candidate => candidate.id === operation.host_id);
358
+ const reserve = memoryReserveMiB(config);
359
+ const startingMemory = host ? activeStartupMemory.get(host.id).memory : 0;
360
+ if (!host || reserve === null
361
+ || host.memory_mib_available - reserve - startingMemory < operation.memory_mib) {
362
+ db.prepare(`
363
+ UPDATE sandbox_operations SET status = 'failed', stage = 'capacity_rejected',
364
+ error_code = 'SANDBOX_CAPACITY_UNAVAILABLE', updated_at = ?
365
+ WHERE id = ? AND status = 'pending'
366
+ `).run(now, operation.id);
367
+ db.prepare(`
368
+ UPDATE sandboxes SET observed_state = 'failed',
369
+ last_error_code = 'SANDBOX_CAPACITY_UNAVAILABLE', updated_at = ?
370
+ WHERE id = ? AND generation = ? AND reservation_held = 1
371
+ `).run(now, operation.sandbox_id, operation.generation);
372
+ appendAuditEvent({
373
+ sandbox: getSandboxById.get(operation.sandbox_id), operationId: operation.id,
374
+ eventType: 'runtime_admission', actorKind: 'server', outcome: 'rejected',
375
+ errorCode: 'SANDBOX_CAPACITY_UNAVAILABLE', now
376
+ });
377
+ return null;
378
+ }
379
+ const admitted = db.prepare(`
380
+ UPDATE sandbox_operations SET status = 'running', stage = 'dispatching', updated_at = ?
381
+ WHERE id = ? AND status = 'pending'
382
+ `).run(now, operation.id);
383
+ return admitted.changes === 1 ? { ...operation, status: 'running', stage: 'dispatching' } : null;
384
+ })();
385
+ },
386
+
387
+ listPendingOperations(now = Date.now()) {
388
+ db.prepare(`
389
+ UPDATE sandbox_operations SET status = 'failed', error_code = 'SANDBOX_OPERATION_TIMEOUT', updated_at = ?
390
+ WHERE status IN ('pending', 'running') AND deadline_at < ?
391
+ `).run(now, now);
392
+ db.prepare(`
393
+ UPDATE sandboxes SET observed_state = CASE WHEN desired_state = 'removed' THEN 'remove_failed' ELSE 'failed' END,
394
+ last_error_code = 'SANDBOX_OPERATION_TIMEOUT', updated_at = ?
395
+ WHERE id IN (SELECT sandbox_id FROM sandbox_operations WHERE error_code = 'SANDBOX_OPERATION_TIMEOUT' AND updated_at = ?)
396
+ `).run(now, now);
397
+ return db.prepare(`
398
+ SELECT o.*, s.host_id, s.instance_id, s.image_digest, s.desired_state,
399
+ s.cpu_millis, s.memory_mib, s.disk_gib
400
+ FROM sandbox_operations o JOIN sandboxes s ON s.id = o.sandbox_id
401
+ WHERE o.status = 'pending' AND s.reservation_held = 1
402
+ ORDER BY o.created_at
403
+ `).all();
404
+ },
405
+
406
+ reconcileRuntimeState(now = Date.now(), config = {}, runtime = {}) {
407
+ return transaction(() => {
408
+ const freshnessMs = config.hostFreshnessMs || 30_000;
409
+ const agentGraceMs = config.agentRecoveryGraceMs || 60_000;
410
+ const sandboxes = db.prepare(`
411
+ SELECT s.*, h.epoch AS current_host_epoch, h.qualified, h.controller_healthy,
412
+ h.helper_healthy, h.runtime_healthy, h.quota_healthy, h.network_healthy,
413
+ h.updated_at AS host_updated_at
414
+ FROM sandboxes s LEFT JOIN sandbox_hosts h ON h.id = s.host_id
415
+ WHERE s.reservation_held = 1 AND s.observed_state != 'recovery_required'
416
+ `).all();
417
+ let recovered = 0;
418
+
419
+ for (const sandbox of sandboxes) {
420
+ let errorCode = null;
421
+ if (!sandbox.current_host_epoch || sandbox.current_host_epoch !== sandbox.host_epoch) {
422
+ errorCode = 'SANDBOX_HOST_EPOCH_CHANGED';
423
+ } else if (!sandbox.qualified || !sandbox.controller_healthy || !sandbox.helper_healthy
424
+ || !sandbox.runtime_healthy || !sandbox.quota_healthy || !sandbox.network_healthy
425
+ || sandbox.host_updated_at < now - freshnessMs) {
426
+ errorCode = 'SANDBOX_HOST_UNAVAILABLE';
427
+ } else if (sandbox.observed_state === 'running' && sandbox.updated_at < now - agentGraceMs
428
+ && !runtime.isAgentReady?.({
429
+ sandboxId: sandbox.id,
430
+ instanceId: sandbox.instance_id,
431
+ generation: sandbox.generation,
432
+ imageDigest: sandbox.image_digest
433
+ })) {
434
+ errorCode = 'SANDBOX_AGENT_NOT_READY';
435
+ }
436
+ if (!errorCode) continue;
437
+
438
+ db.prepare(`
439
+ UPDATE sandboxes SET observed_state = 'recovery_required', last_error_code = ?, updated_at = ?
440
+ WHERE id = ? AND generation = ? AND reservation_held = 1
441
+ `).run(errorCode, now, sandbox.id, sandbox.generation);
442
+ db.prepare(`
443
+ UPDATE sandbox_operations SET status = 'failed', error_code = ?, updated_at = ?
444
+ WHERE sandbox_id = ? AND status IN ('pending', 'running')
445
+ `).run(errorCode, now, sandbox.id);
446
+ const revoked = db.prepare(`
447
+ UPDATE sandbox_credentials SET revoked_at = ?
448
+ WHERE sandbox_id = ? AND revoked_at IS NULL
449
+ `).run(now, sandbox.id);
450
+ appendAuditEvent({
451
+ sandbox: getSandboxById.get(sandbox.id), eventType: 'runtime_reconcile',
452
+ actorKind: 'server', outcome: 'recovery_required', errorCode, now
453
+ });
454
+ if (revoked.changes > 0) {
455
+ appendAuditEvent({
456
+ sandbox: getSandboxById.get(sandbox.id), eventType: 'credential_revoked',
457
+ actorKind: 'server', outcome: 'succeeded', errorCode, now
458
+ });
459
+ }
460
+ recovered++;
461
+ }
462
+ return recovered;
463
+ })();
464
+ },
465
+
466
+ applyControllerResult(result, config = {}, runtime = {}) {
467
+ return transaction(() => {
468
+ const operation = db.prepare('SELECT * FROM sandbox_operations WHERE id = ?').get(result.operationId);
469
+ if (!operation) throw new SandboxConflictError('SANDBOX_OPERATION_NOT_FOUND');
470
+ const sandbox = getSandboxById.get(operation.sandbox_id);
471
+ if (!sandbox || result.action !== operation.kind || result.hostId !== sandbox.host_id
472
+ || result.sandboxId !== operation.sandbox_id || result.requestDigest !== operation.request_digest
473
+ || operation.generation !== result.generation || sandbox.generation !== result.generation
474
+ || operation.host_epoch !== result.hostEpoch || sandbox.host_epoch !== result.hostEpoch) {
475
+ throw new SandboxConflictError('SANDBOX_STALE_RESULT');
476
+ }
477
+ const host = db.prepare('SELECT * FROM sandbox_hosts WHERE id = ?').get(sandbox.host_id);
478
+ const freshnessMs = config.hostFreshnessMs || 30_000;
479
+ const isRemove = operation.kind === 'remove';
480
+ if (!isRemove && (!host || host.epoch !== sandbox.host_epoch)) {
481
+ const now = Date.now();
482
+ db.prepare(`
483
+ UPDATE sandboxes SET observed_state = 'recovery_required',
484
+ last_error_code = 'SANDBOX_HOST_EPOCH_CHANGED', updated_at = ?
485
+ WHERE id = ? AND generation = ? AND reservation_held = 1
486
+ `).run(now, sandbox.id, sandbox.generation);
487
+ db.prepare(`
488
+ UPDATE sandbox_operations SET status = 'failed',
489
+ error_code = 'SANDBOX_HOST_EPOCH_CHANGED', updated_at = ?
490
+ WHERE id = ? AND status IN ('pending', 'running')
491
+ `).run(now, operation.id);
492
+ db.prepare(`
493
+ UPDATE sandbox_credentials SET revoked_at = ?
494
+ WHERE sandbox_id = ? AND revoked_at IS NULL
495
+ `).run(now, sandbox.id);
496
+ appendAuditEvent({
497
+ sandbox, operationId: operation.id, eventType: 'controller_result',
498
+ actorKind: 'controller', outcome: 'recovery_required',
499
+ errorCode: 'SANDBOX_HOST_EPOCH_CHANGED', now
500
+ });
501
+ return { snapshot: publicSnapshot(getSandboxById.get(sandbox.id)), replayed: false };
502
+ }
503
+ if (!isRemove && (!host.qualified || !host.controller_healthy || !host.helper_healthy
504
+ || !host.runtime_healthy || !host.quota_healthy || !host.network_healthy
505
+ || host.updated_at < Date.now() - freshnessMs)) {
506
+ throw new SandboxConflictError('SANDBOX_HOST_UNAVAILABLE');
507
+ }
508
+ if (operation.status !== 'pending' && operation.status !== 'running') {
509
+ return { snapshot: publicSnapshot(sandbox), replayed: true };
510
+ }
511
+ const now = Date.now();
512
+ if (!result.success) {
513
+ const observed = operation.kind === 'remove' ? 'remove_failed' : 'failed';
514
+ const errorCode = result.errorCode || 'SANDBOX_RUNTIME_FAILED';
515
+ db.prepare('UPDATE sandboxes SET observed_state = ?, last_error_code = ?, updated_at = ? WHERE id = ?')
516
+ .run(observed, errorCode, now, sandbox.id);
517
+ db.prepare("UPDATE sandbox_operations SET status = 'failed', error_code = ?, updated_at = ? WHERE id = ?")
518
+ .run(errorCode, now, operation.id);
519
+ appendAuditEvent({
520
+ sandbox, operationId: operation.id, eventType: 'controller_result',
521
+ actorKind: 'controller', outcome: 'failed', errorCode, now
522
+ });
523
+ return { snapshot: publicSnapshot(getSandboxById.get(sandbox.id)), replayed: false };
524
+ }
525
+ if (operation.kind === 'remove') {
526
+ const proof = result.absenceProof || {};
527
+ const required = ['container', 'storage', 'quota', 'network', 'credential'];
528
+ if (!required.every(key => proof[key] === true)) {
529
+ throw new SandboxConflictError('SANDBOX_REMOVE_PROOF_REQUIRED');
530
+ }
531
+ db.prepare(`
532
+ UPDATE sandboxes SET observed_state = 'removed', reservation_held = 0,
533
+ removed_at = ?, updated_at = ? WHERE id = ? AND reservation_held = 1
534
+ `).run(now, now, sandbox.id);
535
+ } else {
536
+ if (operation.kind !== 'stop') {
537
+ const proof = result.readinessProof || {};
538
+ const required = ['image', 'cpu', 'memory', 'pid', 'io', 'quota', 'network', 'credential'];
539
+ if (!required.every(key => proof[key] === true)) {
540
+ throw new SandboxConflictError('SANDBOX_READINESS_PROOF_REQUIRED');
541
+ }
542
+ if (result.imageDigest !== sandbox.image_digest) {
543
+ throw new SandboxConflictError('SANDBOX_IMAGE_MISMATCH');
544
+ }
545
+ if (!runtime.isAgentReady?.({
546
+ sandboxId: sandbox.id,
547
+ instanceId: sandbox.instance_id,
548
+ generation: sandbox.generation,
549
+ imageDigest: sandbox.image_digest
550
+ })) {
551
+ throw new SandboxConflictError('SANDBOX_AGENT_NOT_READY');
552
+ }
553
+ }
554
+ const observed = operation.kind === 'stop' ? 'stopped' : 'running';
555
+ db.prepare('UPDATE sandboxes SET observed_state = ?, last_error_code = NULL, updated_at = ? WHERE id = ?')
556
+ .run(observed, now, sandbox.id);
557
+ }
558
+ db.prepare("UPDATE sandbox_operations SET status = 'succeeded', stage = 'complete', updated_at = ? WHERE id = ?")
559
+ .run(now, operation.id);
560
+ appendAuditEvent({
561
+ sandbox: getSandboxById.get(sandbox.id), operationId: operation.id,
562
+ eventType: 'controller_result', actorKind: 'controller', outcome: 'succeeded', now
563
+ });
564
+ return { snapshot: publicSnapshot(getSandboxById.get(sandbox.id)), replayed: false };
565
+ })();
566
+ },
567
+
568
+ issueBootstrap(operationId, ttlMs = 5 * 60_000, signingKey) {
569
+ return transaction(() => {
570
+ const operation = db.prepare('SELECT * FROM sandbox_operations WHERE id = ?').get(operationId);
571
+ if (!operation || !['create', 'start', 'retry'].includes(operation.kind)
572
+ || !['pending', 'running'].includes(operation.status)) {
573
+ throw new SandboxConflictError('SANDBOX_OPERATION_NOT_FOUND');
574
+ }
575
+ const sandbox = getSandboxById.get(operation.sandbox_id);
576
+ if (!sandbox || sandbox.generation !== operation.generation
577
+ || sandbox.host_epoch !== operation.host_epoch || !sandbox.reservation_held) {
578
+ throw new SandboxConflictError('SANDBOX_STALE_RESULT');
579
+ }
580
+ if (!signingKey) throw new SandboxConflictError('SANDBOX_BOOTSTRAP_SIGNING_KEY_REQUIRED');
581
+ const now = Date.now();
582
+ const credentialPrefix = `sandbox_bootstrap_${operation.id}`;
583
+ const existing = db.prepare(`
584
+ SELECT * FROM sandbox_credentials
585
+ WHERE id LIKE ? AND sandbox_id = ? AND generation = ? AND kind = 'bootstrap'
586
+ AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at >= ?
587
+ ORDER BY created_at DESC LIMIT 1
588
+ `).get(`${credentialPrefix}%`, sandbox.id, sandbox.generation, now);
589
+ if (existing) {
590
+ return {
591
+ token: deriveBootstrapSecret(existing.id, signingKey),
592
+ sandboxId: sandbox.id,
593
+ instanceId: sandbox.instance_id,
594
+ generation: sandbox.generation,
595
+ imageDigest: sandbox.image_digest,
596
+ expiresAt: existing.expires_at
597
+ };
598
+ }
599
+ const credentialId = `${credentialPrefix}_${randomUUID()}`;
600
+ const secret = deriveBootstrapSecret(credentialId, signingKey);
601
+ db.prepare(`
602
+ INSERT INTO sandbox_credentials (
603
+ id, sandbox_id, instance_id, generation, image_digest, kind, secret_hash,
604
+ expires_at, created_at
605
+ ) VALUES (?, ?, ?, ?, ?, 'bootstrap', ?, ?, ?)
606
+ `).run(credentialId, sandbox.id, sandbox.instance_id,
607
+ sandbox.generation, sandbox.image_digest, hashSecret(secret).toString('hex'), now + ttlMs, now);
608
+ return {
609
+ token: secret,
610
+ sandboxId: sandbox.id,
611
+ instanceId: sandbox.instance_id,
612
+ generation: sandbox.generation,
613
+ imageDigest: sandbox.image_digest,
614
+ expiresAt: now + ttlMs
615
+ };
616
+ })();
617
+ },
618
+
619
+ exchangeBootstrap(token, claims) {
620
+ return transaction(() => {
621
+ const now = Date.now();
622
+ const candidates = db.prepare(`
623
+ SELECT * FROM sandbox_credentials
624
+ WHERE kind = 'bootstrap' AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at >= ?
625
+ `).all(now);
626
+ const bootstrap = candidates.find(row => secretMatches(token, row.secret_hash));
627
+ if (!bootstrap) throw new SandboxConflictError('SANDBOX_BOOTSTRAP_INVALID');
628
+ assertBoundClaims(bootstrap, claims);
629
+ const sandbox = getSandboxById.get(bootstrap.sandbox_id);
630
+ if (!credentialMatchesSandbox(bootstrap, sandbox) || !sandbox.reservation_held) {
631
+ throw new SandboxConflictError('SANDBOX_BOOTSTRAP_INVALID');
632
+ }
633
+ const consumed = db.prepare(`
634
+ UPDATE sandbox_credentials SET consumed_at = ?
635
+ WHERE id = ? AND consumed_at IS NULL AND revoked_at IS NULL
636
+ `).run(now, bootstrap.id);
637
+ if (consumed.changes !== 1) throw new SandboxConflictError('SANDBOX_BOOTSTRAP_INVALID');
638
+ db.prepare(`
639
+ UPDATE sandbox_credentials SET revoked_at = ?
640
+ WHERE sandbox_id = ? AND kind = 'agent' AND revoked_at IS NULL
641
+ `).run(now, sandbox.id);
642
+ const secret = randomBytes(32).toString('base64url');
643
+ const credentialId = `sandbox_credential_${randomUUID()}`;
644
+ db.prepare(`
645
+ INSERT INTO sandbox_credentials (
646
+ id, sandbox_id, instance_id, generation, image_digest, kind, secret_hash, created_at
647
+ ) VALUES (?, ?, ?, ?, ?, 'agent', ?, ?)
648
+ `).run(credentialId, sandbox.id, sandbox.instance_id, sandbox.generation,
649
+ sandbox.image_digest, hashSecret(secret).toString('hex'), now);
650
+ appendAuditEvent({
651
+ sandbox, eventType: 'credential_issued', actorKind: 'managed_agent',
652
+ outcome: 'succeeded', now
653
+ });
654
+ return { credentialId, secret };
655
+ })();
656
+ },
657
+
658
+ authenticateCredential(credentialId, secret, claims) {
659
+ const credential = db.prepare(`
660
+ SELECT * FROM sandbox_credentials
661
+ WHERE id = ? AND kind = 'agent' AND revoked_at IS NULL
662
+ `).get(credentialId);
663
+ if (!credential || !secretMatches(secret, credential.secret_hash)) {
664
+ throw new SandboxConflictError('SANDBOX_CREDENTIAL_INVALID');
665
+ }
666
+ assertBoundClaims(credential, claims);
667
+ const sandbox = getSandboxById.get(credential.sandbox_id);
668
+ if (!credentialMatchesSandbox(credential, sandbox) || !sandbox.reservation_held) {
669
+ throw new SandboxConflictError('SANDBOX_CREDENTIAL_INVALID');
670
+ }
671
+ return { sandboxId: sandbox.id, userId: sandbox.user_id };
672
+ }
673
+ };