@yeaft/webchat-agent 1.0.407 → 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.
- package/container-manager.js +28 -4
- package/local-runtime/server/.env.example +8 -0
- package/local-runtime/server/container-agent-service.js +132 -28
- package/local-runtime/server/routes/sandbox-routes.js +28 -15
- package/local-runtime/server/routes/user-routes.js +11 -5
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +81 -80
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
package/container-manager.js
CHANGED
|
@@ -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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
name
|
|
56
|
-
|
|
57
|
-
secretFile,
|
|
58
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 =
|
|
6
|
-
if (!user && req.user.username === 'dev-user') 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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 =
|
|
37
|
-
const result = await
|
|
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
|
|
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, {
|
|
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
|
-
//
|
|
159
|
-
//
|
|
160
|
-
await
|
|
161
|
-
|
|
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.
|
|
1
|
+
{"version":"1.0.408"}
|