@yeaft/webchat-agent 1.0.385 → 1.0.387

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/Dockerfile ADDED
@@ -0,0 +1,26 @@
1
+ FROM node:24-bookworm-slim
2
+
3
+ ARG BUILD_VERSION=dev
4
+ ENV NODE_ENV=production \
5
+ HOME=/home/yeaft \
6
+ YEAFT_DIR=/home/yeaft/.yeaft \
7
+ WORK_DIR=/workspace
8
+
9
+ RUN apt-get update \
10
+ && apt-get install -y --no-install-recommends ca-certificates git openssh-client tini \
11
+ && rm -rf /var/lib/apt/lists/* \
12
+ && useradd --create-home --uid 10001 --shell /usr/sbin/nologin yeaft \
13
+ && mkdir -p /app /workspace /home/yeaft/.yeaft \
14
+ && chown -R yeaft:yeaft /app /workspace /home/yeaft
15
+
16
+ WORKDIR /app
17
+ COPY package.json package-lock.json ./
18
+ COPY agent/package.json ./agent/
19
+ RUN npm ci --workspace=agent --omit=dev
20
+ COPY --chown=yeaft:yeaft agent ./agent
21
+ RUN node -e "const fs=require('fs'); const p='agent/package.json'; const j=JSON.parse(fs.readFileSync(p)); j.version=process.argv[1].replace(/^v/, ''); fs.writeFileSync(p, JSON.stringify(j, null, 2) + '\\n')" "$BUILD_VERSION" \
22
+ && chmod 0755 agent/container-entrypoint.sh
23
+
24
+ VOLUME ["/home/yeaft/.yeaft", "/workspace"]
25
+ ENTRYPOINT ["/app/agent/container-entrypoint.sh"]
26
+ CMD []
package/cli.js CHANGED
@@ -65,12 +65,12 @@ 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') {
68
+ } else if (command === 'container') {
69
69
  try {
70
- const { runManagedSandboxAgent } = await import('./managed-sandbox/agent-runtime.js');
71
- await runManagedSandboxAgent(subArgs);
70
+ const { runContainerCli } = await import('./container-cli.js');
71
+ await runContainerCli(subArgs);
72
72
  } catch (error) {
73
- console.error(`Managed Sandbox Agent failed: ${error.message}`);
73
+ console.error(`Container Agent failed: ${error.code || error.message}`);
74
74
  process.exit(1);
75
75
  }
76
76
  } else if (command === 'upgrade') {
@@ -138,6 +138,7 @@ function printHelp() {
138
138
  yeaft-agent logs [options] View service logs (follow mode)
139
139
  yeaft-agent doctor Diagnose service configuration
140
140
  yeaft-agent llm <command> Configure local Yeaft LLM providers/models
141
+ yeaft-agent container <command> Manage a Dockerized yeaft-agent
141
142
  yeaft-agent upgrade [--name <id>] Upgrade and restart the selected service
142
143
  yeaft-agent --version Show version
143
144
 
@@ -66,18 +66,8 @@ export function connect(WebSocketImpl = WebSocket) {
66
66
  if (msg.type === 'auth_required' && msg.tempId) {
67
67
  console.log('Received auth challenge, sending credentials...');
68
68
  ctx.pendingAuthTempId = msg.tempId;
69
- // Send authentication via WebSocket (not URL)
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
- } : {
69
+ // Send the ordinary Agent credential via WebSocket (not URL).
70
+ socket.send(JSON.stringify({
81
71
  type: 'auth',
82
72
  tempId: msg.tempId,
83
73
  secret: ctx.CONFIG.agentSecret,
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import {
5
+ createContainerAgent,
6
+ inspectContainerAgent,
7
+ logsContainerAgent,
8
+ readSecretInput,
9
+ removeContainerAgent,
10
+ startContainerAgent,
11
+ stopContainerAgent,
12
+ writeAgentSecretFile,
13
+ } from './container-manager.js';
14
+
15
+ function help() {
16
+ console.log(`
17
+ Usage:
18
+ yeaft-agent container create --server <ws-url> --name <name> (--secret <value> | --secret-file <path>) [--image <image>]
19
+ yeaft-agent container start|stop|status|remove|logs --name <name>
20
+
21
+ The container is an ordinary yeaft-agent. This command only manages its Docker lifecycle.
22
+ Use --keep-volumes with remove to preserve its Yeaft data and workspace volumes.
23
+ `);
24
+ }
25
+
26
+ export function parseContainerArgs(args) {
27
+ const options = {};
28
+ const positionals = [];
29
+ for (let i = 0; i < args.length; i++) {
30
+ const arg = args[i];
31
+ if (arg === '--keep-volumes' || arg === '--follow') {
32
+ options[arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = true;
33
+ continue;
34
+ }
35
+ if (!arg.startsWith('--')) {
36
+ positionals.push(arg);
37
+ continue;
38
+ }
39
+ const value = args[++i];
40
+ if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`);
41
+ options[arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value;
42
+ }
43
+ return { action: positionals[0], options };
44
+ }
45
+
46
+ export async function runContainerCli(args) {
47
+ if (args.length === 0 || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') return help();
48
+ const { action, options } = parseContainerArgs(args);
49
+ const name = options.name;
50
+ let result;
51
+ if (action === 'create') {
52
+ const secret = await readSecretInput(options);
53
+ const secretFile = options.secretFile
54
+ ? resolve(options.secretFile)
55
+ : join(homedir(), '.yeaft', 'container-agents', name, 'agent-secret');
56
+ await writeAgentSecretFile(secretFile, secret);
57
+ result = await createContainerAgent({
58
+ name,
59
+ serverUrl: options.server,
60
+ secretFile,
61
+ image: options.image,
62
+ });
63
+ } else if (action === 'start') {
64
+ result = await startContainerAgent(name);
65
+ } else if (action === 'stop') {
66
+ result = await stopContainerAgent(name);
67
+ } else if (action === 'status') {
68
+ result = await inspectContainerAgent(name);
69
+ } else if (action === 'remove') {
70
+ result = await removeContainerAgent(name, { removeVolumes: !options.keepVolumes });
71
+ } else if (action === 'logs') {
72
+ result = await logsContainerAgent(name, { follow: options.follow });
73
+ if (!options.follow && result.stdout) console.log(result.stdout);
74
+ return;
75
+ } else {
76
+ throw new Error(`Unknown container action: ${action}`);
77
+ }
78
+ console.log(JSON.stringify({ name, ...result }, null, 2));
79
+ }
80
+
81
+ if (process.argv[1] && import.meta.url === new URL(`file://${resolve(process.argv[1])}`).href) {
82
+ runContainerCli(process.argv.slice(2)).catch(error => {
83
+ console.error(`Container Agent failed: ${error.code || error.message}`);
84
+ process.exitCode = 1;
85
+ });
86
+ }
@@ -0,0 +1,165 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, resolve } from 'node:path';
4
+
5
+ export const DEFAULT_AGENT_IMAGE = 'ghcr.io/yeaft/yeaft-web-code-agent-agent:dev';
6
+ const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
7
+
8
+ export class ContainerAgentError extends Error {
9
+ constructor(code, message = code) {
10
+ super(message);
11
+ this.name = 'ContainerAgentError';
12
+ this.code = code;
13
+ }
14
+ }
15
+
16
+ export function normalizeContainerAgentName(name) {
17
+ const value = String(name || '').trim();
18
+ if (!NAME_PATTERN.test(value)) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_NAME');
19
+ return value;
20
+ }
21
+
22
+ export function containerNameForAgent(name) {
23
+ return `yeaft-agent-${normalizeContainerAgentName(name)}`;
24
+ }
25
+
26
+ export async function runDocker(args, { spawnImpl = spawn, allowFailure = false, stdout = 'pipe' } = {}) {
27
+ return new Promise((resolvePromise, reject) => {
28
+ const child = spawnImpl('docker', args, {
29
+ stdio: ['ignore', stdout, 'pipe'],
30
+ windowsHide: true,
31
+ });
32
+ const output = [];
33
+ const errors = [];
34
+ child.stdout?.on('data', chunk => output.push(chunk));
35
+ child.stderr?.on('data', chunk => errors.push(chunk));
36
+ child.once('error', error => reject(new ContainerAgentError('CONTAINER_AGENT_DOCKER_UNAVAILABLE', error.message)));
37
+ child.once('close', code => {
38
+ const result = {
39
+ code: code ?? 1,
40
+ stdout: Buffer.concat(output).toString('utf8').trim(),
41
+ stderr: Buffer.concat(errors).toString('utf8').trim(),
42
+ };
43
+ if (result.code === 0 || allowFailure) resolvePromise(result);
44
+ else reject(new ContainerAgentError('CONTAINER_AGENT_DOCKER_FAILED', result.stderr || `docker ${args[0]} failed`));
45
+ });
46
+ });
47
+ }
48
+
49
+ export async function writeAgentSecretFile(path, secret) {
50
+ const value = String(secret || '').trim();
51
+ if (!value) throw new ContainerAgentError('CONTAINER_AGENT_SECRET_REQUIRED');
52
+ const absolute = resolve(path);
53
+ await mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
54
+ await writeFile(absolute, `${value}\n`, { mode: 0o600 });
55
+ await chmod(absolute, 0o600);
56
+ return absolute;
57
+ }
58
+
59
+ export function buildCreateArgs({
60
+ name,
61
+ serverUrl,
62
+ secretFile,
63
+ image = DEFAULT_AGENT_IMAGE,
64
+ dataVolume,
65
+ workspaceVolume,
66
+ restart = 'unless-stopped',
67
+ }) {
68
+ const agentName = normalizeContainerAgentName(name);
69
+ if (!String(serverUrl || '').match(/^wss?:\/\//)) {
70
+ throw new ContainerAgentError('CONTAINER_AGENT_INVALID_SERVER_URL');
71
+ }
72
+ if (!secretFile) throw new ContainerAgentError('CONTAINER_AGENT_SECRET_REQUIRED');
73
+ const containerName = containerNameForAgent(agentName);
74
+ const safeImage = String(image || '').trim();
75
+ if (!safeImage || safeImage.startsWith('-')) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_IMAGE');
76
+ return [
77
+ 'create', '--name', containerName,
78
+ '--label', 'io.yeaft.container-agent=true',
79
+ '--label', `io.yeaft.agent-name=${agentName}`,
80
+ '--restart', restart,
81
+ '--init',
82
+ '--mount', `type=volume,src=${dataVolume || `${containerName}-data`},dst=/home/yeaft/.yeaft`,
83
+ '--mount', `type=volume,src=${workspaceVolume || `${containerName}-workspace`},dst=/workspace`,
84
+ '--mount', `type=bind,src=${resolve(secretFile)},dst=/run/yeaft-host-secret,readonly`,
85
+ '--env', `SERVER_URL=${serverUrl}`,
86
+ '--env', `AGENT_NAME=${agentName}`,
87
+ '--env', 'AGENT_SECRET_FILE=/run/yeaft-host-secret',
88
+ '--env', 'YEAFT_DIR=/home/yeaft/.yeaft',
89
+ '--env', 'WORK_DIR=/workspace',
90
+ safeImage,
91
+ ];
92
+ }
93
+
94
+ export async function inspectContainerAgent(name, options = {}) {
95
+ const result = await runDocker([
96
+ 'inspect', '--format', '{{json .State}}', containerNameForAgent(name),
97
+ ], { ...options, allowFailure: true });
98
+ if (result.code !== 0) {
99
+ if (/no such (object|container)/i.test(result.stderr)) {
100
+ return { exists: false, status: 'absent', running: false };
101
+ }
102
+ throw new ContainerAgentError('CONTAINER_AGENT_DOCKER_FAILED', result.stderr || 'docker inspect failed');
103
+ }
104
+ try {
105
+ const state = JSON.parse(result.stdout);
106
+ return {
107
+ exists: true,
108
+ status: state.Status || 'unknown',
109
+ running: state.Running === true,
110
+ startedAt: state.StartedAt || null,
111
+ error: state.Error || null,
112
+ };
113
+ } catch {
114
+ throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DOCKER_RESPONSE');
115
+ }
116
+ }
117
+
118
+ export async function createContainerAgent(options, runtime = {}) {
119
+ const current = await inspectContainerAgent(options.name, runtime);
120
+ if (current.exists) throw new ContainerAgentError('CONTAINER_AGENT_ALREADY_EXISTS');
121
+ const containerName = containerNameForAgent(options.name);
122
+ await runDocker(buildCreateArgs(options), runtime);
123
+ try {
124
+ await runDocker(['start', containerName], runtime);
125
+ } catch (error) {
126
+ await runDocker(['rm', '-f', containerName], { ...runtime, allowFailure: true });
127
+ throw error;
128
+ }
129
+ return inspectContainerAgent(options.name, runtime);
130
+ }
131
+
132
+ export async function startContainerAgent(name, runtime = {}) {
133
+ await runDocker(['start', containerNameForAgent(name)], runtime);
134
+ return inspectContainerAgent(name, runtime);
135
+ }
136
+
137
+ export async function stopContainerAgent(name, runtime = {}) {
138
+ await runDocker(['stop', '--time', '10', containerNameForAgent(name)], runtime);
139
+ return inspectContainerAgent(name, runtime);
140
+ }
141
+
142
+ export async function removeContainerAgent(name, { removeVolumes = true, ...runtime } = {}) {
143
+ const containerName = containerNameForAgent(name);
144
+ const current = await inspectContainerAgent(name, runtime);
145
+ if (current.exists) await runDocker(['rm', '-f', containerName], runtime);
146
+ if (removeVolumes) {
147
+ await runDocker(['volume', 'rm', `${containerName}-data`, `${containerName}-workspace`], {
148
+ ...runtime,
149
+ allowFailure: true,
150
+ });
151
+ }
152
+ return { exists: false, status: 'absent', running: false };
153
+ }
154
+
155
+ export async function logsContainerAgent(name, { follow = false, ...runtime } = {}) {
156
+ const args = ['logs'];
157
+ if (follow) args.push('--follow');
158
+ args.push(containerNameForAgent(name));
159
+ return runDocker(args, { ...runtime, stdout: follow ? 'inherit' : 'pipe' });
160
+ }
161
+
162
+ export async function readSecretInput({ secret, secretFile }) {
163
+ if (secretFile) return (await readFile(resolve(secretFile), 'utf8')).trim();
164
+ return String(secret || '').trim();
165
+ }
package/index.js CHANGED
@@ -21,7 +21,6 @@ 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';
25
24
  import { SAFE_REMOTE_UPGRADE_CAPABILITY } from './upgrade-command.js';
26
25
  import { loadConfig as loadYeaftConfig } from './yeaft/config.js';
27
26
  import {
@@ -50,7 +49,6 @@ ctx.pkgName = pkg.name;
50
49
  // service instances must stay scoped to their standard per-instance config.
51
50
  const LOCAL_CONFIG_FILE = join(process.cwd(), '.claude-agent.json');
52
51
  const IS_LOCAL_RUN = process.env.YEAFT_LOCAL_RUN === 'true';
53
- const MANAGED_SANDBOX_IDENTITY = getManagedSandboxIdentity();
54
52
  const DEFAULT_AGENT_NAME = getDefaultAgentName();
55
53
 
56
54
  // 加载或创建配置
@@ -113,6 +111,10 @@ try {
113
111
  console.warn(`[Agent] Could not ensure yeaft dir ${YEAFT_DIR}: ${err?.message || err}`);
114
112
  }
115
113
 
114
+ const agentSecret = process.env.AGENT_SECRET_FILE
115
+ ? readFileSync(process.env.AGENT_SECRET_FILE, 'utf8').trim()
116
+ : (process.env.AGENT_SECRET || fileConfig.agentSecret);
117
+
116
118
  const CONFIG = {
117
119
  instanceId: INSTANCE_ID,
118
120
  serverUrl: process.env.SERVER_URL || fileConfig.serverUrl,
@@ -121,8 +123,7 @@ const CONFIG = {
121
123
  yeaftDir: YEAFT_DIR,
122
124
  telemetry: loadYeaftConfig({ dir: YEAFT_DIR }).telemetry,
123
125
  reconnectInterval: fileConfig.reconnectInterval,
124
- agentSecret: process.env.AGENT_SECRET || fileConfig.agentSecret,
125
- managedSandboxIdentity: MANAGED_SANDBOX_IDENTITY,
126
+ agentSecret,
126
127
  // 显式禁用的工具(非 MCP 相关)
127
128
  explicitDisallowedTools: (() => {
128
129
  const raw = process.env.DISALLOWED_TOOLS || fileConfig.disallowedTools || '';
@@ -159,7 +160,6 @@ async function detectCapabilities() {
159
160
  // flip `agent.encryptOutbound = false`, stopping outbound encryption
160
161
  // to this peer. Old servers ignore the unknown capability token.
161
162
  const capabilities = ['background_tasks', 'file_editor', 'ping_session', 'plaintext-ok', SAFE_REMOTE_UPGRADE_CAPABILITY, 'work_center', 'work_center_message_v2', 'session_history_search', 'session_history_outline', 'session_history_window_prefetch'];
162
- if (MANAGED_SANDBOX_IDENTITY) capabilities.push('managed-sandbox');
163
163
  if (process.platform === 'linux') capabilities.push('work_item_attachments');
164
164
  const pty = await loadNodePty();
165
165
  if (pty) capabilities.push('terminal');
@@ -3,7 +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
+ import { homedir } from 'node:os';
7
7
 
8
8
  const __filename = fileURLToPath(import.meta.url);
9
9
  const __dirname = dirname(__filename);
@@ -115,42 +115,13 @@ export const CONFIG = {
115
115
  // Agent authentication (global fallback — per-user agent_secret is preferred)
116
116
  agentSecret: process.env.AGENT_SECRET || DEFAULT_AGENT_SECRET,
117
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.
118
+ // A Sandbox is an ordinary yeaft-agent container managed by this Server's Docker daemon.
119
+ // The Server controls only the container lifecycle; Agent behavior stays on the existing wire.
120
120
  sandbox: {
121
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 || ''
122
+ image: process.env.SANDBOX_AGENT_IMAGE || 'ghcr.io/yeaft/yeaft-web-code-agent-agent:dev',
123
+ serverUrl: process.env.SANDBOX_SERVER_URL || '',
124
+ stateDir: process.env.SANDBOX_STATE_DIR || join(homedir(), '.yeaft', 'container-agents')
154
125
  },
155
126
 
156
127
  // File upload settings
@@ -299,8 +270,8 @@ export function validateProductionConfig() {
299
270
  errors.push('JWT_SECRET must be set to a secure value in production mode');
300
271
  }
301
272
 
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');
273
+ if (CONFIG.sandbox.enabled && !/^wss?:\/\//.test(CONFIG.sandbox.serverUrl)) {
274
+ errors.push('SANDBOX_SERVER_URL must be the ws:// or wss:// URL that container Agents use to connect');
304
275
  }
305
276
 
306
277
  // Check that at least one user with a password exists (in DB or config)
@@ -0,0 +1,78 @@
1
+ import { join } from 'node:path';
2
+ import { CONFIG } from './config.js';
3
+ import {
4
+ createContainerAgent,
5
+ inspectContainerAgent,
6
+ removeContainerAgent,
7
+ startContainerAgent,
8
+ stopContainerAgent,
9
+ writeAgentSecretFile,
10
+ } from '../agent/container-manager.js';
11
+
12
+ function managedName(userId) {
13
+ return `sandbox-${String(userId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 48)}`;
14
+ }
15
+
16
+ export class ContainerAgentService {
17
+ constructor(config = CONFIG.sandbox) {
18
+ this.config = config;
19
+ }
20
+
21
+ capability() {
22
+ return {
23
+ available: this.config.enabled,
24
+ reasonCode: this.config.enabled ? null : 'SANDBOX_DISABLED',
25
+ catalog: this.config.enabled ? [{ id: 'standard' }] : [],
26
+ };
27
+ }
28
+
29
+ nameForUser(userId) {
30
+ return managedName(userId);
31
+ }
32
+
33
+ async snapshot(userId) {
34
+ const name = this.nameForUser(userId);
35
+ const state = await inspectContainerAgent(name);
36
+ if (!state.exists) return null;
37
+ return {
38
+ id: name,
39
+ agentName: name,
40
+ sizeId: 'standard',
41
+ desiredState: state.running ? 'running' : 'stopped',
42
+ observedState: state.running ? 'running' : state.status,
43
+ reservationHeld: true,
44
+ operation: null,
45
+ lastErrorCode: state.error || null,
46
+ };
47
+ }
48
+
49
+ async create(user, { agentName } = {}) {
50
+ if (!this.config.enabled) throw Object.assign(new Error('SANDBOX_DISABLED'), { code: 'SANDBOX_DISABLED' });
51
+ const name = this.nameForUser(user.id);
52
+ const secretFile = join(this.config.stateDir, name, 'agent-secret');
53
+ await writeAgentSecretFile(secretFile, user.agent_secret);
54
+ await createContainerAgent({
55
+ name,
56
+ serverUrl: this.config.serverUrl,
57
+ secretFile,
58
+ image: this.config.image,
59
+ });
60
+ return { snapshot: await this.snapshot(user.id), replayed: false };
61
+ }
62
+
63
+ async action(userId, action) {
64
+ const name = this.nameForUser(userId);
65
+ if (action === 'start') await startContainerAgent(name);
66
+ else if (action === 'retry') {
67
+ const current = await inspectContainerAgent(name);
68
+ if (current.exists) await startContainerAgent(name);
69
+ else throw Object.assign(new Error('SANDBOX_NOT_FOUND'), { code: 'SANDBOX_NOT_FOUND' });
70
+ }
71
+ else if (action === 'stop') await stopContainerAgent(name);
72
+ else if (action === 'remove') await removeContainerAgent(name);
73
+ else throw Object.assign(new Error('SANDBOX_ACTION_NOT_ALLOWED'), { code: 'SANDBOX_ACTION_NOT_ALLOWED' });
74
+ return { snapshot: await this.snapshot(userId), replayed: false };
75
+ }
76
+ }
77
+
78
+ export const containerAgentService = new ContainerAgentService();
@@ -10,6 +10,5 @@ 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';
14
13
  export { closeDb } from './db/connection.js';
15
14
  export { default } from './db/connection.js';
@@ -1316,6 +1316,11 @@ export const stmts = {
1316
1316
  deleteCustomExpertRolesForUser: db.prepare(`
1317
1317
  DELETE FROM custom_expert_roles WHERE user_id = ?
1318
1318
  `),
1319
+ // Remove rows from the retired managed-Sandbox control plane before the user.
1320
+ // Child operations, credentials, and audit rows cascade from sandboxes.
1321
+ deleteLegacySandboxesForUser: db.prepare(`
1322
+ DELETE FROM sandboxes WHERE user_id = ?
1323
+ `),
1319
1324
  // Invitations: keep history but null-out the FK so it doesn't block deletion.
1320
1325
  // (created_by is NOT NULL, so for invitations the user created we just delete them.)
1321
1326
  deleteInvitationsCreatedBy: db.prepare(`
@@ -1324,12 +1329,6 @@ export const stmts = {
1324
1329
  clearInvitationUsedBy: db.prepare(`
1325
1330
  UPDATE invitations SET used_by = NULL WHERE used_by = ?
1326
1331
  `),
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
- `),
1333
1332
  deleteUserById: db.prepare(`
1334
1333
  DELETE FROM users WHERE id = ?
1335
1334
  `),