@yeaft/webchat-agent 1.0.373 → 1.0.375

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 +153 -92
  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,42 @@
1
+ /**
2
+ * Existing Sandbox cleanup needs only the Controller trust and transport configuration.
3
+ * It deliberately does not depend on the product admission flag or creation-only settings.
4
+ * @param {object} config
5
+ * @returns {boolean}
6
+ */
7
+ export function validateSandboxCleanupConfig(config) {
8
+ if (!config?.controllerUrl || !config.controllerToken
9
+ || !config.operationSigningPrivateKey || !config.controllerResultPublicKey
10
+ || !config.controllerHostId || !config.helperAttestationPublicKey
11
+ || !config.controllerClientCert || !config.controllerClientKey
12
+ || !config.controllerCaCert) return false;
13
+ try {
14
+ return new URL(config.controllerUrl).protocol === 'https:';
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Sandbox execution is allowed only through a fully configured dedicated HTTPS Controller.
22
+ * The current mixed-use Server Host is never an implicit execution target.
23
+ * @param {object} config
24
+ * @returns {boolean}
25
+ */
26
+ export function validateSandboxDeploymentConfig(config) {
27
+ if (!config?.enabled || !validateSandboxCleanupConfig(config)
28
+ || !config.bootstrapSigningKey
29
+ || !config.hostAttestationKey || !config.controllerAttestationFingerprint
30
+ || !config.hostAttestationListenerHost
31
+ || !Number.isSafeInteger(config.hostAttestationListenerPort)
32
+ || config.hostAttestationListenerPort <= 0
33
+ || config.hostAttestationListenerPort > 65_535
34
+ || !config.hostAttestationServerCert || !config.hostAttestationServerKey
35
+ || !config.hostAttestationClientCa
36
+ || !Number.isSafeInteger(config.hostAttestationBodyLimitBytes)
37
+ || config.hostAttestationBodyLimitBytes <= 0
38
+ || !config.imageDigest
39
+ || !Number.isSafeInteger(config.hostMemoryReserveMiB)
40
+ || config.hostMemoryReserveMiB <= 0) return false;
41
+ return true;
42
+ }
@@ -0,0 +1,175 @@
1
+ import { createHmac, timingSafeEqual } from 'crypto';
2
+ import db, { transaction } from './db/connection.js';
3
+
4
+ const HEALTH_FIELDS = Object.freeze([
5
+ 'controllerHealthy',
6
+ 'helperHealthy',
7
+ 'runtimeHealthy',
8
+ 'quotaHealthy',
9
+ 'networkHealthy'
10
+ ]);
11
+
12
+ export class SandboxHostAttestationError extends Error {
13
+ constructor(code) {
14
+ super(code);
15
+ this.code = code;
16
+ }
17
+ }
18
+
19
+ function canonicalPayload(attestation) {
20
+ return JSON.stringify({
21
+ hostId: attestation.hostId,
22
+ epoch: attestation.epoch,
23
+ nonce: attestation.nonce,
24
+ observedAt: attestation.observedAt,
25
+ imageDigest: attestation.imageDigest,
26
+ resources: attestation.resources,
27
+ checks: attestation.checks
28
+ });
29
+ }
30
+
31
+ function validIdentifier(value, maxLength = 128) {
32
+ return typeof value === 'string' && value.length > 0 && value.length <= maxLength
33
+ && /^[a-zA-Z0-9._:-]+$/.test(value);
34
+ }
35
+
36
+ function verifySignature(payload, signature, key) {
37
+ if (!key || typeof signature !== 'string') return false;
38
+ const expected = createHmac('sha256', key).update(payload).digest();
39
+ let actual;
40
+ try {
41
+ actual = Buffer.from(signature, 'base64url');
42
+ } catch {
43
+ return false;
44
+ }
45
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
46
+ }
47
+
48
+ function validateAttestation(attestation, config, now) {
49
+ if (!attestation || !validIdentifier(attestation.hostId) || !validIdentifier(attestation.epoch)
50
+ || !validIdentifier(attestation.nonce, 192) || !validIdentifier(attestation.imageDigest, 192)) {
51
+ throw new SandboxHostAttestationError('SANDBOX_HOST_ATTESTATION_INVALID');
52
+ }
53
+ if (attestation.hostId !== config.controllerHostId
54
+ || attestation.imageDigest !== config.imageDigest) {
55
+ throw new SandboxHostAttestationError('SANDBOX_HOST_ATTESTATION_SCOPE_MISMATCH');
56
+ }
57
+ const observedAt = Number(attestation.observedAt);
58
+ if (!Number.isSafeInteger(observedAt)
59
+ || Math.abs(now - observedAt) > config.hostAttestationMaxSkewMs) {
60
+ throw new SandboxHostAttestationError('SANDBOX_HOST_ATTESTATION_STALE');
61
+ }
62
+ const resources = attestation.resources || {};
63
+ if (![resources.cpuMillis, resources.memoryMiB, resources.memoryAvailableMiB, resources.diskGiB]
64
+ .every(value => Number.isSafeInteger(value) && value > 0)
65
+ || resources.memoryAvailableMiB > resources.memoryMiB) {
66
+ throw new SandboxHostAttestationError('SANDBOX_HOST_ATTESTATION_INVALID');
67
+ }
68
+ if (!verifySignature(canonicalPayload(attestation), attestation.signature, config.hostAttestationKey)) {
69
+ throw new SandboxHostAttestationError('SANDBOX_HOST_ATTESTATION_INVALID');
70
+ }
71
+ return Boolean(attestation.checks)
72
+ && HEALTH_FIELDS.every(field => attestation.checks[field] === true);
73
+ }
74
+
75
+ function appendAudit(attestation, outcome, errorCode, now) {
76
+ db.prepare(`
77
+ INSERT INTO sandbox_host_audit_events (
78
+ host_id, epoch, event_type, outcome, error_code, created_at
79
+ ) VALUES (?, ?, 'qualification_attested', ?, ?, ?)
80
+ `).run(
81
+ String(attestation?.hostId || 'invalid').slice(0, 128),
82
+ String(attestation?.epoch || 'invalid').slice(0, 128),
83
+ outcome,
84
+ errorCode,
85
+ now
86
+ );
87
+ }
88
+
89
+ export function registerSandboxHostAttestation(attestation, config, now = Date.now()) {
90
+ try {
91
+ const qualified = validateAttestation(attestation, config, now);
92
+ return transaction(() => {
93
+ const prior = db.prepare('SELECT nonce FROM sandbox_host_attestations WHERE nonce = ?')
94
+ .get(attestation.nonce);
95
+ if (prior) throw new SandboxHostAttestationError('SANDBOX_HOST_ATTESTATION_REPLAYED');
96
+
97
+ const latest = db.prepare(`
98
+ SELECT observed_at FROM sandbox_host_attestations
99
+ WHERE host_id = ? ORDER BY observed_at DESC LIMIT 1
100
+ `).get(attestation.hostId);
101
+ if (latest && Number(attestation.observedAt) <= latest.observed_at) {
102
+ throw new SandboxHostAttestationError('SANDBOX_HOST_ATTESTATION_OUT_OF_ORDER');
103
+ }
104
+
105
+ const priorEpoch = db.prepare(`
106
+ SELECT source_epoch, epoch FROM sandbox_host_epochs WHERE host_id = ?
107
+ `).get(attestation.hostId);
108
+ const allocatedEpoch = priorEpoch
109
+ ? priorEpoch.epoch + Number(priorEpoch.source_epoch !== attestation.epoch)
110
+ : 1;
111
+ const { resources, checks } = attestation;
112
+ db.prepare(`
113
+ INSERT INTO sandbox_hosts (
114
+ id, epoch, qualified, controller_healthy, helper_healthy, runtime_healthy,
115
+ quota_healthy, network_healthy, image_digest, cpu_millis_total,
116
+ memory_mib_total, memory_mib_available, disk_gib_total, updated_at
117
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
118
+ ON CONFLICT(id) DO UPDATE SET
119
+ epoch = excluded.epoch,
120
+ qualified = excluded.qualified,
121
+ controller_healthy = excluded.controller_healthy,
122
+ helper_healthy = excluded.helper_healthy,
123
+ runtime_healthy = excluded.runtime_healthy,
124
+ quota_healthy = excluded.quota_healthy,
125
+ network_healthy = excluded.network_healthy,
126
+ image_digest = excluded.image_digest,
127
+ cpu_millis_total = excluded.cpu_millis_total,
128
+ memory_mib_total = excluded.memory_mib_total,
129
+ memory_mib_available = excluded.memory_mib_available,
130
+ disk_gib_total = excluded.disk_gib_total,
131
+ updated_at = excluded.updated_at
132
+ `).run(
133
+ attestation.hostId, allocatedEpoch, Number(qualified),
134
+ Number(checks.controllerHealthy), Number(checks.helperHealthy),
135
+ Number(checks.runtimeHealthy), Number(checks.quotaHealthy), Number(checks.networkHealthy),
136
+ attestation.imageDigest, resources.cpuMillis, resources.memoryMiB,
137
+ resources.memoryAvailableMiB, resources.diskGiB, now
138
+ );
139
+ db.prepare(`
140
+ INSERT INTO sandbox_host_epochs
141
+ (host_id, source_epoch, epoch, activation_digest, activated_at, updated_at)
142
+ VALUES (?, ?, ?, NULL, NULL, ?)
143
+ ON CONFLICT(host_id) DO UPDATE SET
144
+ source_epoch = excluded.source_epoch,
145
+ epoch = excluded.epoch,
146
+ activation_digest = CASE
147
+ WHEN sandbox_host_epochs.epoch = excluded.epoch
148
+ THEN sandbox_host_epochs.activation_digest ELSE NULL END,
149
+ activated_at = CASE
150
+ WHEN sandbox_host_epochs.epoch = excluded.epoch
151
+ THEN sandbox_host_epochs.activated_at ELSE NULL END,
152
+ updated_at = excluded.updated_at
153
+ `).run(attestation.hostId, attestation.epoch, allocatedEpoch, now);
154
+ db.prepare(`
155
+ INSERT INTO sandbox_host_attestations (nonce, host_id, epoch, observed_at, created_at)
156
+ VALUES (?, ?, ?, ?, ?)
157
+ `).run(attestation.nonce, attestation.hostId, allocatedEpoch, attestation.observedAt, now);
158
+ appendAudit(
159
+ { ...attestation, epoch: allocatedEpoch },
160
+ qualified ? 'accepted' : 'rejected',
161
+ qualified ? null : 'SANDBOX_HOST_NOT_QUALIFIED',
162
+ now
163
+ );
164
+ return { accepted: true, qualified, epoch: allocatedEpoch };
165
+ })();
166
+ } catch (error) {
167
+ const code = error instanceof SandboxHostAttestationError
168
+ ? error.code
169
+ : 'SANDBOX_HOST_ATTESTATION_INVALID';
170
+ appendAudit(attestation, 'rejected', code, now);
171
+ throw error instanceof SandboxHostAttestationError
172
+ ? error
173
+ : new SandboxHostAttestationError(code);
174
+ }
175
+ }
@@ -0,0 +1,355 @@
1
+ import { createHash, randomUUID, sign, verify } from 'crypto';
2
+ import { request as httpsRequest } from 'https';
3
+ import { sandboxDb } from './database.js';
4
+ import { isSandboxAgentReady } from './sandbox-agent-auth.js';
5
+ import {
6
+ validateSandboxCleanupConfig,
7
+ validateSandboxDeploymentConfig
8
+ } from './sandbox-config.js';
9
+
10
+ function canonicalEnvelope(envelope) {
11
+ return JSON.stringify({
12
+ protocolVersion: envelope.protocolVersion,
13
+ operationId: envelope.operationId,
14
+ hostId: envelope.hostId,
15
+ sandboxId: envelope.sandboxId,
16
+ action: envelope.action,
17
+ requestDigest: envelope.requestDigest,
18
+ generation: envelope.generation,
19
+ hostEpoch: envelope.hostEpoch,
20
+ instanceId: envelope.instanceId,
21
+ imageDigest: envelope.imageDigest,
22
+ desiredState: envelope.desiredState,
23
+ issuedAt: envelope.issuedAt,
24
+ expiresAt: envelope.expiresAt,
25
+ nonce: envelope.nonce,
26
+ bootstrap: envelope.bootstrap || null,
27
+ resources: envelope.resources
28
+ });
29
+ }
30
+
31
+ function signEnvelope(envelope, privateKey) {
32
+ return sign(null, Buffer.from(canonicalEnvelope(envelope)), privateKey).toString('base64url');
33
+ }
34
+
35
+ function activationDigest(hostId, epoch) {
36
+ return createHash('sha256')
37
+ .update(JSON.stringify({ protocolVersion: 1, action: 'ACTIVATE_EPOCH', hostId, epoch }))
38
+ .digest('hex');
39
+ }
40
+
41
+ function canonicalHelperAttestation(attestation) {
42
+ return JSON.stringify({
43
+ protocolVersion: attestation.protocolVersion,
44
+ operationId: attestation.operationId,
45
+ hostId: attestation.hostId,
46
+ sandboxId: attestation.sandboxId,
47
+ action: attestation.action,
48
+ requestDigest: attestation.requestDigest,
49
+ generation: attestation.generation,
50
+ hostEpoch: attestation.hostEpoch,
51
+ requestNonce: attestation.requestNonce,
52
+ issuedAt: attestation.issuedAt,
53
+ imageDigest: attestation.imageDigest || null,
54
+ readinessProof: attestation.readinessProof || null,
55
+ absenceProof: attestation.absenceProof || null,
56
+ resourceInspection: attestation.resourceInspection || null
57
+ });
58
+ }
59
+
60
+ function canonicalControllerResult(result) {
61
+ return JSON.stringify({
62
+ operationId: result.operationId,
63
+ action: result.action,
64
+ hostId: result.hostId,
65
+ sandboxId: result.sandboxId,
66
+ requestDigest: result.requestDigest,
67
+ generation: result.generation,
68
+ hostEpoch: result.hostEpoch,
69
+ requestNonce: result.requestNonce,
70
+ issuedAt: result.issuedAt,
71
+ success: result.success,
72
+ imageDigest: result.imageDigest || null,
73
+ helperAttestation: result.helperAttestation || null,
74
+ errorCode: result.errorCode || null
75
+ });
76
+ }
77
+
78
+ function verifyHelperAttestation(result, operation, config, now) {
79
+ const attestation = result.helperAttestation;
80
+ if (!attestation || attestation.protocolVersion !== 1
81
+ || attestation.operationId !== operation.id
82
+ || attestation.hostId !== operation.host_id
83
+ || attestation.sandboxId !== operation.sandbox_id
84
+ || attestation.action !== operation.kind
85
+ || attestation.requestDigest !== operation.request_digest
86
+ || attestation.generation !== operation.generation
87
+ || attestation.hostEpoch !== operation.host_epoch
88
+ || attestation.requestNonce !== operation.requestNonce
89
+ || !Number.isFinite(attestation.issuedAt)
90
+ || Math.abs(now - attestation.issuedAt) > (config.controllerProtocolMaxSkewMs || 30_000)) {
91
+ throw new Error('Controller returned a mismatched Helper attestation');
92
+ }
93
+ let signatureValid = false;
94
+ try {
95
+ signatureValid = verify(
96
+ null,
97
+ Buffer.from(canonicalHelperAttestation(attestation)),
98
+ config.helperAttestationPublicKey,
99
+ Buffer.from(String(attestation.signature || ''), 'base64url')
100
+ );
101
+ } catch {
102
+ signatureValid = false;
103
+ }
104
+ if (!signatureValid) throw new Error('Controller returned an invalid Helper attestation signature');
105
+ return attestation;
106
+ }
107
+
108
+ function verifyResourceInspection(attestation, operation) {
109
+ if (['remove', 'ACTIVATE_EPOCH'].includes(operation.kind)) return;
110
+ const inspection = attestation.resourceInspection;
111
+ const expected = {
112
+ cpuMillis: operation.cpu_millis,
113
+ memoryMiB: operation.memory_mib,
114
+ diskGiB: operation.disk_gib
115
+ };
116
+ if (!inspection
117
+ || inspection.cpuMillis !== expected.cpuMillis
118
+ || inspection.memoryMiB !== expected.memoryMiB
119
+ || inspection.diskGiB !== expected.diskGiB
120
+ || !Number.isInteger(inspection.pidsLimit) || inspection.pidsLimit <= 0
121
+ || !Number.isInteger(inspection.ioWeight) || inspection.ioWeight <= 0
122
+ || inspection.quotaHard !== true
123
+ || inspection.networkPolicy !== 'public-egress-isolated') {
124
+ throw new Error('Helper attestation does not prove the requested Sandbox resource policy');
125
+ }
126
+ }
127
+
128
+ function verifyControllerResult(result, operation, config, now = Date.now()) {
129
+ if (result.operationId !== operation.id
130
+ || result.action !== operation.kind
131
+ || result.hostId !== operation.host_id
132
+ || result.sandboxId !== operation.sandbox_id
133
+ || result.requestDigest !== operation.request_digest
134
+ || result.generation !== operation.generation
135
+ || result.hostEpoch !== operation.host_epoch
136
+ || result.requestNonce !== operation.requestNonce) {
137
+ throw new Error('Controller returned a mismatched operation result');
138
+ }
139
+ if (!Number.isFinite(result.issuedAt)
140
+ || Math.abs(now - result.issuedAt) > (config.controllerProtocolMaxSkewMs || 30_000)) {
141
+ throw new Error('Controller returned a stale operation result');
142
+ }
143
+ let signatureValid = false;
144
+ try {
145
+ signatureValid = verify(
146
+ null,
147
+ Buffer.from(canonicalControllerResult(result)),
148
+ config.controllerResultPublicKey,
149
+ Buffer.from(String(result.signature || ''), 'base64url')
150
+ );
151
+ } catch {
152
+ signatureValid = false;
153
+ }
154
+ if (!signatureValid) throw new Error('Controller returned an invalid operation result signature');
155
+ const attestation = verifyHelperAttestation(result, operation, config, now);
156
+ verifyResourceInspection(attestation, operation);
157
+ return attestation;
158
+ }
159
+
160
+ export function validateControllerConfig(config) {
161
+ return validateSandboxDeploymentConfig(config);
162
+ }
163
+
164
+ function requestController(url, options) {
165
+ return new Promise((resolve, reject) => {
166
+ const request = httpsRequest(url, {
167
+ method: 'POST',
168
+ headers: options.headers,
169
+ cert: options.cert,
170
+ key: options.key,
171
+ ca: options.ca,
172
+ rejectUnauthorized: true,
173
+ timeout: options.timeout
174
+ }, response => {
175
+ const chunks = [];
176
+ response.on('data', chunk => chunks.push(chunk));
177
+ response.on('end', () => resolve({
178
+ ok: response.statusCode >= 200 && response.statusCode < 300,
179
+ status: response.statusCode,
180
+ json: async () => JSON.parse(Buffer.concat(chunks).toString('utf8'))
181
+ }));
182
+ });
183
+ request.on('timeout', () => request.destroy(new Error('Controller request timed out')));
184
+ request.on('error', reject);
185
+ request.end(options.body);
186
+ });
187
+ }
188
+
189
+ export function createSandboxReconciler({
190
+ config,
191
+ store = sandboxDb,
192
+ fetchImpl = requestController,
193
+ logger = console
194
+ }) {
195
+ let running = false;
196
+ const epochActivations = new Map();
197
+
198
+ async function ensureEpochActivated(operation, { allowActivation }) {
199
+ if (!store.isEpochActivated || !store.recordEpochActivation) return allowActivation;
200
+ const epoch = Number(operation.host_epoch);
201
+ if (!Number.isSafeInteger(epoch) || epoch < 1) throw new Error('Invalid Host epoch');
202
+ const digest = activationDigest(operation.host_id, epoch);
203
+ if (store.isEpochActivated(operation.host_id, epoch, digest)) return true;
204
+ if (!allowActivation) return false;
205
+ const key = `${operation.host_id}:${epoch}`;
206
+ if (epochActivations.has(key)) return epochActivations.get(key);
207
+ const activationPromise = (async () => {
208
+ const issuedAt = Date.now();
209
+ const activation = {
210
+ protocolVersion: 1,
211
+ operationId: `activate:${operation.host_id}:${epoch}`,
212
+ hostId: operation.host_id,
213
+ sandboxId: null,
214
+ action: 'ACTIVATE_EPOCH',
215
+ requestDigest: digest,
216
+ generation: null,
217
+ hostEpoch: epoch,
218
+ instanceId: null,
219
+ imageDigest: null,
220
+ desiredState: null,
221
+ issuedAt,
222
+ expiresAt: issuedAt + (config.controllerRequestTimeoutMs || 10_000),
223
+ nonce: randomUUID(),
224
+ bootstrap: null,
225
+ resources: null
226
+ };
227
+ const response = await fetchImpl(new URL('/v1/operations', config.controllerUrl), {
228
+ method: 'POST',
229
+ headers: { authorization: `Bearer ${config.controllerToken}`, 'content-type': 'application/json' },
230
+ body: JSON.stringify({ ...activation, signature: signEnvelope(activation, config.operationSigningPrivateKey) }),
231
+ cert: config.controllerClientCert,
232
+ key: config.controllerClientKey,
233
+ ca: config.controllerCaCert,
234
+ timeout: config.controllerRequestTimeoutMs || 10_000
235
+ });
236
+ if (!response.ok) throw new Error(`Controller returned HTTP ${response.status}`);
237
+ const result = await response.json();
238
+ verifyControllerResult(result, {
239
+ id: activation.operationId,
240
+ kind: activation.action,
241
+ host_id: activation.hostId,
242
+ sandbox_id: activation.sandboxId,
243
+ request_digest: activation.requestDigest,
244
+ generation: activation.generation,
245
+ host_epoch: activation.hostEpoch,
246
+ requestNonce: activation.nonce,
247
+ cpu_millis: null,
248
+ memory_mib: null,
249
+ disk_gib: null
250
+ }, config);
251
+ store.recordEpochActivation(operation.host_id, epoch, digest, Date.now());
252
+ return true;
253
+ })();
254
+ epochActivations.set(key, activationPromise);
255
+ try {
256
+ return await activationPromise;
257
+ } finally {
258
+ epochActivations.delete(key);
259
+ }
260
+ }
261
+
262
+ async function dispatch(pendingOperation, { allowActivation }) {
263
+ if (pendingOperation.host_id !== config.controllerHostId) return;
264
+ if (!await ensureEpochActivated(pendingOperation, { allowActivation })) return;
265
+ const operation = store.admitPendingOperation?.(pendingOperation.id, config, Date.now())
266
+ || (store.admitPendingOperation ? null : pendingOperation);
267
+ if (!operation) return;
268
+ const issuedAt = Date.now();
269
+ const envelope = {
270
+ protocolVersion: 1,
271
+ operationId: operation.id,
272
+ hostId: operation.host_id,
273
+ sandboxId: operation.sandbox_id,
274
+ action: operation.kind,
275
+ requestDigest: operation.request_digest,
276
+ generation: operation.generation,
277
+ hostEpoch: operation.host_epoch,
278
+ instanceId: operation.instance_id,
279
+ imageDigest: operation.image_digest,
280
+ desiredState: operation.desired_state,
281
+ issuedAt,
282
+ expiresAt: issuedAt + (config.controllerRequestTimeoutMs || 10_000),
283
+ nonce: randomUUID(),
284
+ ...(['create', 'start', 'retry'].includes(operation.kind)
285
+ ? { bootstrap: store.issueBootstrap(
286
+ operation.id,
287
+ config.bootstrapTtlMs,
288
+ config.bootstrapSigningKey
289
+ ) }
290
+ : {}),
291
+ resources: {
292
+ cpuMillis: operation.cpu_millis,
293
+ memoryMiB: operation.memory_mib,
294
+ diskGiB: operation.disk_gib
295
+ }
296
+ };
297
+ const response = await fetchImpl(new URL('/v1/operations', config.controllerUrl), {
298
+ method: 'POST',
299
+ headers: {
300
+ authorization: `Bearer ${config.controllerToken}`,
301
+ 'content-type': 'application/json'
302
+ },
303
+ body: JSON.stringify({ ...envelope, signature: signEnvelope(envelope, config.operationSigningPrivateKey) }),
304
+ cert: config.controllerClientCert,
305
+ key: config.controllerClientKey,
306
+ ca: config.controllerCaCert,
307
+ timeout: config.controllerRequestTimeoutMs || 10_000
308
+ });
309
+ if (!response.ok) throw new Error(`Controller returned HTTP ${response.status}`);
310
+ const result = await response.json();
311
+ const helperAttestation = verifyControllerResult(
312
+ result,
313
+ { ...operation, requestNonce: envelope.nonce },
314
+ config
315
+ );
316
+ store.applyControllerResult({
317
+ ...result,
318
+ imageDigest: helperAttestation.imageDigest,
319
+ readinessProof: helperAttestation.readinessProof,
320
+ absenceProof: helperAttestation.absenceProof,
321
+ resourceInspection: helperAttestation.resourceInspection
322
+ }, config, { isAgentReady: isSandboxAgentReady });
323
+ }
324
+
325
+ async function tick(now = Date.now()) {
326
+ if (running || !config) return;
327
+ running = true;
328
+ try {
329
+ store.reconcileRuntimeState?.(now, config, { isAgentReady: isSandboxAgentReady });
330
+ const operations = store.listPendingOperations(now);
331
+ const deploymentReady = validateControllerConfig(config);
332
+ const cleanupReady = validateSandboxCleanupConfig(config);
333
+ const dispatchable = operations.filter(operation => (
334
+ deploymentReady || (cleanupReady && operation.kind === 'remove')
335
+ ));
336
+ await Promise.all(dispatchable.map(operation => dispatch(operation, {
337
+ allowActivation: deploymentReady
338
+ }).catch(error => {
339
+ logger.warn(`[Sandbox] Controller dispatch failed for ${operation.id}: ${error.message}`);
340
+ })));
341
+ } finally {
342
+ running = false;
343
+ }
344
+ }
345
+
346
+ function start() {
347
+ if (!config) return null;
348
+ const timer = setInterval(() => void tick(), config.reconcileIntervalMs || 5_000);
349
+ timer.unref?.();
350
+ void tick();
351
+ return timer;
352
+ }
353
+
354
+ return { start, tick };
355
+ }
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
2
2
  import { WebSocket } from 'ws';
3
3
  import { CONFIG } from './config.js';
4
4
  import { verifyAgent } from './auth.js';
5
+ import { authenticateSandboxAgent, canForceReadyAfterSyncTimeout } from './sandbox-agent-auth.js';
5
6
  import { encodeKey } from './encryption.js';
6
7
  import { agents, pendingAgentConnections } from './context.js';
7
8
  import { userDb } from './database.js';
@@ -103,9 +104,15 @@ export function handleAgentConnection(ws, url) {
103
104
  clearTimeout(pending.timeout);
104
105
  pendingAgentConnections.delete(tempId);
105
106
 
106
- const authResult = skipAgentAuth
107
- ? { valid: true, sessionKey: null, userId: null, username: null }
108
- : verifyAgent(msg.secret);
107
+ const sandboxAuth = !skipAgentAuth && msg.authKind === 'sandbox'
108
+ ? authenticateSandboxAgent(msg, pending)
109
+ : null;
110
+ const authResult = sandboxAuth
111
+ || (msg.authKind
112
+ ? { valid: false, sessionKey: null, userId: null, username: null }
113
+ : skipAgentAuth
114
+ ? { valid: true, sessionKey: null, userId: null, username: null }
115
+ : verifyAgent(msg.secret));
109
116
  if (!authResult.valid) {
110
117
  pruneAgentConnectionGenerations();
111
118
  console.log(`Agent auth failed: ${agentName}`);
@@ -115,19 +122,26 @@ export function handleAgentConnection(ws, url) {
115
122
 
116
123
  const capabilities = Array.isArray(msg.capabilities) ? msg.capabilities : urlCapabilities;
117
124
  const agentVersion = msg.version || null;
125
+ if (sandboxAuth && !capabilities.includes('managed-sandbox')) {
126
+ pruneAgentConnectionGenerations();
127
+ ws.close(1008, 'Invalid Sandbox Agent capability');
128
+ return;
129
+ }
118
130
  // Local no-auth mode still has one durable browser owner. This makes
119
131
  // the server-side Session catalog persistent without changing generic
120
132
  // development-server behavior, which remains ownerless.
121
133
  const localOwner = skipAgentAuth && process.env.YEAFT_LOCAL_RUN === 'true'
122
134
  ? userDb.getOrCreate('dev-user')
123
135
  : null;
124
- const ownerId = localOwner?.id || authResult.userId;
125
- const ownerUsername = localOwner?.username || authResult.username;
136
+ const ownerId = sandboxAuth?.userId || localOwner?.id || authResult.userId;
137
+ const ownerUsername = localOwner?.username || authResult.username || null;
138
+ const registeredInstanceId = sandboxAuth?.instanceId
139
+ || pending.instanceId || pending.agentId || pending.agentName;
126
140
  // Authenticated Agents use an owner-scoped key. SKIP_AUTH preserves
127
141
  // its historical unscoped id while still receiving version metadata.
128
142
  resolvedAgentId = skipAgentAuth
129
143
  ? clientAgentId
130
- : buildAgentMapKey(ownerId, pending.instanceId || pending.agentId || pending.agentName);
144
+ : buildAgentMapKey(ownerId, registeredInstanceId);
131
145
  if (!claimAgentConnection(resolvedAgentId, connectionGeneration)) {
132
146
  resolvedAgentId = null;
133
147
  pruneAgentConnectionGenerations();
@@ -144,7 +158,12 @@ export function handleAgentConnection(ws, url) {
144
158
  ownerId,
145
159
  ownerUsername,
146
160
  agentVersion,
147
- pending.instanceId || pending.agentId || pending.agentName,
161
+ registeredInstanceId,
162
+ sandboxAuth ? {
163
+ sandboxId: sandboxAuth.sandboxId,
164
+ generation: sandboxAuth.generation,
165
+ imageDigest: sandboxAuth.imageDigest,
166
+ } : null,
148
167
  );
149
168
  pruneAgentConnectionGenerations();
150
169
  }
@@ -160,6 +179,10 @@ export function handleAgentConnection(ws, url) {
160
179
  if (!agent) console.error(`[Agent] No agent found for id: ${resolvedAgentId}`);
161
180
  return;
162
181
  }
182
+ if (!skipAgentAuth && agent.ownerId && !userDb.isActive(agent.ownerId)) {
183
+ ws.close(1008, 'Account disabled');
184
+ return;
185
+ }
163
186
  markAgentHeartbeatSeen(agent);
164
187
  const msg = await parseMessage(data, agent.sessionKey);
165
188
  if (msg) {
@@ -225,7 +248,7 @@ function handleAgentDisconnect(agentId, agentName, ws) {
225
248
  broadcastAgentList();
226
249
  }
227
250
 
228
- function completeAgentRegistration(ws, agentId, agentName, workDir, sessionKey, capabilities = [], ownerId = null, ownerUsername = null, agentVersion = null, instanceId = null) {
251
+ function completeAgentRegistration(ws, agentId, agentName, workDir, sessionKey, capabilities = [], ownerId = null, ownerUsername = null, agentVersion = null, instanceId = null, sandboxIdentity = null) {
229
252
  // 如果是重连,保留 conversations;否则(server 重启)创建空 Map
230
253
  const existingAgent = agents.get(agentId);
231
254
  const conversations = existingAgent?.conversations || new Map();
@@ -262,13 +285,13 @@ function completeAgentRegistration(ws, agentId, agentName, workDir, sessionKey,
262
285
  ownerId,
263
286
  ownerUsername,
264
287
  version: agentVersion,
265
- encryptOutbound
288
+ encryptOutbound,
289
+ sandboxIdentity
266
290
  });
267
291
 
268
- // 同步超时保护:30 秒后强制 ready
269
292
  const syncTimeout = setTimeout(() => {
270
293
  const ag = agents.get(agentId);
271
- if (ag?.ws === ws && ag.status === 'syncing') {
294
+ if (ag?.ws === ws && ag.status === 'syncing' && canForceReadyAfterSyncTimeout(ag)) {
272
295
  console.warn(`[Sync] Agent ${agentName} sync timeout, forcing ready`);
273
296
  ag.status = 'ready';
274
297
  broadcastAgentList();