@yeaft/webchat-agent 1.0.442 → 1.0.444
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 +5 -3
- package/container-cli.js +30 -12
- package/container-manager.js +52 -30
- package/index.js +8 -4
- package/local-runtime/agent/container-manager.js +52 -30
- package/local-runtime/server/config.js +4 -3
- package/local-runtime/version.json +1 -1
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -74,11 +74,13 @@ if (command === 'doctor') {
|
|
|
74
74
|
} else if (command === 'local') {
|
|
75
75
|
await handleLocalCommand(subArgs);
|
|
76
76
|
} else if (command === 'container') {
|
|
77
|
+
let formatContainerError = error => error.code || error.message;
|
|
77
78
|
try {
|
|
78
|
-
const
|
|
79
|
-
|
|
79
|
+
const containerCli = await import('./container-cli.js');
|
|
80
|
+
formatContainerError = containerCli.formatContainerError;
|
|
81
|
+
await containerCli.runContainerCli(subArgs);
|
|
80
82
|
} catch (error) {
|
|
81
|
-
console.error(`Container Agent failed: ${error
|
|
83
|
+
console.error(`Container Agent failed: ${formatContainerError(error)}`);
|
|
82
84
|
process.exit(1);
|
|
83
85
|
}
|
|
84
86
|
} else if (command === 'upgrade') {
|
package/container-cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import {
|
|
6
7
|
createContainerAgent,
|
|
7
8
|
DEFAULT_AGENT_SLICE,
|
|
@@ -18,6 +19,25 @@ import {
|
|
|
18
19
|
writeAgentSecretFile,
|
|
19
20
|
} from './container-manager.js';
|
|
20
21
|
|
|
22
|
+
function shellQuote(value) {
|
|
23
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function agentCliEntry() {
|
|
27
|
+
return fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function setupLimitsCommand() {
|
|
31
|
+
return `sudo ${shellQuote(process.execPath)} ${shellQuote(agentCliEntry())} container setup-limits`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatContainerError(error) {
|
|
35
|
+
const code = String(error?.code || '').trim();
|
|
36
|
+
const message = String(error?.message || '').trim();
|
|
37
|
+
if (code && message && code !== message) return `${code}: ${message}`;
|
|
38
|
+
return code || message || String(error);
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
function help() {
|
|
22
42
|
console.log(`
|
|
23
43
|
Usage:
|
|
@@ -34,6 +54,8 @@ Resource protection (default): install attaches the container to the shared
|
|
|
34
54
|
containers at 90% of host CPU and 70% of host memory. The slice is initialized
|
|
35
55
|
automatically on first install (as root directly, otherwise via sudo; you are
|
|
36
56
|
prompted for the sudo password once). Pass --no-slice to opt out of protection.
|
|
57
|
+
If setup-limits must be run manually for a user-local install, use the
|
|
58
|
+
absolute Node/CLI path printed by this command: ${setupLimitsCommand()}.
|
|
37
59
|
|
|
38
60
|
--disk-size <size>: per-volume capacity quota for the data and workspace volumes, e.g.
|
|
39
61
|
20G or 80% of the Docker data-root filesystem. Requires Docker overlay2 on xfs with
|
|
@@ -90,7 +112,7 @@ export async function ensureAgentSliceAuto({
|
|
|
90
112
|
if (!interactive) {
|
|
91
113
|
throw new Error(
|
|
92
114
|
`Agent slice '${DEFAULT_AGENT_SLICE}' is not initialized and this shell is not interactive; ` +
|
|
93
|
-
|
|
115
|
+
`run ${setupLimitsCommand()} once, ` +
|
|
94
116
|
'or pass --no-slice to install without shared resource limits',
|
|
95
117
|
);
|
|
96
118
|
}
|
|
@@ -98,7 +120,7 @@ export async function ensureAgentSliceAuto({
|
|
|
98
120
|
if (code !== 0) {
|
|
99
121
|
throw new Error(
|
|
100
122
|
`Failed to initialize Agent slice '${DEFAULT_AGENT_SLICE}' via sudo (exit ${code}); ` +
|
|
101
|
-
|
|
123
|
+
`run ${setupLimitsCommand()} manually, ` +
|
|
102
124
|
'or pass --no-slice to install without shared resource limits',
|
|
103
125
|
);
|
|
104
126
|
}
|
|
@@ -106,14 +128,10 @@ export async function ensureAgentSliceAuto({
|
|
|
106
128
|
|
|
107
129
|
function runSudoSetupLimits({ spawnImpl = spawn } = {}) {
|
|
108
130
|
return new Promise((resolvePromise, reject) => {
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
const
|
|
113
|
-
const args = ['env', `PATH=${process.env.PATH || ''}`];
|
|
114
|
-
if (entry) args.push(process.execPath, entry);
|
|
115
|
-
else args.push('yeaft-agent');
|
|
116
|
-
args.push('container', 'setup-limits');
|
|
131
|
+
// Use absolute paths for both the runtime and this installed module. This
|
|
132
|
+
// bypasses sudo's secure_path entirely and works for npm-global installs
|
|
133
|
+
// that only exist in the invoking user's PATH.
|
|
134
|
+
const args = [process.execPath, agentCliEntry(), 'container', 'setup-limits'];
|
|
117
135
|
const child = spawnImpl('sudo', args, { stdio: 'inherit' });
|
|
118
136
|
child.once('error', reject);
|
|
119
137
|
child.once('close', code => resolvePromise(code ?? 1));
|
|
@@ -161,7 +179,7 @@ export async function runContainerCli(args) {
|
|
|
161
179
|
});
|
|
162
180
|
} else if (action === 'setup-limits') {
|
|
163
181
|
if (typeof process.getuid === 'function' && process.getuid() !== 0) {
|
|
164
|
-
throw new Error(
|
|
182
|
+
throw new Error(`setup-limits must run as root; use ${setupLimitsCommand()}`);
|
|
165
183
|
}
|
|
166
184
|
const resources = await detectHostResources();
|
|
167
185
|
result = await ensureAgentSlice({
|
|
@@ -191,7 +209,7 @@ export async function runContainerCli(args) {
|
|
|
191
209
|
|
|
192
210
|
if (process.argv[1] && import.meta.url === new URL(`file://${resolve(process.argv[1])}`).href) {
|
|
193
211
|
runContainerCli(process.argv.slice(2)).catch(error => {
|
|
194
|
-
console.error(`Container Agent failed: ${error
|
|
212
|
+
console.error(`Container Agent failed: ${formatContainerError(error)}`);
|
|
195
213
|
process.exitCode = 1;
|
|
196
214
|
});
|
|
197
215
|
}
|
package/container-manager.js
CHANGED
|
@@ -17,6 +17,23 @@ export class ContainerAgentError extends Error {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
function isCgroupWritePermissionError(error) {
|
|
21
|
+
const code = String(error?.code || '').toUpperCase();
|
|
22
|
+
const message = String(error?.message || '');
|
|
23
|
+
return ['EACCES', 'EPERM', 'EROFS'].includes(code)
|
|
24
|
+
|| /EACCES|EPERM|EROFS|permission denied|operation not permitted|read-only file system/i.test(message);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function wrapCgroupWriteError(error, slicePath) {
|
|
28
|
+
if (!isCgroupWritePermissionError(error)) return error;
|
|
29
|
+
return new ContainerAgentError(
|
|
30
|
+
'CONTAINER_AGENT_CGROUP_PERMISSION_DENIED',
|
|
31
|
+
`Cannot write cgroup v2 slice at ${slicePath} (${error.code || 'permission denied'}). ` +
|
|
32
|
+
'The host cgroup hierarchy is not writable or delegated to this process; ' +
|
|
33
|
+
'run setup-limits as host root with a read-write cgroup v2 mount, or pass --no-slice to install without shared resource limits',
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
export function normalizeContainerAgentName(name) {
|
|
21
38
|
const value = String(name || '').trim();
|
|
22
39
|
if (!NAME_PATTERN.test(value)) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_NAME');
|
|
@@ -276,8 +293,9 @@ export async function ensureAgentSlice({
|
|
|
276
293
|
try {
|
|
277
294
|
const text = await fsImpl.readFile('/sys/fs/cgroup/cgroup.controllers', 'utf8');
|
|
278
295
|
controllers = String(text || '').trim().split(/\s+/).filter(Boolean);
|
|
279
|
-
} catch {
|
|
280
|
-
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (isCgroupWritePermissionError(error)) throw wrapCgroupWriteError(error, '/sys/fs/cgroup');
|
|
298
|
+
// Treat a missing controller list as "no controllers writable".
|
|
281
299
|
}
|
|
282
300
|
const wanted = ['cpu', 'memory', 'pids'].filter(name => controllers.includes(name));
|
|
283
301
|
if (wanted.length === 0) {
|
|
@@ -286,35 +304,39 @@ export async function ensureAgentSlice({
|
|
|
286
304
|
'cgroup v2 controllers (cpu/memory/pids) are unavailable; setup-limits requires a host running cgroup v2 and root access to /sys/fs/cgroup',
|
|
287
305
|
);
|
|
288
306
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
+
try {
|
|
308
|
+
await fsImpl.mkdir(slicePath, { recursive: true, mode: 0o755 });
|
|
309
|
+
await fsImpl.writeFile(
|
|
310
|
+
`${slicePath}/cgroup.subtree_control`,
|
|
311
|
+
wanted.map(name => `+${name}`).join(' '),
|
|
312
|
+
'utf8',
|
|
313
|
+
);
|
|
314
|
+
const detected = resources || await detectHostResources({ readFileImpl: fsImpl.readFile });
|
|
315
|
+
const limits = buildSliceLimits({ cpuPercent, memoryPercent, pidsLimit }, detected);
|
|
316
|
+
if (wanted.includes('cpu') && limits.cpuQuotaUs > 0) {
|
|
317
|
+
await fsImpl.writeFile(`${slicePath}/cpu.max`, `${limits.cpuQuotaUs} ${limits.cpuPeriodUs}`, 'utf8');
|
|
318
|
+
}
|
|
319
|
+
if (wanted.includes('memory') && limits.memoryMaxBytes > 0) {
|
|
320
|
+
await fsImpl.writeFile(`${slicePath}/memory.max`, String(limits.memoryMaxBytes), 'utf8');
|
|
321
|
+
// No swap escape hatch: the 70% RAM hard cap is the whole memory budget.
|
|
322
|
+
await fsImpl.writeFile(`${slicePath}/memory.swap.max`, '0', 'utf8');
|
|
323
|
+
}
|
|
324
|
+
if (wanted.includes('pids') && limits.pidsMax > 0) {
|
|
325
|
+
await fsImpl.writeFile(`${slicePath}/pids.max`, String(limits.pidsMax), 'utf8');
|
|
326
|
+
}
|
|
327
|
+
const marker = {
|
|
328
|
+
slice,
|
|
329
|
+
cpuQuotaUs: limits.cpuQuotaUs,
|
|
330
|
+
cpuPeriodUs: limits.cpuPeriodUs,
|
|
331
|
+
memoryMaxBytes: limits.memoryMaxBytes,
|
|
332
|
+
pidsMax: limits.pidsMax,
|
|
333
|
+
updatedAt: new Date().toISOString(),
|
|
334
|
+
};
|
|
335
|
+
await fsImpl.writeFile(`${slicePath}/${SLICE_READY_MARKER}`, JSON.stringify(marker, null, 2), 'utf8');
|
|
336
|
+
return { slice, limits, controllers: wanted };
|
|
337
|
+
} catch (error) {
|
|
338
|
+
throw wrapCgroupWriteError(error, slicePath);
|
|
307
339
|
}
|
|
308
|
-
const marker = {
|
|
309
|
-
slice,
|
|
310
|
-
cpuQuotaUs: limits.cpuQuotaUs,
|
|
311
|
-
cpuPeriodUs: limits.cpuPeriodUs,
|
|
312
|
-
memoryMaxBytes: limits.memoryMaxBytes,
|
|
313
|
-
pidsMax: limits.pidsMax,
|
|
314
|
-
updatedAt: new Date().toISOString(),
|
|
315
|
-
};
|
|
316
|
-
await fsImpl.writeFile(`${slicePath}/${SLICE_READY_MARKER}`, JSON.stringify(marker, null, 2), 'utf8');
|
|
317
|
-
return { slice, limits, controllers: wanted };
|
|
318
340
|
}
|
|
319
341
|
|
|
320
342
|
/**
|
package/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { connect } from './connection.js';
|
|
|
24
24
|
import { loadMcpServers } from './mcp.js';
|
|
25
25
|
import { SAFE_REMOTE_UPGRADE_CAPABILITY } from './upgrade-command.js';
|
|
26
26
|
import { loadConfig as loadYeaftConfig } from './yeaft/config.js';
|
|
27
|
+
import { initYeaftDir } from './yeaft/init.js';
|
|
27
28
|
import { updateBrowserRuntimeSettings } from './yeaft/config-api.js';
|
|
28
29
|
import { bootBrowserRuntime, shutdownBrowserRuntime } from './browser-runtime/index.js';
|
|
29
30
|
import {
|
|
@@ -106,12 +107,15 @@ const { agentName: AGENT_NAME, instanceId: INSTANCE_ID } = resolveRuntimeIdentit
|
|
|
106
107
|
// WebSocket connection goes live, so downstream code can assume a real path.
|
|
107
108
|
const YEAFT_DIR = process.env.YEAFT_DIR || fileConfig.yeaftDir || getDefaultYeaftDir(INSTANCE_ID);
|
|
108
109
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
console.
|
|
110
|
+
const initResult = initYeaftDir(YEAFT_DIR);
|
|
111
|
+
for (const warning of initResult.warnings || []) {
|
|
112
|
+
console.warn(`[Agent] ${warning}`);
|
|
113
|
+
}
|
|
114
|
+
if (initResult.created?.length > 0) {
|
|
115
|
+
console.log(`[Agent] Initialized yeaft dir: ${YEAFT_DIR}`);
|
|
112
116
|
}
|
|
113
117
|
} catch (err) {
|
|
114
|
-
console.warn(`[Agent] Could not
|
|
118
|
+
console.warn(`[Agent] Could not initialize yeaft dir ${YEAFT_DIR}: ${err?.message || err}`);
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
const agentSecret = process.env.AGENT_SECRET_FILE
|
|
@@ -17,6 +17,23 @@ export class ContainerAgentError extends Error {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
function isCgroupWritePermissionError(error) {
|
|
21
|
+
const code = String(error?.code || '').toUpperCase();
|
|
22
|
+
const message = String(error?.message || '');
|
|
23
|
+
return ['EACCES', 'EPERM', 'EROFS'].includes(code)
|
|
24
|
+
|| /EACCES|EPERM|EROFS|permission denied|operation not permitted|read-only file system/i.test(message);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function wrapCgroupWriteError(error, slicePath) {
|
|
28
|
+
if (!isCgroupWritePermissionError(error)) return error;
|
|
29
|
+
return new ContainerAgentError(
|
|
30
|
+
'CONTAINER_AGENT_CGROUP_PERMISSION_DENIED',
|
|
31
|
+
`Cannot write cgroup v2 slice at ${slicePath} (${error.code || 'permission denied'}). ` +
|
|
32
|
+
'The host cgroup hierarchy is not writable or delegated to this process; ' +
|
|
33
|
+
'run setup-limits as host root with a read-write cgroup v2 mount, or pass --no-slice to install without shared resource limits',
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
export function normalizeContainerAgentName(name) {
|
|
21
38
|
const value = String(name || '').trim();
|
|
22
39
|
if (!NAME_PATTERN.test(value)) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_NAME');
|
|
@@ -276,8 +293,9 @@ export async function ensureAgentSlice({
|
|
|
276
293
|
try {
|
|
277
294
|
const text = await fsImpl.readFile('/sys/fs/cgroup/cgroup.controllers', 'utf8');
|
|
278
295
|
controllers = String(text || '').trim().split(/\s+/).filter(Boolean);
|
|
279
|
-
} catch {
|
|
280
|
-
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (isCgroupWritePermissionError(error)) throw wrapCgroupWriteError(error, '/sys/fs/cgroup');
|
|
298
|
+
// Treat a missing controller list as "no controllers writable".
|
|
281
299
|
}
|
|
282
300
|
const wanted = ['cpu', 'memory', 'pids'].filter(name => controllers.includes(name));
|
|
283
301
|
if (wanted.length === 0) {
|
|
@@ -286,35 +304,39 @@ export async function ensureAgentSlice({
|
|
|
286
304
|
'cgroup v2 controllers (cpu/memory/pids) are unavailable; setup-limits requires a host running cgroup v2 and root access to /sys/fs/cgroup',
|
|
287
305
|
);
|
|
288
306
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
+
try {
|
|
308
|
+
await fsImpl.mkdir(slicePath, { recursive: true, mode: 0o755 });
|
|
309
|
+
await fsImpl.writeFile(
|
|
310
|
+
`${slicePath}/cgroup.subtree_control`,
|
|
311
|
+
wanted.map(name => `+${name}`).join(' '),
|
|
312
|
+
'utf8',
|
|
313
|
+
);
|
|
314
|
+
const detected = resources || await detectHostResources({ readFileImpl: fsImpl.readFile });
|
|
315
|
+
const limits = buildSliceLimits({ cpuPercent, memoryPercent, pidsLimit }, detected);
|
|
316
|
+
if (wanted.includes('cpu') && limits.cpuQuotaUs > 0) {
|
|
317
|
+
await fsImpl.writeFile(`${slicePath}/cpu.max`, `${limits.cpuQuotaUs} ${limits.cpuPeriodUs}`, 'utf8');
|
|
318
|
+
}
|
|
319
|
+
if (wanted.includes('memory') && limits.memoryMaxBytes > 0) {
|
|
320
|
+
await fsImpl.writeFile(`${slicePath}/memory.max`, String(limits.memoryMaxBytes), 'utf8');
|
|
321
|
+
// No swap escape hatch: the 70% RAM hard cap is the whole memory budget.
|
|
322
|
+
await fsImpl.writeFile(`${slicePath}/memory.swap.max`, '0', 'utf8');
|
|
323
|
+
}
|
|
324
|
+
if (wanted.includes('pids') && limits.pidsMax > 0) {
|
|
325
|
+
await fsImpl.writeFile(`${slicePath}/pids.max`, String(limits.pidsMax), 'utf8');
|
|
326
|
+
}
|
|
327
|
+
const marker = {
|
|
328
|
+
slice,
|
|
329
|
+
cpuQuotaUs: limits.cpuQuotaUs,
|
|
330
|
+
cpuPeriodUs: limits.cpuPeriodUs,
|
|
331
|
+
memoryMaxBytes: limits.memoryMaxBytes,
|
|
332
|
+
pidsMax: limits.pidsMax,
|
|
333
|
+
updatedAt: new Date().toISOString(),
|
|
334
|
+
};
|
|
335
|
+
await fsImpl.writeFile(`${slicePath}/${SLICE_READY_MARKER}`, JSON.stringify(marker, null, 2), 'utf8');
|
|
336
|
+
return { slice, limits, controllers: wanted };
|
|
337
|
+
} catch (error) {
|
|
338
|
+
throw wrapCgroupWriteError(error, slicePath);
|
|
307
339
|
}
|
|
308
|
-
const marker = {
|
|
309
|
-
slice,
|
|
310
|
-
cpuQuotaUs: limits.cpuQuotaUs,
|
|
311
|
-
cpuPeriodUs: limits.cpuPeriodUs,
|
|
312
|
-
memoryMaxBytes: limits.memoryMaxBytes,
|
|
313
|
-
pidsMax: limits.pidsMax,
|
|
314
|
-
updatedAt: new Date().toISOString(),
|
|
315
|
-
};
|
|
316
|
-
await fsImpl.writeFile(`${slicePath}/${SLICE_READY_MARKER}`, JSON.stringify(marker, null, 2), 'utf8');
|
|
317
|
-
return { slice, limits, controllers: wanted };
|
|
318
340
|
}
|
|
319
341
|
|
|
320
342
|
/**
|
|
@@ -141,9 +141,10 @@ export const CONFIG = {
|
|
|
141
141
|
image: process.env.SANDBOX_AGENT_IMAGE || 'ghcr.io/yeaft/yeaft-web-code-agent-agent:dev',
|
|
142
142
|
serverUrl: process.env.SANDBOX_SERVER_URL || '',
|
|
143
143
|
stateDir: process.env.SANDBOX_STATE_DIR || join(homedir(), '.yeaft', 'container-agents'),
|
|
144
|
-
// Shared cgroup slice (created by
|
|
145
|
-
// Host) that caps the sum of all
|
|
146
|
-
// means no cgroup-parent is
|
|
144
|
+
// Shared cgroup slice (created by the absolute Node/CLI command printed by
|
|
145
|
+
// `yeaft-agent container setup-limits` on the Host) that caps the sum of all
|
|
146
|
+
// sandbox containers' CPU/memory usage. Empty means no cgroup-parent is
|
|
147
|
+
// passed and sandbox containers are unprotected.
|
|
147
148
|
cgroupParent: process.env.SANDBOX_CGROUP_PARENT || ''
|
|
148
149
|
},
|
|
149
150
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.444"}
|
package/package.json
CHANGED