@yeaft/webchat-agent 1.0.406 → 1.0.408

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.
@@ -91,6 +91,18 @@ export function buildCreateArgs({
91
91
  ];
92
92
  }
93
93
 
94
+ /**
95
+ * Verify that the Docker client can reach a daemon before the Server advertises
96
+ * container Agent lifecycle support.
97
+ *
98
+ * @param {object} options runDocker overrides used by tests and alternate runtimes
99
+ * @returns {Promise<{serverVersion: string|null}>}
100
+ */
101
+ export async function checkContainerAgentRuntime(options = {}) {
102
+ const result = await runDocker(['version', '--format', '{{.Server.Version}}'], options);
103
+ return { serverVersion: result.stdout || null };
104
+ }
105
+
94
106
  export async function inspectContainerAgent(name, options = {}) {
95
107
  const result = await runDocker([
96
108
  'inspect', '--format', '{{json .State}}', containerNameForAgent(name),
@@ -139,15 +151,27 @@ export async function stopContainerAgent(name, runtime = {}) {
139
151
  return inspectContainerAgent(name, runtime);
140
152
  }
141
153
 
154
+ function isMissingDockerVolume(stderr) {
155
+ return /no such volume/i.test(String(stderr || ''));
156
+ }
157
+
142
158
  export async function removeContainerAgent(name, { removeVolumes = true, ...runtime } = {}) {
143
159
  const containerName = containerNameForAgent(name);
144
160
  const current = await inspectContainerAgent(name, runtime);
145
161
  if (current.exists) await runDocker(['rm', '-f', containerName], runtime);
146
162
  if (removeVolumes) {
147
- await runDocker(['volume', 'rm', `${containerName}-data`, `${containerName}-workspace`], {
148
- ...runtime,
149
- allowFailure: true,
150
- });
163
+ for (const volume of [`${containerName}-data`, `${containerName}-workspace`]) {
164
+ const result = await runDocker(['volume', 'rm', volume], {
165
+ ...runtime,
166
+ allowFailure: true,
167
+ });
168
+ if (result.code !== 0 && !isMissingDockerVolume(result.stderr)) {
169
+ throw new ContainerAgentError(
170
+ 'CONTAINER_AGENT_DOCKER_FAILED',
171
+ result.stderr || `docker volume rm ${volume} failed`,
172
+ );
173
+ }
174
+ }
151
175
  }
152
176
  return { exists: false, status: 'absent', running: false };
153
177
  }
@@ -44,6 +44,14 @@ EMAIL_CODE_EXPIRES_IN=300000
44
44
  # Agents must provide this secret to connect
45
45
  AGENT_SECRET=agent-shared-secret
46
46
 
47
+ # Server-managed Sandbox (disabled by default)
48
+ # Docker socket access is equivalent to Host root. For Docker Compose, use the
49
+ # explicit docker-compose.sandbox.yml override documented in docs/operations/sandbox-agent.md.
50
+ # SANDBOX_ENABLED=false
51
+ # SANDBOX_SERVER_URL=wss://your-yeaft-server.example
52
+ # SANDBOX_AGENT_IMAGE=ghcr.io/yeaft/yeaft-web-code-agent-agent:dev
53
+ # SANDBOX_STATE_DIR=/var/lib/yeaft/container-agents
54
+
47
55
  # File upload settings
48
56
  MAX_FILE_SIZE=52428800
49
57
  FILE_CLEANUP_INTERVAL=600000
@@ -1,6 +1,9 @@
1
+ import { access, rm } from 'node:fs/promises';
1
2
  import { join } from 'node:path';
2
3
  import { CONFIG } from './config.js';
4
+ import { userDb } from './database.js';
3
5
  import {
6
+ checkContainerAgentRuntime,
4
7
  createContainerAgent,
5
8
  inspectContainerAgent,
6
9
  removeContainerAgent,
@@ -9,21 +12,62 @@ import {
9
12
  writeAgentSecretFile,
10
13
  } from '../agent/container-manager.js';
11
14
 
15
+ const DEFAULT_RUNTIME = Object.freeze({
16
+ check: checkContainerAgentRuntime,
17
+ create: createContainerAgent,
18
+ inspect: inspectContainerAgent,
19
+ remove: removeContainerAgent,
20
+ start: startContainerAgent,
21
+ stop: stopContainerAgent,
22
+ writeSecret: writeAgentSecretFile,
23
+ });
24
+
12
25
  function managedName(userId) {
13
26
  return `sandbox-${String(userId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 48)}`;
14
27
  }
15
28
 
16
29
  export class ContainerAgentService {
17
- constructor(config = CONFIG.sandbox) {
30
+ constructor(config = CONFIG.sandbox, runtime = DEFAULT_RUNTIME, ownerDb = userDb) {
18
31
  this.config = config;
32
+ this.runtime = runtime;
33
+ this.ownerDb = ownerDb;
34
+ this.ownerLifecycleTails = new Map();
19
35
  }
20
36
 
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
- };
37
+ async withOwnerLifecycle(userId, operation) {
38
+ const ownerKey = String(userId);
39
+ const previous = this.ownerLifecycleTails.get(ownerKey) || Promise.resolve();
40
+ let release;
41
+ const current = new Promise(resolve => { release = resolve; });
42
+ const tail = previous.catch(() => {}).then(() => current);
43
+ this.ownerLifecycleTails.set(ownerKey, tail);
44
+ await previous.catch(() => {});
45
+ try {
46
+ return await operation();
47
+ } finally {
48
+ release();
49
+ if (this.ownerLifecycleTails.get(ownerKey) === tail) {
50
+ this.ownerLifecycleTails.delete(ownerKey);
51
+ }
52
+ }
53
+ }
54
+
55
+ async capability() {
56
+ if (!this.config.enabled) {
57
+ return { available: false, reasonCode: 'SANDBOX_DISABLED', catalog: [] };
58
+ }
59
+ try {
60
+ await this.runtime.check();
61
+ return { available: true, reasonCode: null, catalog: [{ id: 'standard' }] };
62
+ } catch {
63
+ return { available: false, reasonCode: 'SANDBOX_DOCKER_UNAVAILABLE', catalog: [] };
64
+ }
65
+ }
66
+
67
+ assertEnabled() {
68
+ if (!this.config.enabled) {
69
+ throw Object.assign(new Error('SANDBOX_DISABLED'), { code: 'SANDBOX_DISABLED' });
70
+ }
27
71
  }
28
72
 
29
73
  nameForUser(userId) {
@@ -31,8 +75,9 @@ export class ContainerAgentService {
31
75
  }
32
76
 
33
77
  async snapshot(userId) {
78
+ if (!this.config.enabled) return null;
34
79
  const name = this.nameForUser(userId);
35
- const state = await inspectContainerAgent(name);
80
+ const state = await this.runtime.inspect(name);
36
81
  if (!state.exists) return null;
37
82
  return {
38
83
  id: name,
@@ -46,32 +91,91 @@ export class ContainerAgentService {
46
91
  };
47
92
  }
48
93
 
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,
94
+ async create(user) {
95
+ return this.withOwnerLifecycle(user.id, async () => {
96
+ await this.assertAvailable();
97
+ if (!this.ownerDb.isActive(user.id)) {
98
+ throw Object.assign(new Error('SANDBOX_OWNER_INACTIVE'), { code: 'SANDBOX_OWNER_INACTIVE' });
99
+ }
100
+ const name = this.nameForUser(user.id);
101
+ const secretFile = join(this.config.stateDir, name, 'agent-secret');
102
+ await this.runtime.writeSecret(secretFile, user.agent_secret);
103
+ await this.runtime.create({
104
+ name,
105
+ serverUrl: this.config.serverUrl,
106
+ secretFile,
107
+ image: this.config.image,
108
+ });
109
+ return { snapshot: await this.snapshot(user.id), replayed: false };
59
110
  });
60
- return { snapshot: await this.snapshot(user.id), replayed: false };
61
111
  }
62
112
 
113
+ async assertAvailable() {
114
+ this.assertEnabled();
115
+ try {
116
+ await this.runtime.check();
117
+ } catch {
118
+ throw Object.assign(new Error('SANDBOX_DOCKER_UNAVAILABLE'), {
119
+ code: 'SANDBOX_DOCKER_UNAVAILABLE',
120
+ });
121
+ }
122
+ }
123
+
124
+ // User-visible lifecycle operations are admitted on every request. The
125
+ // feature flag and Docker reachability can change while a browser is open.
63
126
  async action(userId, action) {
127
+ return this.withOwnerLifecycle(userId, async () => {
128
+ await this.assertAvailable();
129
+ if (!this.ownerDb.isActive(userId)) {
130
+ throw Object.assign(new Error('SANDBOX_OWNER_INACTIVE'), { code: 'SANDBOX_OWNER_INACTIVE' });
131
+ }
132
+ const name = this.nameForUser(userId);
133
+ if (action === 'start') await this.runtime.start(name);
134
+ else if (action === 'retry') {
135
+ const current = await this.runtime.inspect(name);
136
+ if (current.exists) await this.runtime.start(name);
137
+ else throw Object.assign(new Error('SANDBOX_NOT_FOUND'), { code: 'SANDBOX_NOT_FOUND' });
138
+ }
139
+ else if (action === 'stop') await this.runtime.stop(name);
140
+ else if (action === 'remove') {
141
+ await this.runtime.remove(name);
142
+ await rm(join(this.config.stateDir, name), { recursive: true, force: true });
143
+ }
144
+ else throw Object.assign(new Error('SANDBOX_ACTION_NOT_ALLOWED'), { code: 'SANDBOX_ACTION_NOT_ALLOWED' });
145
+ return { snapshot: await this.snapshot(userId), replayed: false };
146
+ });
147
+ }
148
+
149
+ // Account deletion bypasses public admission only for durable Server-owned
150
+ // resources. No marker means this owner never reached a managed create
151
+ // attempt, so a default deployment without Docker must not probe the daemon.
152
+ async cleanupManagedContainer(userId) {
153
+ return this.withOwnerLifecycle(userId, () => this.cleanupManagedContainerLocked(userId));
154
+ }
155
+
156
+ async cleanupManagedContainerLocked(userId) {
64
157
  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' });
158
+ const ownerDir = join(this.config.stateDir, name);
159
+ const marker = join(ownerDir, 'agent-secret');
160
+ try {
161
+ await access(marker);
162
+ } catch (error) {
163
+ if (error?.code === 'ENOENT') return { cleaned: false };
164
+ throw error;
70
165
  }
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 };
166
+ await this.runtime.remove(name);
167
+ await rm(ownerDir, { recursive: true, force: true });
168
+ return { cleaned: true };
169
+ }
170
+
171
+ async prepareOwnerDeletion(userId, beginDeletion) {
172
+ if (typeof beginDeletion !== 'function') {
173
+ throw new TypeError('beginDeletion must be a function');
174
+ }
175
+ return this.withOwnerLifecycle(userId, async () => {
176
+ await this.cleanupManagedContainerLocked(userId);
177
+ return beginDeletion();
178
+ });
75
179
  }
76
180
  }
77
181
 
@@ -1,9 +1,9 @@
1
1
  import { userDb } from '../database.js';
2
2
  import { containerAgentService } from '../container-agent-service.js';
3
3
 
4
- function loadUser(req) {
5
- let user = userDb.getByUsername(req.user.username);
6
- if (!user && req.user.username === 'dev-user') user = userDb.getOrCreate('dev-user', 'dev-user');
4
+ function loadUser(req, sandboxUserDb = userDb) {
5
+ let user = sandboxUserDb.getByUsername(req.user.username);
6
+ if (!user && req.user.username === 'dev-user') user = sandboxUserDb.getOrCreate('dev-user', 'dev-user');
7
7
  return user;
8
8
  }
9
9
 
@@ -14,29 +14,42 @@ function sendError(res, error) {
14
14
  return res.status(known ? 409 : 500).json({ code: known ? code : 'SANDBOX_INTERNAL_ERROR' });
15
15
  }
16
16
 
17
- export function registerSandboxRoutes(app, { requireAuth }) {
18
- app.get('/api/sandbox/capability', requireAuth, (_req, res) => {
19
- res.json(containerAgentService.capability());
17
+ /**
18
+ * Register user-owned Sandbox lifecycle routes.
19
+ *
20
+ * @param {object} app Express-compatible route registrar
21
+ * @param {{ requireAuth: Function, sandboxService?: object, sandboxUserDb?: object }} dependencies
22
+ */
23
+ export function registerSandboxRoutes(app, {
24
+ requireAuth,
25
+ sandboxService = containerAgentService,
26
+ sandboxUserDb = userDb,
27
+ }) {
28
+ app.get('/api/sandbox/capability', requireAuth, async (_req, res) => {
29
+ res.json(await sandboxService.capability());
20
30
  });
21
31
 
22
32
  app.get('/api/sandbox', requireAuth, async (req, res) => {
23
- const user = loadUser(req);
33
+ const user = loadUser(req, sandboxUserDb);
24
34
  if (!user) return res.status(404).json({ code: 'USER_NOT_FOUND' });
25
35
  try {
26
- return res.json({ sandbox: await containerAgentService.snapshot(user.id) });
36
+ // Older browsers request capability and snapshot concurrently. Keep their
37
+ // snapshot response successful when the Docker runtime is unavailable so
38
+ // the capability reason remains visible instead of a generic load error.
39
+ const capability = await sandboxService.capability();
40
+ if (!capability.available) return res.json({ sandbox: null });
41
+ return res.json({ sandbox: await sandboxService.snapshot(user.id) });
27
42
  } catch (error) {
28
43
  return sendError(res, error);
29
44
  }
30
45
  });
31
46
 
32
47
  app.post('/api/sandbox', requireAuth, async (req, res) => {
33
- const user = loadUser(req);
48
+ const user = loadUser(req, sandboxUserDb);
34
49
  if (!user) return res.status(404).json({ code: 'USER_NOT_FOUND' });
35
50
  try {
36
- const agentSecret = userDb.getAgentSecret(user.id) || userDb.resetAgentSecret(user.id);
37
- const result = await containerAgentService.create({ ...user, agent_secret: agentSecret }, {
38
- agentName: req.body?.agentName,
39
- });
51
+ const agentSecret = sandboxUserDb.getAgentSecret(user.id) || sandboxUserDb.resetAgentSecret(user.id);
52
+ const result = await sandboxService.create({ ...user, agent_secret: agentSecret });
40
53
  return res.status(201).json(result);
41
54
  } catch (error) {
42
55
  return sendError(res, error);
@@ -45,10 +58,10 @@ export function registerSandboxRoutes(app, { requireAuth }) {
45
58
 
46
59
  for (const action of ['start', 'stop', 'retry', 'remove']) {
47
60
  app.post(`/api/sandbox/${action}`, requireAuth, async (req, res) => {
48
- const user = loadUser(req);
61
+ const user = loadUser(req, sandboxUserDb);
49
62
  if (!user) return res.status(404).json({ code: 'USER_NOT_FOUND' });
50
63
  try {
51
- return res.json(await containerAgentService.action(user.id, action));
64
+ return res.json(await sandboxService.action(user.id, action));
52
65
  } catch (error) {
53
66
  return sendError(res, error);
54
67
  }
@@ -54,7 +54,11 @@ function ensureAgentSecret(user) {
54
54
  /**
55
55
  * Register user profile, agent secret, and admin user management routes.
56
56
  */
57
- export function registerUserRoutes(app, { requireAuth, requireAdmin }) {
57
+ export function registerUserRoutes(app, {
58
+ requireAuth,
59
+ requireAdmin,
60
+ containerService = containerAgentService,
61
+ }) {
58
62
  // Get my profile
59
63
  app.get('/api/user/profile', requireAuth, (req, res) => {
60
64
  try {
@@ -155,10 +159,12 @@ export function registerUserRoutes(app, { requireAuth, requireAdmin }) {
155
159
  }
156
160
  }
157
161
 
158
- // Remove the Server-managed container before deleting the owner record.
159
- // Manually launched remote container Agents remain outside Server lifecycle control.
160
- await containerAgentService.action(user.id, 'remove');
161
- const deletion = userDb.beginDeletion(user.id);
162
+ // Keep managed cleanup and the durable active -> pending transition inside
163
+ // one owner lifecycle fence so an admitted create cannot outlive deletion.
164
+ const deletion = await containerService.prepareOwnerDeletion(
165
+ user.id,
166
+ () => userDb.beginDeletion(user.id),
167
+ );
162
168
  if (!deletion) return res.status(404).json({ error: 'User not found or already deleted' });
163
169
 
164
170
  // Durable eligibility is enforced from the user row. These sweeps close
@@ -1 +1 @@
1
- {"version":"1.0.406"}
1
+ {"version":"1.0.408"}