@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
|
@@ -1,4 +1,50 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
import db, { stmts, generateUserId, generateAgentSecret, transaction } from './connection.js';
|
|
3
|
+
|
|
4
|
+
function deletionRequestDigest() {
|
|
5
|
+
return createHash('sha256').update(JSON.stringify({ kind: 'remove' })).digest('hex');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function ensureDeletionRemoval(userId, deletionId, now = Date.now()) {
|
|
9
|
+
const sandbox = db.prepare('SELECT * FROM sandboxes WHERE user_id = ? AND reservation_held = 1').get(userId);
|
|
10
|
+
if (!sandbox) return null;
|
|
11
|
+
const baseKey = `account-delete:${deletionId}`;
|
|
12
|
+
const attempts = db.prepare(`
|
|
13
|
+
SELECT * FROM sandbox_operations
|
|
14
|
+
WHERE user_id = ? AND (idempotency_key = ? OR idempotency_key LIKE ?)
|
|
15
|
+
ORDER BY created_at DESC, rowid DESC
|
|
16
|
+
`).all(userId, baseKey, `${baseKey}:attempt:%`);
|
|
17
|
+
const latest = attempts[0];
|
|
18
|
+
if (latest && latest.status !== 'failed') return latest;
|
|
19
|
+
const key = attempts.length === 0 ? baseKey : `${baseKey}:attempt:${attempts.length + 1}`;
|
|
20
|
+
const existing = db.prepare('SELECT * FROM sandbox_operations WHERE user_id = ? AND idempotency_key = ?')
|
|
21
|
+
.get(userId, key);
|
|
22
|
+
if (existing) return existing;
|
|
23
|
+
db.prepare(`
|
|
24
|
+
UPDATE sandbox_operations SET status = 'failed', error_code = 'ACCOUNT_DELETION_REQUESTED', updated_at = ?
|
|
25
|
+
WHERE sandbox_id = ? AND status IN ('pending', 'running')
|
|
26
|
+
`).run(now, sandbox.id);
|
|
27
|
+
db.prepare('UPDATE sandbox_credentials SET revoked_at = ? WHERE sandbox_id = ? AND revoked_at IS NULL')
|
|
28
|
+
.run(now, sandbox.id);
|
|
29
|
+
const host = db.prepare('SELECT epoch FROM sandbox_hosts WHERE id = ?').get(sandbox.host_id);
|
|
30
|
+
const hostEpoch = host?.epoch || sandbox.host_epoch;
|
|
31
|
+
const generation = sandbox.generation + 1;
|
|
32
|
+
db.prepare(`
|
|
33
|
+
UPDATE sandboxes SET desired_state = 'removed', observed_state = CASE
|
|
34
|
+
WHEN observed_state = 'removed' THEN observed_state ELSE 'recovery_required' END,
|
|
35
|
+
generation = ?, host_epoch = ?, last_error_code = NULL, updated_at = ?
|
|
36
|
+
WHERE id = ? AND reservation_held = 1
|
|
37
|
+
`).run(generation, hostEpoch, now, sandbox.id);
|
|
38
|
+
const operationId = `sandbox_op_${randomUUID()}`;
|
|
39
|
+
db.prepare(`
|
|
40
|
+
INSERT INTO sandbox_operations (
|
|
41
|
+
id, sandbox_id, user_id, idempotency_key, request_digest, kind, status,
|
|
42
|
+
stage, generation, host_epoch, deadline_at, created_at, updated_at
|
|
43
|
+
) VALUES (?, ?, ?, ?, ?, 'remove', 'pending', 'removing', ?, ?, ?, ?, ?)
|
|
44
|
+
`).run(operationId, sandbox.id, userId, key, deletionRequestDigest(), generation,
|
|
45
|
+
hostEpoch, now + 10 * 60_000, now, now);
|
|
46
|
+
return db.prepare('SELECT * FROM sandbox_operations WHERE id = ?').get(operationId);
|
|
47
|
+
}
|
|
2
48
|
|
|
3
49
|
export const userDb = {
|
|
4
50
|
getOrCreate(username, displayName = null) {
|
|
@@ -21,9 +67,10 @@ export const userDb = {
|
|
|
21
67
|
},
|
|
22
68
|
|
|
23
69
|
migrateUser(username, passwordHash, email, role = 'admin') {
|
|
70
|
+
if (this.isDeletionTombstoned(username)) return null;
|
|
24
71
|
const existing = stmts.getUserByUsername.get(username);
|
|
25
72
|
if (existing) {
|
|
26
|
-
if (existing.password_hash) {
|
|
73
|
+
if ((existing.deletion_state && existing.deletion_state !== 'active') || existing.password_hash) {
|
|
27
74
|
return existing;
|
|
28
75
|
}
|
|
29
76
|
const newSecret = generateAgentSecret();
|
|
@@ -41,6 +88,10 @@ export const userDb = {
|
|
|
41
88
|
return stmts.getUserByUsername.get(username);
|
|
42
89
|
},
|
|
43
90
|
|
|
91
|
+
isDeletionTombstoned(username) {
|
|
92
|
+
return stmts.getUserDeletionTombstone.get(username) !== undefined;
|
|
93
|
+
},
|
|
94
|
+
|
|
44
95
|
getUserByAgentSecret(secret) {
|
|
45
96
|
if (!secret) return null;
|
|
46
97
|
return stmts.getUserByAgentSecret.get(secret) || null;
|
|
@@ -50,6 +101,63 @@ export const userDb = {
|
|
|
50
101
|
return stmts.getAllUsers.all();
|
|
51
102
|
},
|
|
52
103
|
|
|
104
|
+
isActive(userId) {
|
|
105
|
+
return db.prepare("SELECT 1 FROM users WHERE id = ? AND deletion_state = 'active'").get(userId) !== undefined;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
beginDeletion(userId, now = Date.now()) {
|
|
109
|
+
return transaction(() => {
|
|
110
|
+
const user = stmts.getUserById.get(userId);
|
|
111
|
+
if (!user) return null;
|
|
112
|
+
const deletionId = user.deletion_id || `deletion_${randomUUID()}`;
|
|
113
|
+
if (user.deletion_state !== 'pending') {
|
|
114
|
+
const changed = db.prepare(`
|
|
115
|
+
UPDATE users SET deletion_state = 'pending', deletion_requested_at = ?, deletion_id = ?,
|
|
116
|
+
password_hash = NULL, agent_secret = NULL, totp_secret = NULL, totp_enabled = 0
|
|
117
|
+
WHERE id = ? AND deletion_state = 'active'
|
|
118
|
+
`).run(now, deletionId, userId);
|
|
119
|
+
if (changed.changes !== 1) {
|
|
120
|
+
throw new Error('ACCOUNT_DELETION_STATE_CONFLICT');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
db.prepare('UPDATE sandbox_entitlements SET enabled = 0, updated_at = ? WHERE user_id = ?')
|
|
124
|
+
.run(now, userId);
|
|
125
|
+
const operation = ensureDeletionRemoval(userId, deletionId, now);
|
|
126
|
+
return { deletionId, status: 'pending', operationId: operation?.id || null };
|
|
127
|
+
})();
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
reconcilePendingDeletions(now = Date.now()) {
|
|
131
|
+
const pending = db.prepare("SELECT id, deletion_id FROM users WHERE deletion_state = 'pending'").all();
|
|
132
|
+
let finalized = 0;
|
|
133
|
+
for (const user of pending) {
|
|
134
|
+
if (stmts.getReservedSandboxForUser.get(user.id)) {
|
|
135
|
+
transaction(() => ensureDeletionRemoval(user.id, user.deletion_id, now))();
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const unsettled = db.prepare(`
|
|
139
|
+
SELECT 1 FROM sandboxes s
|
|
140
|
+
WHERE s.user_id = ? AND (
|
|
141
|
+
s.observed_state != 'removed' OR s.removed_at IS NULL
|
|
142
|
+
OR EXISTS (
|
|
143
|
+
SELECT 1 FROM sandbox_operations o
|
|
144
|
+
WHERE o.sandbox_id = s.id AND o.status IN ('pending', 'running')
|
|
145
|
+
)
|
|
146
|
+
OR NOT EXISTS (
|
|
147
|
+
SELECT 1 FROM sandbox_operations o
|
|
148
|
+
WHERE o.sandbox_id = s.id AND o.kind = 'remove' AND o.status = 'succeeded'
|
|
149
|
+
)
|
|
150
|
+
OR EXISTS (
|
|
151
|
+
SELECT 1 FROM sandbox_credentials c
|
|
152
|
+
WHERE c.sandbox_id = s.id AND c.revoked_at IS NULL
|
|
153
|
+
)
|
|
154
|
+
) LIMIT 1
|
|
155
|
+
`).get(user.id);
|
|
156
|
+
if (!unsettled && this.deleteUser(user.id, { requirePending: true })) finalized++;
|
|
157
|
+
}
|
|
158
|
+
return finalized;
|
|
159
|
+
},
|
|
160
|
+
|
|
53
161
|
updateLogin(id) {
|
|
54
162
|
stmts.updateUserLogin.run(Date.now(), id);
|
|
55
163
|
},
|
|
@@ -106,7 +214,8 @@ export const userDb = {
|
|
|
106
214
|
|
|
107
215
|
getByAadOid(aadOid) {
|
|
108
216
|
if (!aadOid) return null;
|
|
109
|
-
|
|
217
|
+
const user = stmts.getUserByAadOid.get(aadOid) || null;
|
|
218
|
+
return user?.deletion_state === 'active' ? user : null;
|
|
110
219
|
},
|
|
111
220
|
|
|
112
221
|
updateAadOid(userId, aadOid) {
|
|
@@ -145,18 +254,39 @@ export const userDb = {
|
|
|
145
254
|
* Caller is responsible for revoking JWT sessions (we don't import the
|
|
146
255
|
* session store from here to keep this layer pure).
|
|
147
256
|
*/
|
|
148
|
-
deleteUser(userId) {
|
|
149
|
-
//
|
|
150
|
-
// rolls back. A partial delete (e.g. users row gone but daily_stats
|
|
151
|
-
// orphaned) is worse than the operation failing outright.
|
|
257
|
+
deleteUser(userId, { requirePending = false } = {}) {
|
|
258
|
+
// Final erasure is allowed only after every Sandbox reservation has settled.
|
|
152
259
|
const run = transaction((id) => {
|
|
260
|
+
const user = stmts.getUserById.get(id);
|
|
261
|
+
if (!user || (requirePending && user.deletion_state !== 'pending')) return false;
|
|
262
|
+
if (stmts.getReservedSandboxForUser.get(id)) {
|
|
263
|
+
const error = new Error('SANDBOX_REMOVE_REQUIRED');
|
|
264
|
+
error.code = 'SANDBOX_REMOVE_REQUIRED';
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
if (requirePending) {
|
|
268
|
+
const unsettled = db.prepare(`
|
|
269
|
+
SELECT 1 FROM sandboxes s WHERE s.user_id = ? AND (
|
|
270
|
+
s.observed_state != 'removed' OR s.removed_at IS NULL
|
|
271
|
+
OR EXISTS (SELECT 1 FROM sandbox_operations o WHERE o.sandbox_id = s.id AND o.status IN ('pending', 'running'))
|
|
272
|
+
OR NOT EXISTS (SELECT 1 FROM sandbox_operations o WHERE o.sandbox_id = s.id AND o.kind = 'remove' AND o.status = 'succeeded')
|
|
273
|
+
OR EXISTS (SELECT 1 FROM sandbox_credentials c WHERE c.sandbox_id = s.id AND c.revoked_at IS NULL)
|
|
274
|
+
) LIMIT 1
|
|
275
|
+
`).get(id);
|
|
276
|
+
if (unsettled) return false;
|
|
277
|
+
}
|
|
278
|
+
stmts.deleteReleasedSandboxesForUser.run(id);
|
|
153
279
|
stmts.deleteIdentitiesForUser.run(id);
|
|
154
280
|
stmts.deleteUserSessionsByUser.run(id);
|
|
281
|
+
stmts.deleteYeaftSessionsByUserCascade.run(id);
|
|
155
282
|
stmts.deleteUserStats.run(id);
|
|
156
283
|
stmts.deleteDailyStatsForUser.run(id);
|
|
157
284
|
stmts.deleteCustomExpertRolesForUser.run(id);
|
|
158
285
|
stmts.deleteInvitationsCreatedBy.run(id);
|
|
159
286
|
stmts.clearInvitationUsedBy.run(id);
|
|
287
|
+
if (requirePending) {
|
|
288
|
+
stmts.insertUserDeletionTombstone.run(user.username, user.deletion_id, Date.now());
|
|
289
|
+
}
|
|
160
290
|
const result = stmts.deleteUserById.run(id);
|
|
161
291
|
return result.changes > 0;
|
|
162
292
|
});
|
|
@@ -9,13 +9,18 @@ import { fileURLToPath } from 'url';
|
|
|
9
9
|
import { dirname, join } from 'path';
|
|
10
10
|
import { CONFIG, isEmailConfigured, validateProductionConfig } from './config.js';
|
|
11
11
|
import { agents, webClients, userFileTabs, userStatsDeltas } from './context.js';
|
|
12
|
-
import { invitationDb, userStatsDb, closeDb } from './database.js';
|
|
12
|
+
import { invitationDb, userDb, userStatsDb, closeDb } from './database.js';
|
|
13
13
|
import { registerApiRoutes } from './api.js';
|
|
14
14
|
import { registerProxyRoutes, handleProxyWebSocketUpgrade } from './proxy.js';
|
|
15
15
|
import { handleAgentConnection } from './ws-agent.js';
|
|
16
16
|
import { handleWebConnection } from './ws-client.js';
|
|
17
17
|
import { sendToWebClient } from './ws-utils.js';
|
|
18
18
|
import { markAgentHeartbeatPing, markAgentHeartbeatStall, shouldTerminateAgentHeartbeat } from './heartbeat-policy.js';
|
|
19
|
+
import { createSandboxReconciler } from './sandbox-reconciler.js';
|
|
20
|
+
import {
|
|
21
|
+
closeSandboxAttestationListener,
|
|
22
|
+
startSandboxAttestationListener
|
|
23
|
+
} from './sandbox-attestation-listener.js';
|
|
19
24
|
|
|
20
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
26
|
|
|
@@ -62,6 +67,7 @@ const wss = new WebSocketServer({
|
|
|
62
67
|
// =====================
|
|
63
68
|
const AGENT_HEARTBEAT_INTERVAL = 30000;
|
|
64
69
|
const CLIENT_HEARTBEAT_INTERVAL = 90000;
|
|
70
|
+
const ACCOUNT_DELETION_RECONCILE_INTERVAL = 5000;
|
|
65
71
|
|
|
66
72
|
let lastAgentHeartbeatTickAt = Date.now();
|
|
67
73
|
setInterval(() => {
|
|
@@ -221,13 +227,57 @@ if (configValidation.warnings) {
|
|
|
221
227
|
console.warn('');
|
|
222
228
|
}
|
|
223
229
|
|
|
224
|
-
|
|
230
|
+
let sandboxReconcileTimer = null;
|
|
231
|
+
let sandboxAttestationListener = null;
|
|
232
|
+
const accountDeletionTimer = setInterval(() => {
|
|
233
|
+
try {
|
|
234
|
+
userDb.reconcilePendingDeletions();
|
|
235
|
+
} catch (error) {
|
|
236
|
+
console.error('[AccountDeletion] Reconcile failed:', error.message);
|
|
237
|
+
}
|
|
238
|
+
}, ACCOUNT_DELETION_RECONCILE_INTERVAL);
|
|
239
|
+
accountDeletionTimer.unref?.();
|
|
240
|
+
userDb.reconcilePendingDeletions();
|
|
241
|
+
|
|
242
|
+
async function startServers() {
|
|
243
|
+
sandboxAttestationListener = await startSandboxAttestationListener({ config: CONFIG.sandbox });
|
|
244
|
+
if (sandboxAttestationListener) {
|
|
245
|
+
console.log(`Sandbox Host attestation listener running on https://${CONFIG.sandbox.hostAttestationListenerHost}:${CONFIG.sandbox.hostAttestationListenerPort}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
await new Promise((resolve, reject) => {
|
|
250
|
+
const onError = (err) => {
|
|
251
|
+
server.off('listening', onListening);
|
|
252
|
+
reject(err);
|
|
253
|
+
};
|
|
254
|
+
const onListening = () => {
|
|
255
|
+
server.off('error', onError);
|
|
256
|
+
resolve();
|
|
257
|
+
};
|
|
258
|
+
server.once('error', onError);
|
|
259
|
+
server.once('listening', onListening);
|
|
260
|
+
server.listen(CONFIG.port, CONFIG.host);
|
|
261
|
+
});
|
|
262
|
+
} catch (err) {
|
|
263
|
+
await closeSandboxAttestationListener(sandboxAttestationListener);
|
|
264
|
+
sandboxAttestationListener = null;
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
sandboxReconcileTimer = createSandboxReconciler({ config: CONFIG.sandbox }).start();
|
|
225
269
|
console.log(`Server running on http://${CONFIG.host || '0.0.0.0'}:${CONFIG.port}`);
|
|
226
270
|
console.log(`Auth mode: ${CONFIG.skipAuth ? 'SKIP (development)' : 'ENABLED'}`);
|
|
227
271
|
if (!CONFIG.skipAuth) {
|
|
228
272
|
console.log(`Users configured: ${CONFIG.users.length}`);
|
|
229
273
|
console.log(`Email verification: ${isEmailConfigured() ? 'ENABLED' : 'DISABLED'}`);
|
|
230
274
|
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
startServers().catch(err => {
|
|
278
|
+
console.error('[Startup] Failed to start server:', err);
|
|
279
|
+
if (sandboxReconcileTimer) clearInterval(sandboxReconcileTimer);
|
|
280
|
+
process.exit(1);
|
|
231
281
|
});
|
|
232
282
|
|
|
233
283
|
// =====================
|
|
@@ -235,6 +285,14 @@ server.listen(CONFIG.port, CONFIG.host, () => {
|
|
|
235
285
|
// =====================
|
|
236
286
|
async function gracefulShutdown(signal) {
|
|
237
287
|
console.log(`\n[Shutdown] Received ${signal}, starting graceful shutdown...`);
|
|
288
|
+
if (sandboxReconcileTimer) clearInterval(sandboxReconcileTimer);
|
|
289
|
+
clearInterval(accountDeletionTimer);
|
|
290
|
+
try {
|
|
291
|
+
await closeSandboxAttestationListener(sandboxAttestationListener);
|
|
292
|
+
if (sandboxAttestationListener) console.log('[Shutdown] Sandbox attestation listener closed');
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.error('[Shutdown] Failed to close Sandbox attestation listener:', err.message);
|
|
295
|
+
}
|
|
238
296
|
|
|
239
297
|
// 1. 通知所有 web client 服务即将更新
|
|
240
298
|
const updateMsg = { type: 'server_updating' };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'crypto';
|
|
2
|
+
import { CONFIG } from '../config.js';
|
|
3
|
+
import { sandboxDb, userDb } from '../database.js';
|
|
4
|
+
import { SandboxConflictError } from '../db/sandbox-db.js';
|
|
5
|
+
import {
|
|
6
|
+
registerSandboxHostAttestation,
|
|
7
|
+
SandboxHostAttestationError
|
|
8
|
+
} from '../sandbox-host-attestation.js';
|
|
9
|
+
|
|
10
|
+
function loadUser(req) {
|
|
11
|
+
let user = userDb.getByUsername(req.user.username);
|
|
12
|
+
if (!user && CONFIG.skipAuth) user = userDb.getOrCreate(req.user.username, req.user.username);
|
|
13
|
+
return user;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sendError(res, err) {
|
|
17
|
+
if (err instanceof SandboxConflictError) {
|
|
18
|
+
return res.status(409).json({ code: err.code });
|
|
19
|
+
}
|
|
20
|
+
console.error('Sandbox API error:', err);
|
|
21
|
+
return res.status(500).json({ code: 'SANDBOX_INTERNAL_ERROR' });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizedFingerprint(value) {
|
|
25
|
+
return String(value || '').replaceAll(':', '').trim().toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function authenticateSandboxController(request, expectedFingerprint) {
|
|
29
|
+
if (!request?.socket?.authorized) return false;
|
|
30
|
+
const expected = Buffer.from(normalizedFingerprint(expectedFingerprint));
|
|
31
|
+
const certificate = request.socket.getPeerCertificate?.();
|
|
32
|
+
const actual = Buffer.from(normalizedFingerprint(certificate?.fingerprint256));
|
|
33
|
+
return expected.length > 0 && expected.length === actual.length
|
|
34
|
+
&& timingSafeEqual(expected, actual);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function handleSandboxHostAttestation(req, res, config = CONFIG.sandbox) {
|
|
38
|
+
if (!authenticateSandboxController(req, config.controllerAttestationFingerprint)) {
|
|
39
|
+
return res.status(401).json({ code: 'SANDBOX_CONTROLLER_IDENTITY_REJECTED' });
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
registerSandboxHostAttestation(req.body, config);
|
|
43
|
+
return res.status(202).json({ accepted: true });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err instanceof SandboxHostAttestationError) {
|
|
46
|
+
return res.status(401).json({ code: 'SANDBOX_HOST_ATTESTATION_REJECTED' });
|
|
47
|
+
}
|
|
48
|
+
return sendError(res, err);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function registerSandboxRoutes(app, { requireAuth, requireAdmin }) {
|
|
53
|
+
// The managed runtime has no user Agent secret. Its one-time bootstrap token
|
|
54
|
+
// is itself the scoped authorization for obtaining a revocable credential.
|
|
55
|
+
app.post('/api/sandbox/bootstrap/exchange', (req, res) => {
|
|
56
|
+
try {
|
|
57
|
+
const result = sandboxDb.exchangeBootstrap(req.body?.token, req.body?.claims);
|
|
58
|
+
return res.json(result);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err instanceof SandboxConflictError) {
|
|
61
|
+
return res.status(401).json({ code: 'SANDBOX_BOOTSTRAP_INVALID' });
|
|
62
|
+
}
|
|
63
|
+
return sendError(res, err);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
app.put('/api/admin/sandbox/entitlements/:userId', requireAuth, requireAdmin, (req, res) => {
|
|
68
|
+
try {
|
|
69
|
+
const entitlement = sandboxDb.setEntitlement(
|
|
70
|
+
req.params.userId,
|
|
71
|
+
req.body?.enabled,
|
|
72
|
+
req.user?.username
|
|
73
|
+
);
|
|
74
|
+
return res.json({ entitlement });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err instanceof SandboxConflictError && err.code === 'SANDBOX_USER_NOT_FOUND') {
|
|
77
|
+
return res.status(404).json({ code: err.code });
|
|
78
|
+
}
|
|
79
|
+
return sendError(res, err);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
app.get('/api/sandbox/capability', requireAuth, (req, res) => {
|
|
84
|
+
const user = loadUser(req);
|
|
85
|
+
if (!user) return res.status(404).json({ code: 'USER_NOT_FOUND' });
|
|
86
|
+
res.json(sandboxDb.capability(user.id, CONFIG.sandbox));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
app.get('/api/sandbox', requireAuth, (req, res) => {
|
|
90
|
+
const user = loadUser(req);
|
|
91
|
+
if (!user) return res.status(404).json({ code: 'USER_NOT_FOUND' });
|
|
92
|
+
res.json({ sandbox: sandboxDb.snapshot(user.id) });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
app.post('/api/sandbox', requireAuth, (req, res) => {
|
|
96
|
+
const user = loadUser(req);
|
|
97
|
+
if (!user) return res.status(404).json({ code: 'USER_NOT_FOUND' });
|
|
98
|
+
try {
|
|
99
|
+
const result = sandboxDb.create(user.id, {
|
|
100
|
+
agentName: req.body?.agentName,
|
|
101
|
+
sizeId: req.body?.sizeId,
|
|
102
|
+
idempotencyKey: req.get('Idempotency-Key')
|
|
103
|
+
}, CONFIG.sandbox);
|
|
104
|
+
return res.status(result.replayed ? 200 : 202).json(result);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return sendError(res, err);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
for (const action of ['start', 'stop', 'retry', 'remove']) {
|
|
111
|
+
app.post(`/api/sandbox/${action}`, requireAuth, (req, res) => {
|
|
112
|
+
const user = loadUser(req);
|
|
113
|
+
if (!user) return res.status(404).json({ code: 'USER_NOT_FOUND' });
|
|
114
|
+
try {
|
|
115
|
+
const result = sandboxDb.requestAction(
|
|
116
|
+
user.id, action, req.get('Idempotency-Key'), CONFIG.sandbox
|
|
117
|
+
);
|
|
118
|
+
return res.status(result.replayed ? 200 : 202).json(result);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return sendError(res, err);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { CONFIG } from '../config.js';
|
|
2
2
|
import { hashPassword } from '../auth.js';
|
|
3
3
|
import { userDb, sessionDb } from '../database.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
activeSessions, pendingVerifications, pendingTotpVerifications, pendingTotpSetup,
|
|
6
|
+
revokedTokens
|
|
7
|
+
} from '../auth/session-store.js';
|
|
8
|
+
import { clearSessionCookie } from '../auth/request-auth.js';
|
|
9
|
+
import { agents, webClients, userStatsDeltas } from '../context.js';
|
|
5
10
|
|
|
6
11
|
// 过滤用户敏感字段
|
|
7
12
|
function sanitizeUser(user) {
|
|
@@ -149,21 +154,31 @@ export function registerUserRoutes(app, { requireAuth, requireAdmin }) {
|
|
|
149
154
|
}
|
|
150
155
|
}
|
|
151
156
|
|
|
152
|
-
const
|
|
153
|
-
if (!
|
|
154
|
-
|
|
155
|
-
// Best-effort: revoke every active JWT belonging to this user so any
|
|
156
|
-
// open tabs can't keep talking to the API.
|
|
157
|
-
try {
|
|
158
|
-
for (const [token, info] of activeSessions.entries()) {
|
|
159
|
-
if (info && info.username === user.username) {
|
|
160
|
-
activeSessions.delete(token);
|
|
161
|
-
revokedTokens.add(token);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
} catch {}
|
|
157
|
+
const deletion = userDb.beginDeletion(user.id);
|
|
158
|
+
if (!deletion) return res.status(404).json({ error: 'User not found or already deleted' });
|
|
165
159
|
|
|
166
|
-
|
|
160
|
+
// Durable eligibility is enforced from the user row. These sweeps close
|
|
161
|
+
// already-open channels immediately rather than waiting for their next use.
|
|
162
|
+
for (const [token, info] of activeSessions.entries()) {
|
|
163
|
+
if (info?.username === user.username) {
|
|
164
|
+
activeSessions.delete(token);
|
|
165
|
+
revokedTokens.add(token);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const pending of [pendingVerifications, pendingTotpVerifications, pendingTotpSetup]) {
|
|
169
|
+
for (const [token, info] of pending) {
|
|
170
|
+
if (info?.username === user.username) pending.delete(token);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const [, client] of webClients) {
|
|
174
|
+
if (client.userId === user.id) client.ws.close(1008, 'Account disabled');
|
|
175
|
+
}
|
|
176
|
+
for (const [, agent] of agents) {
|
|
177
|
+
if (agent.ownerId === user.id) agent.ws.close(1008, 'Account disabled');
|
|
178
|
+
}
|
|
179
|
+
userStatsDeltas.delete(user.id);
|
|
180
|
+
clearSessionCookie(req, res);
|
|
181
|
+
res.status(202).json(deletion);
|
|
167
182
|
} catch (err) {
|
|
168
183
|
console.error('Delete user error:', err);
|
|
169
184
|
res.status(500).json({ error: 'Failed to delete account' });
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { sandboxDb } from './database.js';
|
|
2
|
+
import { agents } from './context.js';
|
|
3
|
+
|
|
4
|
+
function readClaims(message) {
|
|
5
|
+
const claims = message?.sandboxClaims;
|
|
6
|
+
if (!claims || typeof claims !== 'object') return null;
|
|
7
|
+
if (typeof claims.sandboxId !== 'string' || typeof claims.instanceId !== 'string'
|
|
8
|
+
|| !Number.isInteger(claims.generation) || typeof claims.imageDigest !== 'string') {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
return claims;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Authenticate a managed Sandbox Agent without accepting the user's general
|
|
16
|
+
* Agent secret. URL identity and credential scope must describe the same
|
|
17
|
+
* immutable Sandbox instance.
|
|
18
|
+
*/
|
|
19
|
+
export function authenticateSandboxAgent(message, pending, store = sandboxDb) {
|
|
20
|
+
if (message?.authKind !== 'sandbox' || typeof message.credentialId !== 'string'
|
|
21
|
+
|| typeof message.secret !== 'string') {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const claims = readClaims(message);
|
|
25
|
+
if (!claims || pending.instanceId !== claims.instanceId || pending.agentId !== claims.sandboxId) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const authenticated = store.authenticateCredential(message.credentialId, message.secret, claims);
|
|
30
|
+
if (authenticated.sandboxId !== claims.sandboxId) return null;
|
|
31
|
+
return {
|
|
32
|
+
...authenticated,
|
|
33
|
+
instanceId: claims.instanceId,
|
|
34
|
+
generation: claims.generation,
|
|
35
|
+
imageDigest: claims.imageDigest,
|
|
36
|
+
sessionKey: message.secret
|
|
37
|
+
};
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve readiness from the authenticated managed Agent connection. Controller
|
|
45
|
+
* booleans are inspection evidence, but cannot prove that the scoped Agent has
|
|
46
|
+
* completed its end-to-end Server sync.
|
|
47
|
+
*/
|
|
48
|
+
export function canForceReadyAfterSyncTimeout(agent) {
|
|
49
|
+
return !agent?.sandboxIdentity;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isSandboxAgentReady(identity, connectedAgents = agents) {
|
|
53
|
+
if (!identity) return false;
|
|
54
|
+
for (const agent of connectedAgents.values()) {
|
|
55
|
+
const current = agent.sandboxIdentity;
|
|
56
|
+
if (agent.status === 'ready' && agent.isAlive && current
|
|
57
|
+
&& current.sandboxId === identity.sandboxId
|
|
58
|
+
&& agent.instanceId === identity.instanceId
|
|
59
|
+
&& current.generation === identity.generation
|
|
60
|
+
&& current.imageDigest === identity.imageDigest
|
|
61
|
+
&& agent.capabilities?.includes('managed-sandbox')) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createServer } from 'https';
|
|
3
|
+
import { CONFIG } from './config.js';
|
|
4
|
+
import {
|
|
5
|
+
authenticateSandboxController,
|
|
6
|
+
handleSandboxHostAttestation
|
|
7
|
+
} from './routes/sandbox-routes.js';
|
|
8
|
+
|
|
9
|
+
export const SANDBOX_HOST_ATTESTATION_PATH = '/api/sandbox/hosts/attest';
|
|
10
|
+
|
|
11
|
+
function authenticatePeer(req, res, next, config) {
|
|
12
|
+
if (!authenticateSandboxController(req, config.controllerAttestationFingerprint)) {
|
|
13
|
+
return res.status(401).json({ code: 'SANDBOX_CONTROLLER_IDENTITY_REJECTED' });
|
|
14
|
+
}
|
|
15
|
+
return next();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseStrictJson(req, res, next) {
|
|
19
|
+
try {
|
|
20
|
+
const body = JSON.parse(req.body.toString('utf8'));
|
|
21
|
+
if (!body || typeof body !== 'object') {
|
|
22
|
+
return res.status(400).json({ code: 'SANDBOX_ATTESTATION_BODY_INVALID' });
|
|
23
|
+
}
|
|
24
|
+
req.body = body;
|
|
25
|
+
return next();
|
|
26
|
+
} catch {
|
|
27
|
+
return res.status(400).json({ code: 'SANDBOX_ATTESTATION_BODY_INVALID' });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function bodyErrorHandler(err, req, res, next) {
|
|
32
|
+
if (!err) return next();
|
|
33
|
+
if (err.type === 'entity.too.large') {
|
|
34
|
+
return res.status(413).json({ code: 'SANDBOX_ATTESTATION_BODY_TOO_LARGE' });
|
|
35
|
+
}
|
|
36
|
+
return next(err);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create the dedicated HTTPS server used only for Controller Host attestation.
|
|
41
|
+
* TLS chain authorization occurs before Express, while the route also pins the
|
|
42
|
+
* leaf certificate fingerprint before consuming the signed payload.
|
|
43
|
+
*
|
|
44
|
+
* @param {{ config?: object, handler?: Function }} options
|
|
45
|
+
* @returns {import('https').Server}
|
|
46
|
+
*/
|
|
47
|
+
export function createSandboxAttestationListener({
|
|
48
|
+
config = CONFIG.sandbox,
|
|
49
|
+
handler = handleSandboxHostAttestation
|
|
50
|
+
} = {}) {
|
|
51
|
+
const app = express();
|
|
52
|
+
app.disable('x-powered-by');
|
|
53
|
+
|
|
54
|
+
app.post(
|
|
55
|
+
SANDBOX_HOST_ATTESTATION_PATH,
|
|
56
|
+
(req, res, next) => authenticatePeer(req, res, next, config),
|
|
57
|
+
express.raw({ limit: config.hostAttestationBodyLimitBytes, type: () => true }),
|
|
58
|
+
parseStrictJson,
|
|
59
|
+
(req, res) => handler(req, res, config)
|
|
60
|
+
);
|
|
61
|
+
app.use(bodyErrorHandler);
|
|
62
|
+
|
|
63
|
+
const listener = createServer({
|
|
64
|
+
cert: config.hostAttestationServerCert,
|
|
65
|
+
key: config.hostAttestationServerKey,
|
|
66
|
+
ca: config.hostAttestationClientCa,
|
|
67
|
+
requestCert: true,
|
|
68
|
+
rejectUnauthorized: true,
|
|
69
|
+
minVersion: 'TLSv1.2'
|
|
70
|
+
}, app);
|
|
71
|
+
const sockets = new Set();
|
|
72
|
+
listener.on('connection', socket => {
|
|
73
|
+
sockets.add(socket);
|
|
74
|
+
socket.once('close', () => sockets.delete(socket));
|
|
75
|
+
});
|
|
76
|
+
listener.sandboxAttestationSockets = sockets;
|
|
77
|
+
listener.sandboxAttestationShutdownTimeoutMs = config.hostAttestationShutdownTimeoutMs;
|
|
78
|
+
return listener;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Start the attestation listener when Managed Sandbox is enabled.
|
|
83
|
+
* @param {{ config?: object, handler?: Function }} options
|
|
84
|
+
* @returns {Promise<import('https').Server|null>}
|
|
85
|
+
*/
|
|
86
|
+
export async function startSandboxAttestationListener({
|
|
87
|
+
config = CONFIG.sandbox,
|
|
88
|
+
handler = handleSandboxHostAttestation
|
|
89
|
+
} = {}) {
|
|
90
|
+
if (!config.enabled) return null;
|
|
91
|
+
const listener = createSandboxAttestationListener({ config, handler });
|
|
92
|
+
await new Promise((resolve, reject) => {
|
|
93
|
+
const onError = (err) => {
|
|
94
|
+
listener.off('listening', onListening);
|
|
95
|
+
reject(err);
|
|
96
|
+
};
|
|
97
|
+
const onListening = () => {
|
|
98
|
+
listener.off('error', onError);
|
|
99
|
+
resolve();
|
|
100
|
+
};
|
|
101
|
+
listener.once('error', onError);
|
|
102
|
+
listener.once('listening', onListening);
|
|
103
|
+
listener.listen(config.hostAttestationListenerPort, config.hostAttestationListenerHost);
|
|
104
|
+
});
|
|
105
|
+
return listener;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function closeSandboxAttestationListener(listener) {
|
|
109
|
+
if (!listener) return;
|
|
110
|
+
const sockets = listener.sandboxAttestationSockets || new Set();
|
|
111
|
+
const timeoutMs = listener.sandboxAttestationShutdownTimeoutMs || 1_000;
|
|
112
|
+
await new Promise((resolve, reject) => {
|
|
113
|
+
let settled = false;
|
|
114
|
+
const finish = err => {
|
|
115
|
+
if (settled) return;
|
|
116
|
+
settled = true;
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
if (err && err.code !== 'ERR_SERVER_NOT_RUNNING') reject(err);
|
|
119
|
+
else resolve();
|
|
120
|
+
};
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
for (const socket of sockets) socket.destroy();
|
|
123
|
+
}, timeoutMs);
|
|
124
|
+
timer.unref?.();
|
|
125
|
+
listener.close(finish);
|
|
126
|
+
listener.closeIdleConnections?.();
|
|
127
|
+
});
|
|
128
|
+
}
|