@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.
- package/cli.js +8 -0
- package/connection/index.js +11 -1
- package/index.js +4 -0
- package/local-runtime/server/api.js +2 -0
- package/local-runtime/server/auth/login.js +1 -0
- package/local-runtime/server/auth/oauth-flow.js +2 -2
- package/local-runtime/server/auth/token.js +4 -0
- package/local-runtime/server/config.js +47 -1
- package/local-runtime/server/database.js +1 -0
- package/local-runtime/server/db/connection.js +346 -4
- package/local-runtime/server/db/sandbox-db.js +673 -0
- package/local-runtime/server/db/user-db.js +137 -7
- package/local-runtime/server/index.js +60 -2
- package/local-runtime/server/routes/sandbox-routes.js +124 -0
- package/local-runtime/server/routes/user-routes.js +30 -15
- package/local-runtime/server/sandbox-agent-auth.js +66 -0
- package/local-runtime/server/sandbox-attestation-listener.js +128 -0
- package/local-runtime/server/sandbox-config.js +42 -0
- package/local-runtime/server/sandbox-host-attestation.js +175 -0
- package/local-runtime/server/sandbox-reconciler.js +355 -0
- package/local-runtime/server/ws-agent.js +34 -11
- package/local-runtime/server/ws-client.js +18 -5
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +153 -92
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/managed-sandbox/agent-runtime.js +73 -0
- package/managed-sandbox/controller.js +118 -0
- package/managed-sandbox/helper.js +437 -0
- package/managed-sandbox/identity-store.js +9 -0
- package/managed-sandbox/runtime-executor.js +387 -0
- 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
|
|
107
|
-
?
|
|
108
|
-
:
|
|
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,
|
|
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
|
-
|
|
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();
|