@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.
- 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 +90 -29
- 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
|
Binary file
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { setManagedSandboxIdentity } from './identity-store.js';
|
|
4
|
+
|
|
5
|
+
function assertIdentity(value) {
|
|
6
|
+
const claims = value?.claims;
|
|
7
|
+
if (!value?.serverUrl || !claims?.sandboxId || !claims?.instanceId
|
|
8
|
+
|| !Number.isInteger(claims.generation) || !claims.imageDigest) {
|
|
9
|
+
throw new Error('Managed Sandbox Agent rejected an invalid identity');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function readJson(path) {
|
|
14
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function persistCredential(path, identity) {
|
|
18
|
+
const temporary = join(dirname(path), `.managed-agent-credential-${process.pid}.tmp`);
|
|
19
|
+
await writeFile(temporary, JSON.stringify(identity), { mode: 0o600 });
|
|
20
|
+
await rename(temporary, path);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function exchangeUrl(serverUrl) {
|
|
24
|
+
const url = new URL(serverUrl);
|
|
25
|
+
if (url.protocol === 'wss:') url.protocol = 'https:';
|
|
26
|
+
else if (url.protocol === 'ws:') url.protocol = 'http:';
|
|
27
|
+
url.pathname = '/api/sandbox/bootstrap/exchange';
|
|
28
|
+
url.search = '';
|
|
29
|
+
return url;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function loadManagedSandboxIdentity({ bootstrapFile, credentialFile, fetchImpl = fetch }) {
|
|
33
|
+
try {
|
|
34
|
+
const saved = await readJson(credentialFile);
|
|
35
|
+
assertIdentity(saved);
|
|
36
|
+
if (!saved.credentialId || !saved.secret) throw new Error('Managed Sandbox Agent credential is incomplete');
|
|
37
|
+
return saved;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error.code !== 'ENOENT') throw error;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const bootstrap = await readJson(bootstrapFile);
|
|
43
|
+
assertIdentity(bootstrap);
|
|
44
|
+
if (!bootstrap.token) throw new Error('Managed Sandbox Agent bootstrap token is missing');
|
|
45
|
+
const response = await fetchImpl(exchangeUrl(bootstrap.serverUrl), {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'content-type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ token: bootstrap.token, claims: bootstrap.claims })
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok) throw new Error(`Managed Sandbox Agent bootstrap exchange failed (${response.status})`);
|
|
51
|
+
const credential = await response.json();
|
|
52
|
+
const identity = { ...bootstrap, token: undefined, ...credential };
|
|
53
|
+
if (!identity.credentialId || !identity.secret) throw new Error('Managed Sandbox Agent bootstrap returned no credential');
|
|
54
|
+
await persistCredential(credentialFile, identity);
|
|
55
|
+
await unlink(bootstrapFile);
|
|
56
|
+
return identity;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function runManagedSandboxAgent(args, options = {}) {
|
|
60
|
+
const bootstrapIndex = args.indexOf('--bootstrap-file');
|
|
61
|
+
if (bootstrapIndex < 0 || !args[bootstrapIndex + 1]) {
|
|
62
|
+
throw new Error('managed-sandbox requires --bootstrap-file');
|
|
63
|
+
}
|
|
64
|
+
const bootstrapFile = args[bootstrapIndex + 1];
|
|
65
|
+
const credentialFile = options.credentialFile || '/home/yeaft/.yeaft/managed-agent-credential';
|
|
66
|
+
const identity = await loadManagedSandboxIdentity({ bootstrapFile, credentialFile, fetchImpl: options.fetchImpl });
|
|
67
|
+
setManagedSandboxIdentity(identity);
|
|
68
|
+
process.env.SERVER_URL = identity.serverUrl;
|
|
69
|
+
process.env.AGENT_NAME = identity.claims.sandboxId;
|
|
70
|
+
process.env.YEAFT_AGENT_INSTANCE = identity.claims.instanceId;
|
|
71
|
+
process.env.WORK_DIR = '/workspace';
|
|
72
|
+
await (options.startAgent || (() => import('../index.js')))();
|
|
73
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createServer as createHttpsServer } from 'node:https';
|
|
2
|
+
import { sign, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
5
|
+
|
|
6
|
+
function canonicalControllerResult(result) {
|
|
7
|
+
return JSON.stringify({
|
|
8
|
+
operationId: result.operationId,
|
|
9
|
+
action: result.action,
|
|
10
|
+
hostId: result.hostId,
|
|
11
|
+
sandboxId: result.sandboxId,
|
|
12
|
+
requestDigest: result.requestDigest,
|
|
13
|
+
generation: result.generation,
|
|
14
|
+
hostEpoch: result.hostEpoch,
|
|
15
|
+
requestNonce: result.requestNonce,
|
|
16
|
+
issuedAt: result.issuedAt,
|
|
17
|
+
success: result.success,
|
|
18
|
+
imageDigest: result.imageDigest || null,
|
|
19
|
+
helperAttestation: result.helperAttestation || null,
|
|
20
|
+
errorCode: result.errorCode || null
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function tokenMatches(header, expectedToken) {
|
|
25
|
+
const prefix = 'Bearer ';
|
|
26
|
+
if (typeof header !== 'string' || !header.startsWith(prefix)) return false;
|
|
27
|
+
const actual = Buffer.from(header.slice(prefix.length));
|
|
28
|
+
const expected = Buffer.from(expectedToken);
|
|
29
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeJson(response, status, body) {
|
|
33
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
34
|
+
response.end(JSON.stringify(body));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Dedicated-Host Controller boundary. Privileged work remains exclusively in
|
|
39
|
+
* the Helper; the Controller only authenticates Server requests and signs the
|
|
40
|
+
* Helper's durable result for transport back to the control plane.
|
|
41
|
+
*/
|
|
42
|
+
export function createSandboxController({ config, helper, now = Date.now, createServer = createHttpsServer }) {
|
|
43
|
+
if (!config?.hostId || !config.token || !config.tlsCert || !config.tlsKey || !config.clientCa
|
|
44
|
+
|| !config.resultSigningPrivateKey || !helper?.execute) {
|
|
45
|
+
throw new Error('Sandbox Controller requires complete dedicated Host configuration');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function execute(operation) {
|
|
49
|
+
if (!operation || operation.hostId !== config.hostId) {
|
|
50
|
+
throw new Error('Sandbox Controller rejected an operation for another Host');
|
|
51
|
+
}
|
|
52
|
+
const helperResult = await helper.execute(operation);
|
|
53
|
+
const result = {
|
|
54
|
+
operationId: operation.operationId,
|
|
55
|
+
action: operation.action,
|
|
56
|
+
hostId: operation.hostId,
|
|
57
|
+
sandboxId: operation.sandboxId || null,
|
|
58
|
+
requestDigest: operation.requestDigest,
|
|
59
|
+
generation: operation.generation || null,
|
|
60
|
+
hostEpoch: operation.hostEpoch,
|
|
61
|
+
requestNonce: operation.nonce,
|
|
62
|
+
issuedAt: now(),
|
|
63
|
+
success: helperResult.success === true,
|
|
64
|
+
imageDigest: helperResult.helperAttestation?.imageDigest || null,
|
|
65
|
+
helperAttestation: helperResult.helperAttestation || null,
|
|
66
|
+
errorCode: helperResult.errorCode || null
|
|
67
|
+
};
|
|
68
|
+
result.signature = sign(
|
|
69
|
+
null,
|
|
70
|
+
Buffer.from(canonicalControllerResult(result)),
|
|
71
|
+
config.resultSigningPrivateKey
|
|
72
|
+
).toString('base64url');
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const server = createServer({
|
|
77
|
+
cert: config.tlsCert,
|
|
78
|
+
key: config.tlsKey,
|
|
79
|
+
ca: config.clientCa,
|
|
80
|
+
requestCert: true,
|
|
81
|
+
rejectUnauthorized: true
|
|
82
|
+
}, (request, response) => {
|
|
83
|
+
if (request.method !== 'POST' || request.url !== '/v1/operations') {
|
|
84
|
+
writeJson(response, 404, { code: 'NOT_FOUND' });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!request.socket.authorized || !tokenMatches(request.headers.authorization, config.token)) {
|
|
88
|
+
writeJson(response, 401, { code: 'UNAUTHORIZED' });
|
|
89
|
+
request.resume();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const chunks = [];
|
|
94
|
+
let size = 0;
|
|
95
|
+
request.on('data', chunk => {
|
|
96
|
+
size += chunk.length;
|
|
97
|
+
if (size > MAX_REQUEST_BYTES) request.destroy();
|
|
98
|
+
else chunks.push(chunk);
|
|
99
|
+
});
|
|
100
|
+
request.on('end', async () => {
|
|
101
|
+
if (size > MAX_REQUEST_BYTES) return;
|
|
102
|
+
try {
|
|
103
|
+
const operation = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
104
|
+
writeJson(response, 200, await execute(operation));
|
|
105
|
+
} catch {
|
|
106
|
+
writeJson(response, 400, { code: 'SANDBOX_OPERATION_REJECTED' });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
execute,
|
|
113
|
+
listen: (...args) => server.listen(...args),
|
|
114
|
+
close: () => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { canonicalControllerResult };
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { createHash, sign, verify } from 'node:crypto';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
|
|
6
|
+
const ALLOWED_ACTIONS = new Set(['ACTIVATE_EPOCH', 'create', 'start', 'stop', 'retry', 'remove']);
|
|
7
|
+
|
|
8
|
+
function canonicalOperation(operation) {
|
|
9
|
+
return JSON.stringify({
|
|
10
|
+
protocolVersion: operation.protocolVersion,
|
|
11
|
+
operationId: operation.operationId,
|
|
12
|
+
hostId: operation.hostId,
|
|
13
|
+
sandboxId: operation.sandboxId || null,
|
|
14
|
+
action: operation.action,
|
|
15
|
+
requestDigest: operation.requestDigest,
|
|
16
|
+
generation: operation.generation || null,
|
|
17
|
+
hostEpoch: operation.hostEpoch,
|
|
18
|
+
instanceId: operation.instanceId,
|
|
19
|
+
imageDigest: operation.imageDigest,
|
|
20
|
+
desiredState: operation.desiredState,
|
|
21
|
+
issuedAt: operation.issuedAt,
|
|
22
|
+
expiresAt: operation.expiresAt,
|
|
23
|
+
nonce: operation.nonce,
|
|
24
|
+
bootstrap: operation.bootstrap || null,
|
|
25
|
+
resources: operation.resources
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function digestOperation(operation) {
|
|
30
|
+
return createHash('sha256').update(canonicalOperation(operation)).digest('hex');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function canonicalAttestation(attestation) {
|
|
34
|
+
return JSON.stringify({
|
|
35
|
+
protocolVersion: attestation.protocolVersion,
|
|
36
|
+
operationId: attestation.operationId,
|
|
37
|
+
hostId: attestation.hostId,
|
|
38
|
+
sandboxId: attestation.sandboxId,
|
|
39
|
+
action: attestation.action,
|
|
40
|
+
requestDigest: attestation.requestDigest,
|
|
41
|
+
generation: attestation.generation,
|
|
42
|
+
hostEpoch: attestation.hostEpoch,
|
|
43
|
+
requestNonce: attestation.requestNonce,
|
|
44
|
+
issuedAt: attestation.issuedAt,
|
|
45
|
+
imageDigest: attestation.imageDigest || null,
|
|
46
|
+
readinessProof: attestation.readinessProof || null,
|
|
47
|
+
absenceProof: attestation.absenceProof || null,
|
|
48
|
+
resourceInspection: attestation.resourceInspection || null
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertProofShape(operation, result) {
|
|
53
|
+
if (!result || typeof result !== 'object' || typeof result.success !== 'boolean') {
|
|
54
|
+
throw new Error('Sandbox Helper executor returned an invalid result');
|
|
55
|
+
}
|
|
56
|
+
if (!result.success) return;
|
|
57
|
+
if (operation.action === 'remove') {
|
|
58
|
+
const proof = result.absenceProof;
|
|
59
|
+
if (!proof || proof.container !== true || proof.storage !== true || proof.quota !== true
|
|
60
|
+
|| proof.network !== true || proof.credential !== true) {
|
|
61
|
+
throw new Error('Sandbox Helper executor did not prove resource absence');
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const proof = result.readinessProof;
|
|
66
|
+
const inspection = result.resourceInspection;
|
|
67
|
+
const requiredProofs = ['image', 'cpu', 'memory', 'pid', 'io', 'quota', 'network', 'credential'];
|
|
68
|
+
if (!proof || !requiredProofs.every(key => proof[key] === true)
|
|
69
|
+
|| !inspection || inspection.cpuMillis !== operation.resources.cpuMillis
|
|
70
|
+
|| inspection.memoryMiB !== operation.resources.memoryMiB
|
|
71
|
+
|| inspection.diskGiB !== operation.resources.diskGiB
|
|
72
|
+
|| !Number.isInteger(inspection.pidsLimit) || inspection.pidsLimit <= 0
|
|
73
|
+
|| !Number.isInteger(inspection.ioWeight) || inspection.ioWeight <= 0
|
|
74
|
+
|| inspection.quotaHard !== true
|
|
75
|
+
|| inspection.networkPolicy !== 'public-egress-isolated') {
|
|
76
|
+
throw new Error('Sandbox Helper executor did not prove the requested resource policy');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildAttestation(operation, result, config, issuedAt) {
|
|
81
|
+
const attestation = {
|
|
82
|
+
protocolVersion: 1,
|
|
83
|
+
operationId: operation.operationId,
|
|
84
|
+
hostId: operation.hostId,
|
|
85
|
+
sandboxId: operation.sandboxId || null,
|
|
86
|
+
action: operation.action,
|
|
87
|
+
requestDigest: operation.requestDigest,
|
|
88
|
+
generation: operation.generation || null,
|
|
89
|
+
hostEpoch: operation.hostEpoch,
|
|
90
|
+
requestNonce: operation.nonce,
|
|
91
|
+
issuedAt,
|
|
92
|
+
imageDigest: ['remove', 'ACTIVATE_EPOCH'].includes(operation.action) ? null : operation.imageDigest,
|
|
93
|
+
readinessProof: result.success && !['remove', 'ACTIVATE_EPOCH'].includes(operation.action) ? result.readinessProof : null,
|
|
94
|
+
absenceProof: result.success && operation.action === 'remove' ? result.absenceProof : null,
|
|
95
|
+
resourceInspection: result.success && !['remove', 'ACTIVATE_EPOCH'].includes(operation.action) ? result.resourceInspection : null
|
|
96
|
+
};
|
|
97
|
+
attestation.signature = sign(
|
|
98
|
+
null,
|
|
99
|
+
Buffer.from(canonicalAttestation(attestation)),
|
|
100
|
+
config.attestationSigningPrivateKey
|
|
101
|
+
).toString('base64url');
|
|
102
|
+
return { ...result, helperAttestation: attestation };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateOperation(operation, config, now) {
|
|
106
|
+
const activation = operation?.action === 'ACTIVATE_EPOCH';
|
|
107
|
+
if (!operation || operation.protocolVersion !== 1
|
|
108
|
+
|| !operation.operationId || (!activation && (!operation.sandboxId || !operation.instanceId))
|
|
109
|
+
|| operation.hostId !== config.hostId
|
|
110
|
+
|| !ALLOWED_ACTIONS.has(operation.action)
|
|
111
|
+
|| (!activation && (!Number.isInteger(operation.generation) || operation.generation < 1))
|
|
112
|
+
|| !operation.requestDigest
|
|
113
|
+
|| !Number.isSafeInteger(operation.hostEpoch) || operation.hostEpoch <= 0
|
|
114
|
+
|| !operation.nonce
|
|
115
|
+
|| !Number.isFinite(operation.issuedAt) || !Number.isFinite(operation.expiresAt)
|
|
116
|
+
|| operation.issuedAt > now + config.maxClockSkewMs
|
|
117
|
+
|| operation.expiresAt < now
|
|
118
|
+
|| operation.expiresAt - operation.issuedAt > config.maxOperationTtlMs) {
|
|
119
|
+
throw new Error('Sandbox Helper rejected an invalid operation envelope');
|
|
120
|
+
}
|
|
121
|
+
if (!activation && operation.action !== 'remove') {
|
|
122
|
+
const resources = operation.resources;
|
|
123
|
+
if (!resources || !Number.isInteger(resources.cpuMillis) || resources.cpuMillis <= 0
|
|
124
|
+
|| !Number.isInteger(resources.memoryMiB) || resources.memoryMiB <= 0
|
|
125
|
+
|| !Number.isInteger(resources.diskGiB) || resources.diskGiB <= 0
|
|
126
|
+
|| operation.imageDigest !== config.imageDigest) {
|
|
127
|
+
throw new Error('Sandbox Helper rejected an invalid resource policy');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let valid = false;
|
|
131
|
+
try {
|
|
132
|
+
valid = verify(
|
|
133
|
+
null,
|
|
134
|
+
Buffer.from(canonicalOperation(operation)),
|
|
135
|
+
config.operationSigningPublicKey,
|
|
136
|
+
Buffer.from(String(operation.signature || ''), 'base64url')
|
|
137
|
+
);
|
|
138
|
+
} catch {
|
|
139
|
+
valid = false;
|
|
140
|
+
}
|
|
141
|
+
if (!valid) throw new Error('Sandbox Helper rejected an invalid operation signature');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Root-only durable authorization boundary for a dedicated Sandbox Host.
|
|
146
|
+
* The injected executor is the only component allowed to perform runtime actions.
|
|
147
|
+
*/
|
|
148
|
+
export function createSandboxHelper({ config, executor, now = Date.now }) {
|
|
149
|
+
if (!config?.hostId || !config.imageDigest || !config.operationSigningPublicKey
|
|
150
|
+
|| !config.attestationSigningPrivateKey || !config.journalPath || !executor?.execute) {
|
|
151
|
+
throw new Error('Sandbox Helper requires a complete dedicated Host configuration');
|
|
152
|
+
}
|
|
153
|
+
const effectiveConfig = {
|
|
154
|
+
maxClockSkewMs: 30_000,
|
|
155
|
+
maxOperationTtlMs: 30_000,
|
|
156
|
+
...config
|
|
157
|
+
};
|
|
158
|
+
mkdirSync(dirname(effectiveConfig.journalPath), { recursive: true, mode: 0o700 });
|
|
159
|
+
const db = new DatabaseSync(effectiveConfig.journalPath);
|
|
160
|
+
const epochLockDb = new DatabaseSync(`${effectiveConfig.journalPath}.epoch-lock`);
|
|
161
|
+
db.exec(`
|
|
162
|
+
PRAGMA journal_mode = WAL;
|
|
163
|
+
PRAGMA synchronous = FULL;
|
|
164
|
+
PRAGMA busy_timeout = 5000;
|
|
165
|
+
CREATE TABLE IF NOT EXISTS helper_state (
|
|
166
|
+
key TEXT PRIMARY KEY,
|
|
167
|
+
value TEXT NOT NULL
|
|
168
|
+
);
|
|
169
|
+
CREATE TABLE IF NOT EXISTS helper_operations (
|
|
170
|
+
operation_id TEXT PRIMARY KEY,
|
|
171
|
+
sandbox_id TEXT,
|
|
172
|
+
action TEXT,
|
|
173
|
+
request_digest TEXT NOT NULL,
|
|
174
|
+
host_epoch TEXT NOT NULL,
|
|
175
|
+
status TEXT NOT NULL CHECK(status IN ('in_progress', 'succeeded', 'failed', 'recovery_required')),
|
|
176
|
+
result_json TEXT,
|
|
177
|
+
updated_at INTEGER NOT NULL
|
|
178
|
+
);
|
|
179
|
+
`);
|
|
180
|
+
epochLockDb.exec(`
|
|
181
|
+
PRAGMA journal_mode = DELETE;
|
|
182
|
+
PRAGMA synchronous = FULL;
|
|
183
|
+
PRAGMA busy_timeout = 0;
|
|
184
|
+
CREATE TABLE IF NOT EXISTS helper_epoch_lock (
|
|
185
|
+
id INTEGER PRIMARY KEY CHECK(id = 1),
|
|
186
|
+
holder TEXT
|
|
187
|
+
);
|
|
188
|
+
INSERT OR IGNORE INTO helper_epoch_lock(id, holder) VALUES (1, NULL);
|
|
189
|
+
`);
|
|
190
|
+
const operationColumns = db.prepare('PRAGMA table_info(helper_operations)').all();
|
|
191
|
+
if (!operationColumns.some(column => column.name === 'sandbox_id')) {
|
|
192
|
+
db.exec('ALTER TABLE helper_operations ADD COLUMN sandbox_id TEXT');
|
|
193
|
+
}
|
|
194
|
+
if (!operationColumns.some(column => column.name === 'action')) {
|
|
195
|
+
db.exec('ALTER TABLE helper_operations ADD COLUMN action TEXT');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let executing = 0;
|
|
199
|
+
let executionWaiter = null;
|
|
200
|
+
let queuedActivations = 0;
|
|
201
|
+
let activationTail = Promise.resolve();
|
|
202
|
+
|
|
203
|
+
async function acquireEpochLock() {
|
|
204
|
+
while (true) {
|
|
205
|
+
try {
|
|
206
|
+
epochLockDb.exec('BEGIN IMMEDIATE');
|
|
207
|
+
return;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (!String(error?.message || '').includes('database is locked')) throw error;
|
|
210
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function releaseEpochLock() {
|
|
216
|
+
epochLockDb.exec('COMMIT');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function activeEpoch() {
|
|
220
|
+
const storedEpoch = db.prepare("SELECT value FROM helper_state WHERE key = 'active_epoch'").get()?.value;
|
|
221
|
+
const digest = db.prepare("SELECT value FROM helper_state WHERE key = 'active_epoch_digest'").get()?.value;
|
|
222
|
+
if (!storedEpoch && !digest) return null;
|
|
223
|
+
const epoch = Number(storedEpoch);
|
|
224
|
+
if (!Number.isSafeInteger(epoch) || epoch <= 0 || !digest) {
|
|
225
|
+
throw new Error('Sandbox Helper found invalid durable Host epoch state');
|
|
226
|
+
}
|
|
227
|
+
return { epoch, digest };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function writeActiveEpoch(epoch, digest) {
|
|
231
|
+
const write = db.prepare(`INSERT INTO helper_state(key, value) VALUES (?, ?)
|
|
232
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`);
|
|
233
|
+
write.run('active_epoch', epoch);
|
|
234
|
+
write.run('active_epoch_digest', digest);
|
|
235
|
+
write.run(`epoch:${epoch}`, digest);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function activationIntent() {
|
|
239
|
+
const storedEpoch = db.prepare("SELECT value FROM helper_state WHERE key = 'activation_intent_epoch'").get()?.value;
|
|
240
|
+
const digest = db.prepare("SELECT value FROM helper_state WHERE key = 'activation_intent_digest'").get()?.value;
|
|
241
|
+
if (!storedEpoch && !digest) return null;
|
|
242
|
+
const epoch = Number(storedEpoch);
|
|
243
|
+
if (!Number.isSafeInteger(epoch) || epoch <= 0 || !digest) {
|
|
244
|
+
throw new Error('Sandbox Helper found invalid durable Host epoch activation intent');
|
|
245
|
+
}
|
|
246
|
+
return { epoch, digest };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function writeActivationIntent(epoch, digest) {
|
|
250
|
+
db.exec('BEGIN IMMEDIATE');
|
|
251
|
+
try {
|
|
252
|
+
const current = activeEpoch();
|
|
253
|
+
const pending = activationIntent();
|
|
254
|
+
if (current?.epoch === epoch) {
|
|
255
|
+
if (current.digest !== digest) throw new Error('Sandbox Helper rejected conflicting epoch activation');
|
|
256
|
+
db.exec('COMMIT');
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
if ((current && epoch < current.epoch) || (pending && pending.epoch > epoch)) {
|
|
260
|
+
throw new Error('Sandbox Helper rejected epoch rollback');
|
|
261
|
+
}
|
|
262
|
+
if (pending?.epoch === epoch && pending.digest !== digest) {
|
|
263
|
+
throw new Error('Sandbox Helper rejected conflicting epoch activation');
|
|
264
|
+
}
|
|
265
|
+
const write = db.prepare(`INSERT INTO helper_state(key, value) VALUES (?, ?)
|
|
266
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`);
|
|
267
|
+
write.run('activation_intent_epoch', epoch);
|
|
268
|
+
write.run('activation_intent_digest', digest);
|
|
269
|
+
db.exec('COMMIT');
|
|
270
|
+
return true;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
db.exec('ROLLBACK');
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function activateEpoch(operation) {
|
|
278
|
+
validateOperation(operation, effectiveConfig, now());
|
|
279
|
+
if (operation.action !== 'ACTIVATE_EPOCH') {
|
|
280
|
+
throw new Error('Sandbox Helper rejected an invalid epoch activation');
|
|
281
|
+
}
|
|
282
|
+
const epoch = operation.hostEpoch;
|
|
283
|
+
// The durable epoch identity is the Server-authorized immutable activation
|
|
284
|
+
// digest. Per-request nonce/timestamps remain covered by the signature but
|
|
285
|
+
// must not make an idempotent activation look like a conflicting epoch.
|
|
286
|
+
const digest = operation.requestDigest;
|
|
287
|
+
let activationNeeded;
|
|
288
|
+
try {
|
|
289
|
+
activationNeeded = writeActivationIntent(epoch, digest);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return Promise.reject(error);
|
|
292
|
+
}
|
|
293
|
+
if (!activationNeeded) {
|
|
294
|
+
return Promise.resolve(buildAttestation(
|
|
295
|
+
operation, { success: true, activated: false }, effectiveConfig, now()
|
|
296
|
+
));
|
|
297
|
+
}
|
|
298
|
+
queuedActivations++;
|
|
299
|
+
const activation = activationTail.then(async () => {
|
|
300
|
+
try {
|
|
301
|
+
if (executing > 0) await new Promise(resolve => { executionWaiter = resolve; });
|
|
302
|
+
await acquireEpochLock();
|
|
303
|
+
try {
|
|
304
|
+
const current = activeEpoch();
|
|
305
|
+
if (current?.epoch === epoch) {
|
|
306
|
+
if (current.digest !== digest) throw new Error('Sandbox Helper rejected conflicting epoch activation');
|
|
307
|
+
return buildAttestation(operation, { success: true, activated: false }, effectiveConfig, now());
|
|
308
|
+
}
|
|
309
|
+
if (current && epoch < current.epoch) {
|
|
310
|
+
throw new Error('Sandbox Helper rejected epoch rollback');
|
|
311
|
+
}
|
|
312
|
+
db.exec('BEGIN IMMEDIATE');
|
|
313
|
+
try {
|
|
314
|
+
writeActiveEpoch(epoch, digest);
|
|
315
|
+
const pending = activationIntent();
|
|
316
|
+
if (pending?.epoch === epoch && pending.digest === digest) {
|
|
317
|
+
db.prepare("DELETE FROM helper_state WHERE key IN ('activation_intent_epoch', 'activation_intent_digest')").run();
|
|
318
|
+
}
|
|
319
|
+
db.exec('COMMIT');
|
|
320
|
+
} catch (error) {
|
|
321
|
+
db.exec('ROLLBACK');
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
return buildAttestation(operation, { success: true, activated: true }, effectiveConfig, now());
|
|
325
|
+
} finally {
|
|
326
|
+
releaseEpochLock();
|
|
327
|
+
}
|
|
328
|
+
} finally {
|
|
329
|
+
queuedActivations--;
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
activationTail = activation.catch(() => {});
|
|
333
|
+
return activation;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const interrupted = db.prepare("SELECT operation_id, sandbox_id FROM helper_operations WHERE status = 'in_progress'").all();
|
|
337
|
+
if (interrupted.length > 0) {
|
|
338
|
+
db.exec('BEGIN IMMEDIATE');
|
|
339
|
+
try {
|
|
340
|
+
db.prepare("UPDATE helper_operations SET status = 'recovery_required', updated_at = ? WHERE status = 'in_progress'")
|
|
341
|
+
.run(now());
|
|
342
|
+
const recordRecovery = db.prepare("INSERT INTO helper_state(key, value) VALUES (?, '1') ON CONFLICT(key) DO UPDATE SET value = excluded.value");
|
|
343
|
+
for (const operation of interrupted) {
|
|
344
|
+
recordRecovery.run(operation.sandbox_id ? `recovery:${operation.sandbox_id}` : 'recovery_required');
|
|
345
|
+
}
|
|
346
|
+
db.exec('COMMIT');
|
|
347
|
+
} catch (error) {
|
|
348
|
+
db.exec('ROLLBACK');
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function assertAvailable(operation) {
|
|
354
|
+
const legacyRecovery = db.prepare("SELECT value FROM helper_state WHERE key = 'recovery_required'").get();
|
|
355
|
+
const sandboxRecovery = db.prepare('SELECT value FROM helper_state WHERE key = ?')
|
|
356
|
+
.get(`recovery:${operation.sandboxId}`);
|
|
357
|
+
if (legacyRecovery?.value === '1' || (sandboxRecovery?.value === '1' && operation.action !== 'remove')) {
|
|
358
|
+
throw new Error('Sandbox Helper requires operator recovery');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function execute(operation) {
|
|
363
|
+
if (operation?.action === 'ACTIVATE_EPOCH') return activateEpoch(operation);
|
|
364
|
+
validateOperation(operation, effectiveConfig, now());
|
|
365
|
+
while (queuedActivations > 0) await activationTail;
|
|
366
|
+
await acquireEpochLock();
|
|
367
|
+
const requestDigest = digestOperation(operation);
|
|
368
|
+
db.exec('BEGIN IMMEDIATE');
|
|
369
|
+
try {
|
|
370
|
+
const existing = db.prepare('SELECT * FROM helper_operations WHERE operation_id = ?').get(operation.operationId);
|
|
371
|
+
if (existing) {
|
|
372
|
+
if (existing.request_digest !== requestDigest) {
|
|
373
|
+
throw new Error('Sandbox Helper rejected operation ID reuse with a different request');
|
|
374
|
+
}
|
|
375
|
+
if (existing.status === 'succeeded' || existing.status === 'failed') {
|
|
376
|
+
db.exec('COMMIT');
|
|
377
|
+
releaseEpochLock();
|
|
378
|
+
return JSON.parse(existing.result_json);
|
|
379
|
+
}
|
|
380
|
+
throw new Error('Sandbox Helper operation is not safely replayable');
|
|
381
|
+
}
|
|
382
|
+
const epoch = activeEpoch();
|
|
383
|
+
const pending = activationIntent();
|
|
384
|
+
if (!epoch || operation.hostEpoch !== epoch.epoch
|
|
385
|
+
|| (pending && operation.hostEpoch !== pending.epoch)) {
|
|
386
|
+
throw new Error('Sandbox Helper rejected an inactive Host epoch');
|
|
387
|
+
}
|
|
388
|
+
assertAvailable(operation);
|
|
389
|
+
db.prepare(`INSERT INTO helper_operations
|
|
390
|
+
(operation_id, sandbox_id, action, request_digest, host_epoch, status, updated_at)
|
|
391
|
+
VALUES (?, ?, ?, ?, ?, 'in_progress', ?)`)
|
|
392
|
+
.run(operation.operationId, operation.sandboxId, operation.action,
|
|
393
|
+
requestDigest, operation.hostEpoch, now());
|
|
394
|
+
db.exec('COMMIT');
|
|
395
|
+
} catch (error) {
|
|
396
|
+
db.exec('ROLLBACK');
|
|
397
|
+
releaseEpochLock();
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
executing++;
|
|
402
|
+
let result;
|
|
403
|
+
let status;
|
|
404
|
+
try {
|
|
405
|
+
try {
|
|
406
|
+
const executorResult = await executor.execute(operation);
|
|
407
|
+
assertProofShape(operation, executorResult);
|
|
408
|
+
result = buildAttestation(operation, executorResult, effectiveConfig, now());
|
|
409
|
+
status = executorResult.success ? 'succeeded' : 'failed';
|
|
410
|
+
} catch (error) {
|
|
411
|
+
result = buildAttestation(operation, {
|
|
412
|
+
success: false,
|
|
413
|
+
errorCode: 'SANDBOX_HELPER_EXECUTION_FAILED'
|
|
414
|
+
}, effectiveConfig, now());
|
|
415
|
+
status = 'failed';
|
|
416
|
+
}
|
|
417
|
+
db.prepare('UPDATE helper_operations SET status = ?, result_json = ?, updated_at = ? WHERE operation_id = ?')
|
|
418
|
+
.run(status, JSON.stringify(result), now(), operation.operationId);
|
|
419
|
+
return result;
|
|
420
|
+
} finally {
|
|
421
|
+
releaseEpochLock();
|
|
422
|
+
executing--;
|
|
423
|
+
if (executing === 0 && executionWaiter) {
|
|
424
|
+
const resolve = executionWaiter;
|
|
425
|
+
executionWaiter = null;
|
|
426
|
+
resolve();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { execute, activateEpoch, activeEpoch, close: () => {
|
|
432
|
+
db.close();
|
|
433
|
+
epochLockDb.close();
|
|
434
|
+
} };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export { canonicalOperation };
|