@yeaft/webchat-agent 1.0.372 → 1.0.374

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/cli.js +8 -0
  2. package/connection/index.js +11 -1
  3. package/index.js +4 -0
  4. package/local-runtime/server/api.js +2 -0
  5. package/local-runtime/server/auth/login.js +1 -0
  6. package/local-runtime/server/auth/oauth-flow.js +2 -2
  7. package/local-runtime/server/auth/token.js +4 -0
  8. package/local-runtime/server/config.js +47 -1
  9. package/local-runtime/server/database.js +1 -0
  10. package/local-runtime/server/db/connection.js +346 -4
  11. package/local-runtime/server/db/sandbox-db.js +673 -0
  12. package/local-runtime/server/db/user-db.js +137 -7
  13. package/local-runtime/server/index.js +60 -2
  14. package/local-runtime/server/routes/sandbox-routes.js +124 -0
  15. package/local-runtime/server/routes/user-routes.js +30 -15
  16. package/local-runtime/server/sandbox-agent-auth.js +66 -0
  17. package/local-runtime/server/sandbox-attestation-listener.js +128 -0
  18. package/local-runtime/server/sandbox-config.js +42 -0
  19. package/local-runtime/server/sandbox-host-attestation.js +175 -0
  20. package/local-runtime/server/sandbox-reconciler.js +355 -0
  21. package/local-runtime/server/ws-agent.js +34 -11
  22. package/local-runtime/server/ws-client.js +18 -5
  23. package/local-runtime/version.json +1 -1
  24. package/local-runtime/web/app.bundle.js +90 -29
  25. package/local-runtime/web/app.bundle.js.gz +0 -0
  26. package/local-runtime/web/index.html +2 -2
  27. package/local-runtime/web/style.bundle.css +1 -1
  28. package/local-runtime/web/style.bundle.css.gz +0 -0
  29. package/managed-sandbox/agent-runtime.js +73 -0
  30. package/managed-sandbox/controller.js +118 -0
  31. package/managed-sandbox/helper.js +437 -0
  32. package/managed-sandbox/identity-store.js +9 -0
  33. package/managed-sandbox/runtime-executor.js +387 -0
  34. package/package.json +1 -1
package/cli.js CHANGED
@@ -65,6 +65,14 @@ if (command === 'doctor') {
65
65
  await handleLlmCommand(subArgs);
66
66
  } else if (command === 'local') {
67
67
  await handleLocalCommand(subArgs);
68
+ } else if (command === 'managed-sandbox') {
69
+ try {
70
+ const { runManagedSandboxAgent } = await import('./managed-sandbox/agent-runtime.js');
71
+ await runManagedSandboxAgent(subArgs);
72
+ } catch (error) {
73
+ console.error(`Managed Sandbox Agent failed: ${error.message}`);
74
+ process.exit(1);
75
+ }
68
76
  } else if (command === 'upgrade') {
69
77
  await upgrade(subArgs);
70
78
  } else if (command === '--version' || command === '-v') {
@@ -67,7 +67,17 @@ export function connect(WebSocketImpl = WebSocket) {
67
67
  console.log('Received auth challenge, sending credentials...');
68
68
  ctx.pendingAuthTempId = msg.tempId;
69
69
  // Send authentication via WebSocket (not URL)
70
- socket.send(JSON.stringify({
70
+ const managed = ctx.CONFIG.managedSandboxIdentity;
71
+ socket.send(JSON.stringify(managed ? {
72
+ type: 'auth',
73
+ tempId: msg.tempId,
74
+ authKind: 'sandbox',
75
+ credentialId: managed.credentialId,
76
+ secret: managed.secret,
77
+ sandboxClaims: managed.claims,
78
+ capabilities: ctx.agentCapabilities,
79
+ version: ctx.agentVersion
80
+ } : {
71
81
  type: 'auth',
72
82
  tempId: msg.tempId,
73
83
  secret: ctx.CONFIG.agentSecret,
package/index.js CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  import { loadNodePty } from './terminal.js';
22
22
  import { connect } from './connection.js';
23
23
  import { loadMcpServers } from './mcp.js';
24
+ import { getManagedSandboxIdentity } from './managed-sandbox/identity-store.js';
24
25
  import { loadConfig as loadYeaftConfig } from './yeaft/config.js';
25
26
  import {
26
27
  ensureManagedCliTools,
@@ -48,6 +49,7 @@ ctx.pkgName = pkg.name;
48
49
  // service instances must stay scoped to their standard per-instance config.
49
50
  const LOCAL_CONFIG_FILE = join(process.cwd(), '.claude-agent.json');
50
51
  const IS_LOCAL_RUN = process.env.YEAFT_LOCAL_RUN === 'true';
52
+ const MANAGED_SANDBOX_IDENTITY = getManagedSandboxIdentity();
51
53
  const DEFAULT_AGENT_NAME = getDefaultAgentName();
52
54
 
53
55
  // 加载或创建配置
@@ -119,6 +121,7 @@ const CONFIG = {
119
121
  telemetry: loadYeaftConfig({ dir: YEAFT_DIR }).telemetry,
120
122
  reconnectInterval: fileConfig.reconnectInterval,
121
123
  agentSecret: process.env.AGENT_SECRET || fileConfig.agentSecret,
124
+ managedSandboxIdentity: MANAGED_SANDBOX_IDENTITY,
122
125
  // 显式禁用的工具(非 MCP 相关)
123
126
  explicitDisallowedTools: (() => {
124
127
  const raw = process.env.DISALLOWED_TOOLS || fileConfig.disallowedTools || '';
@@ -155,6 +158,7 @@ async function detectCapabilities() {
155
158
  // flip `agent.encryptOutbound = false`, stopping outbound encryption
156
159
  // to this peer. Old servers ignore the unknown capability token.
157
160
  const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', 'work_center', 'work_center_message_v2', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch'];
161
+ if (MANAGED_SANDBOX_IDENTITY) capabilities.push('managed-sandbox');
158
162
  if (process.platform === 'linux') capabilities.push('work_item_attachments');
159
163
  const pty = await loadNodePty();
160
164
  if (pty) capabilities.push('terminal');
@@ -11,6 +11,7 @@ import { registerSessionRoutes } from './routes/session-routes.js';
11
11
  import { registerUploadRoutes } from './routes/upload-routes.js';
12
12
  import { registerAdminRoutes } from './routes/admin-routes.js';
13
13
  import { registerExpertRoutes } from './routes/expert-routes.js';
14
+ import { registerSandboxRoutes } from './routes/sandbox-routes.js';
14
15
 
15
16
  // 登录速率限制: IP -> { attempts, resetAt }
16
17
  const loginAttempts = new Map();
@@ -111,4 +112,5 @@ export function registerApiRoutes(app) {
111
112
  registerUploadRoutes(app, shared);
112
113
  registerAdminRoutes(app, shared);
113
114
  registerExpertRoutes(app, shared);
115
+ registerSandboxRoutes(app, shared);
114
116
  }
@@ -13,6 +13,7 @@ import { generateVerificationCode, maskEmail } from './utils.js';
13
13
  */
14
14
  export function completeLogin(username, sessionKey, role) {
15
15
  const user = getUserByUsername(username);
16
+ if (!user) return { success: false, error: 'Invalid or disabled account' };
16
17
  const token = issueSessionToken(username);
17
18
  activeSessions.set(token, { username, sessionKey });
18
19
  return {
@@ -214,8 +214,8 @@ export async function handleCallback({ provider, code, state }) {
214
214
  // intent='login' (or anything else — default to login).
215
215
  if (existing) {
216
216
  const user = userDb.get(existing.user_id);
217
- if (!user) {
218
- return { kind: 'error', status: 500, error: 'Bound user no longer exists' };
217
+ if (!user || user.deletion_state !== 'active') {
218
+ return { kind: 'error', status: 403, error: 'Account is disabled' };
219
219
  }
220
220
  // Backfill display_name on subsequent logins if it was never set away
221
221
  // from the auto-generated username (e.g. early logins before this code
@@ -32,6 +32,10 @@ export function verifyToken(token) {
32
32
  }
33
33
 
34
34
  const user = getUserByUsername(decoded.username);
35
+ if (!user) {
36
+ activeSessions.delete(token);
37
+ return { valid: false };
38
+ }
35
39
 
36
40
  return {
37
41
  valid: true,
@@ -3,6 +3,7 @@ import { readFileSync, existsSync, writeFileSync } from 'fs';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname, join } from 'path';
5
5
  import { userDb } from './database.js';
6
+ import { validateSandboxDeploymentConfig } from './sandbox-config.js';
6
7
 
7
8
  const __filename = fileURLToPath(import.meta.url);
8
9
  const __dirname = dirname(__filename);
@@ -114,6 +115,44 @@ export const CONFIG = {
114
115
  // Agent authentication (global fallback — per-user agent_secret is preferred)
115
116
  agentSecret: process.env.AGENT_SECRET || DEFAULT_AGENT_SECRET,
116
117
 
118
+ // Managed Sandbox is fail-closed. Enabling the product flag is not enough:
119
+ // entitlement and a qualified dedicated Host with capacity are also required.
120
+ sandbox: {
121
+ enabled: process.env.SANDBOX_ENABLED === 'true',
122
+ maxReservedSandboxes: parseInt(process.env.SANDBOX_MAX_RESERVED, 10) || 2,
123
+ hostMemoryReserveMiB: parseInt(process.env.SANDBOX_HOST_MEMORY_RESERVE_MIB, 10) || 2048,
124
+ hostFreshnessMs: parseInt(process.env.SANDBOX_HOST_FRESHNESS_MS, 10) || 30_000,
125
+ agentRecoveryGraceMs: parseInt(process.env.SANDBOX_AGENT_RECOVERY_GRACE_MS, 10) || 60_000,
126
+ operationTimeoutMs: parseInt(process.env.SANDBOX_OPERATION_TIMEOUT_MS, 10) || 10 * 60_000,
127
+ reconcileIntervalMs: parseInt(process.env.SANDBOX_RECONCILE_INTERVAL_MS, 10) || 5_000,
128
+ controllerRequestTimeoutMs: parseInt(process.env.SANDBOX_CONTROLLER_TIMEOUT_MS, 10) || 10_000,
129
+ bootstrapTtlMs: parseInt(process.env.SANDBOX_BOOTSTRAP_TTL_MS, 10) || 5 * 60_000,
130
+ bootstrapSigningKey: process.env.SANDBOX_BOOTSTRAP_SIGNING_KEY || '',
131
+ hostAttestationKey: process.env.SANDBOX_HOST_ATTESTATION_KEY || '',
132
+ controllerAttestationFingerprint: process.env.SANDBOX_CONTROLLER_ATTESTATION_FINGERPRINT || '',
133
+ hostAttestationListenerHost: process.env.SANDBOX_HOST_ATTESTATION_LISTENER_HOST || '',
134
+ hostAttestationListenerPort: parseInt(process.env.SANDBOX_HOST_ATTESTATION_LISTENER_PORT, 10) || 0,
135
+ hostAttestationServerCert: process.env.SANDBOX_HOST_ATTESTATION_SERVER_CERT || '',
136
+ hostAttestationServerKey: process.env.SANDBOX_HOST_ATTESTATION_SERVER_KEY || '',
137
+ hostAttestationClientCa: process.env.SANDBOX_HOST_ATTESTATION_CLIENT_CA || '',
138
+ hostAttestationBodyLimitBytes:
139
+ parseInt(process.env.SANDBOX_HOST_ATTESTATION_BODY_LIMIT_BYTES, 10) || 64 * 1024,
140
+ hostAttestationShutdownTimeoutMs:
141
+ parseInt(process.env.SANDBOX_HOST_ATTESTATION_SHUTDOWN_TIMEOUT_MS, 10) || 1_000,
142
+ helperAttestationPublicKey: process.env.SANDBOX_HELPER_ATTESTATION_PUBLIC_KEY || '',
143
+ hostAttestationMaxSkewMs: parseInt(process.env.SANDBOX_HOST_ATTESTATION_MAX_SKEW_MS, 10) || 30_000,
144
+ imageDigest: process.env.SANDBOX_IMAGE_DIGEST || '',
145
+ controllerUrl: process.env.SANDBOX_CONTROLLER_URL || '',
146
+ controllerToken: process.env.SANDBOX_CONTROLLER_TOKEN || '',
147
+ controllerClientCert: process.env.SANDBOX_CONTROLLER_CLIENT_CERT || '',
148
+ controllerClientKey: process.env.SANDBOX_CONTROLLER_CLIENT_KEY || '',
149
+ controllerCaCert: process.env.SANDBOX_CONTROLLER_CA_CERT || '',
150
+ operationSigningPrivateKey: process.env.SANDBOX_OPERATION_SIGNING_PRIVATE_KEY || '',
151
+ controllerResultPublicKey: process.env.SANDBOX_CONTROLLER_RESULT_PUBLIC_KEY || '',
152
+ controllerProtocolMaxSkewMs: parseInt(process.env.SANDBOX_CONTROLLER_PROTOCOL_MAX_SKEW_MS, 10) || 30_000,
153
+ controllerHostId: process.env.SANDBOX_CONTROLLER_HOST_ID || ''
154
+ },
155
+
117
156
  // File upload settings
118
157
  maxFileSize: parseInt(process.env.MAX_FILE_SIZE, 10) || 50 * 1024 * 1024, // 50MB
119
158
  fileCleanupInterval: parseInt(process.env.FILE_CLEANUP_INTERVAL, 10) || 600000, // 10 minutes
@@ -192,6 +231,7 @@ export function getUserByUsername(username) {
192
231
  // Query database first (includes migrated, registered, and SSO-only users).
193
232
  const dbUser = userDb.getByUsername(username);
194
233
  if (dbUser) {
234
+ if (dbUser.deletion_state && dbUser.deletion_state !== 'active') return null;
195
235
  return {
196
236
  username: dbUser.username,
197
237
  passwordHash: dbUser.password_hash || null,
@@ -202,7 +242,9 @@ export function getUserByUsername(username) {
202
242
  id: dbUser.id
203
243
  };
204
244
  }
205
- // Fallback to CONFIG.users (only relevant before first migration)
245
+ // A finalized deletion remains authoritative after the users row is erased.
246
+ if (userDb.isDeletionTombstoned(username)) return null;
247
+ // Fallback to CONFIG.users only when no authoritative database state exists.
206
248
  return CONFIG.users.find(u => u.username === username) || null;
207
249
  }
208
250
 
@@ -257,6 +299,10 @@ export function validateProductionConfig() {
257
299
  errors.push('JWT_SECRET must be set to a secure value in production mode');
258
300
  }
259
301
 
302
+ if (CONFIG.sandbox.enabled && !validateSandboxDeploymentConfig(CONFIG.sandbox)) {
303
+ errors.push('Sandbox requires an HTTPS dedicated Controller, Host binding, fixed image digest, a dedicated mTLS Host attestation listener with a pinned Controller certificate, Controller token, asymmetric operation/result keys, bootstrap signing key, Host attestation key, and Helper attestation public key');
304
+ }
305
+
260
306
  // Check that at least one user with a password exists (in DB or config)
261
307
  // Only warn (don't block startup) — allows first-time setup via create-user.js
262
308
  const dbUsers = userDb.getAll();
@@ -10,5 +10,6 @@ export { messageDb } from './db/message-db.js';
10
10
  export { userStatsDb } from './db/user-stats-db.js';
11
11
  export { expertDb } from './db/expert-db.js';
12
12
  export { identityDb } from './db/identity-db.js';
13
+ export { sandboxDb } from './db/sandbox-db.js';
13
14
  export { closeDb } from './db/connection.js';
14
15
  export { default } from './db/connection.js';
@@ -32,7 +32,18 @@ db.exec(`
32
32
  username TEXT UNIQUE NOT NULL,
33
33
  display_name TEXT,
34
34
  created_at INTEGER NOT NULL,
35
- last_login_at INTEGER
35
+ last_login_at INTEGER,
36
+ deletion_state TEXT NOT NULL DEFAULT 'active',
37
+ deletion_requested_at INTEGER,
38
+ deletion_id TEXT UNIQUE
39
+ );
40
+
41
+ -- Finalized deletion survives removal of the users row. This prevents a
42
+ -- configured credential from becoming authoritative again on restart.
43
+ CREATE TABLE IF NOT EXISTS user_deletion_tombstones (
44
+ username TEXT PRIMARY KEY,
45
+ deletion_id TEXT,
46
+ deleted_at INTEGER NOT NULL
36
47
  );
37
48
 
38
49
  -- 会话表
@@ -141,6 +152,158 @@ db.exec(`
141
152
  -- concurrent reads continue during the build; writers will block briefly.
142
153
  CREATE INDEX IF NOT EXISTS idx_messages_session_role_id ON messages(session_id, role, id DESC);
143
154
  CREATE INDEX IF NOT EXISTS idx_daily_stats_date ON daily_stats(date);
155
+
156
+ -- Managed Sandbox control-plane state. Runtime resources are deliberately
157
+ -- not created on this mixed-use Server Host; qualified Controllers report
158
+ -- Host capacity separately.
159
+ CREATE TABLE IF NOT EXISTS sandbox_hosts (
160
+ id TEXT PRIMARY KEY,
161
+ epoch INTEGER NOT NULL,
162
+ qualified INTEGER NOT NULL DEFAULT 0,
163
+ controller_healthy INTEGER NOT NULL DEFAULT 0,
164
+ helper_healthy INTEGER NOT NULL DEFAULT 0,
165
+ runtime_healthy INTEGER NOT NULL DEFAULT 0,
166
+ quota_healthy INTEGER NOT NULL DEFAULT 0,
167
+ network_healthy INTEGER NOT NULL DEFAULT 0,
168
+ image_digest TEXT NOT NULL,
169
+ cpu_millis_total INTEGER NOT NULL,
170
+ memory_mib_total INTEGER NOT NULL,
171
+ memory_mib_available INTEGER NOT NULL,
172
+ disk_gib_total INTEGER NOT NULL,
173
+ updated_at INTEGER NOT NULL
174
+ );
175
+
176
+ CREATE TABLE IF NOT EXISTS sandbox_host_attestations (
177
+ nonce TEXT PRIMARY KEY,
178
+ host_id TEXT NOT NULL REFERENCES sandbox_hosts(id) ON DELETE CASCADE,
179
+ epoch INTEGER NOT NULL,
180
+ observed_at INTEGER NOT NULL,
181
+ created_at INTEGER NOT NULL
182
+ );
183
+
184
+ -- Controller boot identity is only a change detector. The Server owns this
185
+ -- durable monotonic fence and its exact activation identity.
186
+ CREATE TABLE IF NOT EXISTS sandbox_host_epochs (
187
+ host_id TEXT PRIMARY KEY REFERENCES sandbox_hosts(id) ON DELETE CASCADE,
188
+ source_epoch TEXT NOT NULL,
189
+ epoch INTEGER NOT NULL CHECK(epoch >= 1),
190
+ activation_digest TEXT,
191
+ activated_at INTEGER,
192
+ updated_at INTEGER NOT NULL
193
+ );
194
+
195
+ CREATE INDEX IF NOT EXISTS idx_sandbox_host_attestations_host
196
+ ON sandbox_host_attestations(host_id, created_at DESC);
197
+
198
+ CREATE TABLE IF NOT EXISTS sandbox_host_audit_events (
199
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
200
+ host_id TEXT NOT NULL,
201
+ epoch INTEGER NOT NULL,
202
+ event_type TEXT NOT NULL,
203
+ outcome TEXT NOT NULL,
204
+ error_code TEXT,
205
+ created_at INTEGER NOT NULL
206
+ );
207
+
208
+ CREATE INDEX IF NOT EXISTS idx_sandbox_host_audit_created
209
+ ON sandbox_host_audit_events(host_id, created_at DESC, id DESC);
210
+
211
+ CREATE TABLE IF NOT EXISTS sandbox_entitlements (
212
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
213
+ enabled INTEGER NOT NULL DEFAULT 0,
214
+ updated_at INTEGER NOT NULL
215
+ );
216
+
217
+ CREATE TABLE IF NOT EXISTS sandbox_entitlement_audit_events (
218
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
219
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
220
+ actor_username TEXT NOT NULL,
221
+ enabled INTEGER NOT NULL,
222
+ created_at INTEGER NOT NULL
223
+ );
224
+
225
+ CREATE INDEX IF NOT EXISTS idx_sandbox_entitlement_audit_user_created
226
+ ON sandbox_entitlement_audit_events(user_id, created_at DESC, id DESC);
227
+
228
+ CREATE TABLE IF NOT EXISTS sandboxes (
229
+ id TEXT PRIMARY KEY,
230
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
231
+ host_id TEXT NOT NULL REFERENCES sandbox_hosts(id) ON DELETE RESTRICT,
232
+ host_epoch INTEGER NOT NULL,
233
+ agent_name TEXT NOT NULL,
234
+ size_id TEXT NOT NULL,
235
+ cpu_millis INTEGER NOT NULL,
236
+ memory_mib INTEGER NOT NULL,
237
+ disk_gib INTEGER NOT NULL,
238
+ desired_state TEXT NOT NULL,
239
+ observed_state TEXT NOT NULL,
240
+ generation INTEGER NOT NULL DEFAULT 1,
241
+ instance_id TEXT NOT NULL,
242
+ image_digest TEXT NOT NULL,
243
+ reservation_held INTEGER NOT NULL DEFAULT 1,
244
+ last_error_code TEXT,
245
+ created_at INTEGER NOT NULL,
246
+ updated_at INTEGER NOT NULL,
247
+ removed_at INTEGER
248
+ );
249
+
250
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_sandboxes_active_user
251
+ ON sandboxes(user_id) WHERE reservation_held = 1;
252
+ CREATE INDEX IF NOT EXISTS idx_sandboxes_host_reservation
253
+ ON sandboxes(host_id, reservation_held);
254
+
255
+ CREATE TABLE IF NOT EXISTS sandbox_operations (
256
+ id TEXT PRIMARY KEY,
257
+ sandbox_id TEXT NOT NULL REFERENCES sandboxes(id) ON DELETE CASCADE,
258
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
259
+ idempotency_key TEXT NOT NULL,
260
+ request_digest TEXT NOT NULL,
261
+ kind TEXT NOT NULL,
262
+ status TEXT NOT NULL,
263
+ stage TEXT NOT NULL,
264
+ generation INTEGER NOT NULL,
265
+ host_epoch INTEGER NOT NULL,
266
+ deadline_at INTEGER NOT NULL,
267
+ error_code TEXT,
268
+ created_at INTEGER NOT NULL,
269
+ updated_at INTEGER NOT NULL,
270
+ UNIQUE(user_id, idempotency_key)
271
+ );
272
+
273
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_sandbox_operations_active
274
+ ON sandbox_operations(sandbox_id)
275
+ WHERE status IN ('pending', 'running');
276
+
277
+ CREATE TABLE IF NOT EXISTS sandbox_credentials (
278
+ id TEXT PRIMARY KEY,
279
+ sandbox_id TEXT NOT NULL REFERENCES sandboxes(id) ON DELETE CASCADE,
280
+ instance_id TEXT NOT NULL,
281
+ generation INTEGER NOT NULL,
282
+ image_digest TEXT NOT NULL,
283
+ kind TEXT NOT NULL,
284
+ secret_hash TEXT NOT NULL,
285
+ expires_at INTEGER,
286
+ consumed_at INTEGER,
287
+ revoked_at INTEGER,
288
+ created_at INTEGER NOT NULL
289
+ );
290
+
291
+ CREATE TABLE IF NOT EXISTS sandbox_audit_events (
292
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
293
+ sandbox_id TEXT NOT NULL REFERENCES sandboxes(id) ON DELETE CASCADE,
294
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
295
+ operation_id TEXT,
296
+ event_type TEXT NOT NULL,
297
+ actor_kind TEXT NOT NULL,
298
+ generation INTEGER NOT NULL,
299
+ host_epoch INTEGER NOT NULL,
300
+ outcome TEXT NOT NULL,
301
+ error_code TEXT,
302
+ created_at INTEGER NOT NULL
303
+ );
304
+
305
+ CREATE INDEX IF NOT EXISTS idx_sandbox_audit_sandbox_created
306
+ ON sandbox_audit_events(sandbox_id, created_at, id);
144
307
  `);
145
308
 
146
309
  // 数据库迁移 - 添加缺失的列
@@ -194,7 +357,11 @@ const migrations = [
194
357
  `ALTER TABLE daily_stats ADD COLUMN output_tokens INTEGER DEFAULT 0`,
195
358
  `ALTER TABLE daily_stats ADD COLUMN cache_read_tokens INTEGER DEFAULT 0`,
196
359
  `ALTER TABLE daily_stats ADD COLUMN cache_write_tokens INTEGER DEFAULT 0`,
197
- `ALTER TABLE daily_stats ADD COLUMN total_tokens INTEGER DEFAULT 0`
360
+ `ALTER TABLE daily_stats ADD COLUMN total_tokens INTEGER DEFAULT 0`,
361
+ `ALTER TABLE sandbox_hosts ADD COLUMN memory_mib_available INTEGER NOT NULL DEFAULT 0`,
362
+ `ALTER TABLE users ADD COLUMN deletion_state TEXT NOT NULL DEFAULT 'active'`,
363
+ `ALTER TABLE users ADD COLUMN deletion_requested_at INTEGER`,
364
+ `ALTER TABLE users ADD COLUMN deletion_id TEXT`
198
365
  ];
199
366
 
200
367
  // Yeaft sessions table — server-side persistence so the unified sidebar
@@ -356,6 +523,165 @@ for (const migration of migrations) {
356
523
  }
357
524
  }
358
525
 
526
+ // One-time fail-closed migration from the PR's opaque TEXT Host epochs. SQLite
527
+ // does not change existing column affinity for CREATE TABLE IF NOT EXISTS, so
528
+ // normalize every live fence explicitly and seed the Server-owned allocator.
529
+ const unmigratedSandboxHosts = db.prepare(`
530
+ SELECT h.id, h.epoch FROM sandbox_hosts h
531
+ LEFT JOIN sandbox_host_epochs e ON e.host_id = h.id
532
+ WHERE e.host_id IS NULL
533
+ `).all();
534
+ for (const host of unmigratedSandboxHosts) {
535
+ const values = [host.epoch];
536
+ for (const row of db.prepare('SELECT host_epoch AS epoch FROM sandboxes WHERE host_id = ?').all(host.id)) {
537
+ values.push(row.epoch);
538
+ }
539
+ for (const row of db.prepare(`
540
+ SELECT o.host_epoch AS epoch FROM sandbox_operations o
541
+ JOIN sandboxes s ON s.id = o.sandbox_id WHERE s.host_id = ?
542
+ `).all(host.id)) values.push(row.epoch);
543
+ const observed = values.map(value => {
544
+ const numeric = Number(value);
545
+ if (Number.isSafeInteger(numeric) && numeric >= 1) return numeric;
546
+ const match = String(value || '').match(/(\d+)$/);
547
+ return match ? Number(match[1]) : 0;
548
+ }).filter(value => Number.isSafeInteger(value) && value >= 1);
549
+ const epoch = Math.max(0, ...observed) + 1;
550
+ const now = Date.now();
551
+ db.exec('BEGIN IMMEDIATE');
552
+ try {
553
+ db.prepare('UPDATE sandbox_hosts SET epoch = ? WHERE id = ?').run(epoch, host.id);
554
+ db.prepare('UPDATE sandboxes SET host_epoch = ? WHERE host_id = ?').run(epoch, host.id);
555
+ db.prepare(`
556
+ UPDATE sandbox_operations SET host_epoch = ?
557
+ WHERE sandbox_id IN (SELECT id FROM sandboxes WHERE host_id = ?)
558
+ `).run(epoch, host.id);
559
+ db.prepare(`
560
+ UPDATE sandbox_audit_events SET host_epoch = ?
561
+ WHERE sandbox_id IN (SELECT id FROM sandboxes WHERE host_id = ?)
562
+ `).run(epoch, host.id);
563
+ db.prepare('UPDATE sandbox_host_attestations SET epoch = ? WHERE host_id = ?').run(epoch, host.id);
564
+ db.prepare('UPDATE sandbox_host_audit_events SET epoch = ? WHERE host_id = ?').run(epoch, host.id);
565
+ db.prepare(`
566
+ INSERT INTO sandbox_host_epochs
567
+ (host_id, source_epoch, epoch, activation_digest, activated_at, updated_at)
568
+ VALUES (?, ?, ?, NULL, NULL, ?)
569
+ `).run(host.id, `migrated:${String(host.epoch)}`, epoch, now);
570
+ db.exec('COMMIT');
571
+ } catch (error) {
572
+ try { db.exec('ROLLBACK'); } catch {}
573
+ throw error;
574
+ }
575
+ }
576
+
577
+ // SQLite keeps the affinity from the original CREATE TABLE. Rebuild legacy
578
+ // sandbox tables so epoch values are stored and compared as integers, rather
579
+ // than merely writing numeric-looking values into TEXT-affinity columns.
580
+ const sandboxEpochTableRebuilds = [
581
+ {
582
+ table: 'sandbox_hosts', column: 'epoch',
583
+ create: `CREATE TABLE sandbox_hosts (
584
+ id TEXT PRIMARY KEY, epoch INTEGER NOT NULL, qualified INTEGER NOT NULL DEFAULT 0,
585
+ controller_healthy INTEGER NOT NULL DEFAULT 0, helper_healthy INTEGER NOT NULL DEFAULT 0,
586
+ runtime_healthy INTEGER NOT NULL DEFAULT 0, quota_healthy INTEGER NOT NULL DEFAULT 0,
587
+ network_healthy INTEGER NOT NULL DEFAULT 0, image_digest TEXT NOT NULL,
588
+ cpu_millis_total INTEGER NOT NULL, memory_mib_total INTEGER NOT NULL,
589
+ memory_mib_available INTEGER NOT NULL, disk_gib_total INTEGER NOT NULL, updated_at INTEGER NOT NULL
590
+ )`,
591
+ columns: 'id, epoch, qualified, controller_healthy, helper_healthy, runtime_healthy, quota_healthy, network_healthy, image_digest, cpu_millis_total, memory_mib_total, memory_mib_available, disk_gib_total, updated_at',
592
+ select: 'id, CAST(epoch AS INTEGER), qualified, controller_healthy, helper_healthy, runtime_healthy, quota_healthy, network_healthy, image_digest, cpu_millis_total, memory_mib_total, memory_mib_available, disk_gib_total, updated_at',
593
+ indexes: ['CREATE INDEX IF NOT EXISTS idx_sandboxes_host_reservation ON sandboxes(host_id, reservation_held)']
594
+ },
595
+ {
596
+ table: 'sandbox_host_attestations', column: 'epoch',
597
+ create: `CREATE TABLE sandbox_host_attestations (
598
+ nonce TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES sandbox_hosts(id) ON DELETE CASCADE,
599
+ epoch INTEGER NOT NULL, observed_at INTEGER NOT NULL, created_at INTEGER NOT NULL
600
+ )`,
601
+ columns: 'nonce, host_id, epoch, observed_at, created_at',
602
+ select: 'nonce, host_id, CAST(epoch AS INTEGER), observed_at, created_at',
603
+ indexes: ['CREATE INDEX IF NOT EXISTS idx_sandbox_host_attestations_host ON sandbox_host_attestations(host_id, created_at DESC)']
604
+ },
605
+ {
606
+ table: 'sandbox_host_audit_events', column: 'epoch',
607
+ create: `CREATE TABLE sandbox_host_audit_events (
608
+ id INTEGER PRIMARY KEY AUTOINCREMENT, host_id TEXT NOT NULL, epoch INTEGER NOT NULL,
609
+ event_type TEXT NOT NULL, outcome TEXT NOT NULL, error_code TEXT, created_at INTEGER NOT NULL
610
+ )`,
611
+ columns: 'id, host_id, epoch, event_type, outcome, error_code, created_at',
612
+ select: 'id, host_id, CAST(epoch AS INTEGER), event_type, outcome, error_code, created_at',
613
+ indexes: ['CREATE INDEX IF NOT EXISTS idx_sandbox_host_audit_created ON sandbox_host_audit_events(host_id, created_at DESC, id DESC)']
614
+ },
615
+ {
616
+ table: 'sandboxes', column: 'host_epoch',
617
+ create: `CREATE TABLE sandboxes (
618
+ id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
619
+ host_id TEXT NOT NULL REFERENCES sandbox_hosts(id) ON DELETE RESTRICT, host_epoch INTEGER NOT NULL,
620
+ agent_name TEXT NOT NULL, size_id TEXT NOT NULL, cpu_millis INTEGER NOT NULL,
621
+ memory_mib INTEGER NOT NULL, disk_gib INTEGER NOT NULL, desired_state TEXT NOT NULL,
622
+ observed_state TEXT NOT NULL, generation INTEGER NOT NULL DEFAULT 1, instance_id TEXT NOT NULL,
623
+ image_digest TEXT NOT NULL, reservation_held INTEGER NOT NULL DEFAULT 1, last_error_code TEXT,
624
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, removed_at INTEGER
625
+ )`,
626
+ columns: 'id, user_id, host_id, host_epoch, agent_name, size_id, cpu_millis, memory_mib, disk_gib, desired_state, observed_state, generation, instance_id, image_digest, reservation_held, last_error_code, created_at, updated_at, removed_at',
627
+ select: 'id, user_id, host_id, CAST(host_epoch AS INTEGER), agent_name, size_id, cpu_millis, memory_mib, disk_gib, desired_state, observed_state, generation, instance_id, image_digest, reservation_held, last_error_code, created_at, updated_at, removed_at',
628
+ indexes: [
629
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_sandboxes_active_user ON sandboxes(user_id) WHERE reservation_held = 1',
630
+ 'CREATE INDEX IF NOT EXISTS idx_sandboxes_host_reservation ON sandboxes(host_id, reservation_held)'
631
+ ]
632
+ },
633
+ {
634
+ table: 'sandbox_operations', column: 'host_epoch',
635
+ create: `CREATE TABLE sandbox_operations (
636
+ id TEXT PRIMARY KEY, sandbox_id TEXT NOT NULL REFERENCES sandboxes(id) ON DELETE CASCADE,
637
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, idempotency_key TEXT NOT NULL,
638
+ request_digest TEXT NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL, stage TEXT NOT NULL,
639
+ generation INTEGER NOT NULL, host_epoch INTEGER NOT NULL, deadline_at INTEGER NOT NULL,
640
+ error_code TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
641
+ UNIQUE(user_id, idempotency_key)
642
+ )`,
643
+ columns: 'id, sandbox_id, user_id, idempotency_key, request_digest, kind, status, stage, generation, host_epoch, deadline_at, error_code, created_at, updated_at',
644
+ select: 'id, sandbox_id, user_id, idempotency_key, request_digest, kind, status, stage, generation, CAST(host_epoch AS INTEGER), deadline_at, error_code, created_at, updated_at',
645
+ indexes: ["CREATE UNIQUE INDEX IF NOT EXISTS idx_sandbox_operations_active ON sandbox_operations(sandbox_id) WHERE status IN ('pending', 'running')"]
646
+ },
647
+ {
648
+ table: 'sandbox_audit_events', column: 'host_epoch',
649
+ create: `CREATE TABLE sandbox_audit_events (
650
+ id INTEGER PRIMARY KEY AUTOINCREMENT, sandbox_id TEXT NOT NULL REFERENCES sandboxes(id) ON DELETE CASCADE,
651
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, operation_id TEXT,
652
+ event_type TEXT NOT NULL, actor_kind TEXT NOT NULL, generation INTEGER NOT NULL,
653
+ host_epoch INTEGER NOT NULL, outcome TEXT NOT NULL, error_code TEXT, created_at INTEGER NOT NULL
654
+ )`,
655
+ columns: 'id, sandbox_id, user_id, operation_id, event_type, actor_kind, generation, host_epoch, outcome, error_code, created_at',
656
+ select: 'id, sandbox_id, user_id, operation_id, event_type, actor_kind, generation, CAST(host_epoch AS INTEGER), outcome, error_code, created_at',
657
+ indexes: ['CREATE INDEX IF NOT EXISTS idx_sandbox_audit_sandbox_created ON sandbox_audit_events(sandbox_id, created_at, id)']
658
+ }
659
+ ];
660
+
661
+ for (const rebuild of sandboxEpochTableRebuilds) {
662
+ const affinity = db.prepare(`PRAGMA table_info(${rebuild.table})`).all()
663
+ .find(column => column.name === rebuild.column)?.type?.toUpperCase();
664
+ if (affinity === 'INTEGER') continue;
665
+ const legacyTable = `${rebuild.table}_legacy_epoch`;
666
+ db.exec('PRAGMA foreign_keys = OFF');
667
+ db.exec('PRAGMA legacy_alter_table = ON');
668
+ db.exec('BEGIN IMMEDIATE');
669
+ try {
670
+ db.exec(`ALTER TABLE ${rebuild.table} RENAME TO ${legacyTable}`);
671
+ db.exec(rebuild.create);
672
+ db.exec(`INSERT INTO ${rebuild.table} (${rebuild.columns}) SELECT ${rebuild.select} FROM ${legacyTable}`);
673
+ db.exec(`DROP TABLE ${legacyTable}`);
674
+ for (const index of rebuild.indexes) db.exec(index);
675
+ db.exec('COMMIT');
676
+ } catch (error) {
677
+ try { db.exec('ROLLBACK'); } catch {}
678
+ throw error;
679
+ } finally {
680
+ db.exec('PRAGMA legacy_alter_table = OFF');
681
+ db.exec('PRAGMA foreign_keys = ON');
682
+ }
683
+ }
684
+
359
685
  // User identities table (multi-provider SSO + account binding)
360
686
  // One user can have multiple identities (microsoft / github / google / wechat / alipay).
361
687
  // UNIQUE(provider, subject) enforces "this provider account is bound to one user only".
@@ -442,7 +768,8 @@ try { db.exec(customExpertTables); } catch (e) { /* tables already exist */ }
442
768
  const postMigrationIndexes = [
443
769
  `CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`,
444
770
  `CREATE INDEX IF NOT EXISTS idx_users_agent_secret ON users(agent_secret)`,
445
- `CREATE INDEX IF NOT EXISTS idx_users_aad_oid ON users(aad_oid)`
771
+ `CREATE INDEX IF NOT EXISTS idx_users_aad_oid ON users(aad_oid)`,
772
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_deletion_id ON users(deletion_id)`
446
773
  ];
447
774
  // Time the post-migration index pass so the operational signal lands in the
448
775
  // deploy log on first startup after composite-index addition — a multi-second
@@ -514,8 +841,17 @@ export const stmts = {
514
841
  SELECT * FROM users WHERE username = ?
515
842
  `),
516
843
 
844
+ getUserDeletionTombstone: db.prepare(`
845
+ SELECT * FROM user_deletion_tombstones WHERE username = ?
846
+ `),
847
+
848
+ insertUserDeletionTombstone: db.prepare(`
849
+ INSERT OR IGNORE INTO user_deletion_tombstones (username, deletion_id, deleted_at)
850
+ VALUES (?, ?, ?)
851
+ `),
852
+
517
853
  getUserByAgentSecret: db.prepare(`
518
- SELECT * FROM users WHERE agent_secret = ?
854
+ SELECT * FROM users WHERE agent_secret = ? AND deletion_state = 'active'
519
855
  `),
520
856
 
521
857
  getAllUsers: db.prepare(`
@@ -988,6 +1324,12 @@ export const stmts = {
988
1324
  clearInvitationUsedBy: db.prepare(`
989
1325
  UPDATE invitations SET used_by = NULL WHERE used_by = ?
990
1326
  `),
1327
+ getReservedSandboxForUser: db.prepare(`
1328
+ SELECT id FROM sandboxes WHERE user_id = ? AND reservation_held = 1 LIMIT 1
1329
+ `),
1330
+ deleteReleasedSandboxesForUser: db.prepare(`
1331
+ DELETE FROM sandboxes WHERE user_id = ? AND reservation_held = 0
1332
+ `),
991
1333
  deleteUserById: db.prepare(`
992
1334
  DELETE FROM users WHERE id = ?
993
1335
  `),