@yeaft/webchat-agent 1.0.437 → 1.0.438

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-cli.js CHANGED
@@ -3,9 +3,15 @@ import { homedir } from 'node:os';
3
3
  import { join, resolve } from 'node:path';
4
4
  import {
5
5
  createContainerAgent,
6
+ DEFAULT_AGENT_SLICE,
7
+ detectHostResources,
8
+ ensureAgentSlice,
6
9
  inspectContainerAgent,
10
+ isAgentSliceReady,
7
11
  logsContainerAgent,
8
12
  removeContainerAgent,
13
+ resolveDiskSize,
14
+ runDocker,
9
15
  startContainerAgent,
10
16
  stopContainerAgent,
11
17
  writeAgentSecretFile,
@@ -14,12 +20,24 @@ import {
14
20
  function help() {
15
21
  console.log(`
16
22
  Usage:
17
- yeaft-agent container install --server <ws-url> --name <name> --secret <secret> [--image <image>]
23
+ yeaft-agent container install --server <ws-url> --name <name> --secret <secret> [--image <image>] [--cgroup-parent <slice>] [--no-slice] [--disk-size <size>]
24
+ yeaft-agent container setup-limits [--cpu-percent <pct>] [--memory-percent <pct>] [--pids <n>]
18
25
  yeaft-agent container start|stop|status|remove|logs --name <name>
19
26
 
20
27
  The container is an ordinary yeaft-agent. This command only manages its Docker lifecycle.
21
28
  The secret is passed as an argument, like 'yeaft-agent install', and persisted by this
22
29
  command to a private 0600 file before the container is created.
30
+
31
+ Resource protection (default): install attaches the container to the shared
32
+ '${DEFAULT_AGENT_SLICE}' cgroup slice created by 'setup-limits', which caps the sum of all
33
+ Yeaft Agent containers at 90% of host CPU and 70% of host memory. Run setup-limits once
34
+ as root before installing (or pass --no-slice to opt out of protection).
35
+
36
+ --disk-size <size>: per-volume capacity quota for the data and workspace volumes, e.g.
37
+ 20G or 80% of the Docker data-root filesystem. Requires Docker overlay2 on xfs with
38
+ project quotas; unsupported filesystems fail docker create. Applies to newly created
39
+ volumes only (Docker ignores size for existing volumes).
40
+
23
41
  Use --keep-volumes with remove to preserve its Yeaft data and workspace volumes.
24
42
  `);
25
43
  }
@@ -29,7 +47,7 @@ export function parseContainerArgs(args) {
29
47
  const positionals = [];
30
48
  for (let i = 0; i < args.length; i++) {
31
49
  const arg = args[i];
32
- if (arg === '--keep-volumes' || arg === '--follow') {
50
+ if (arg === '--keep-volumes' || arg === '--follow' || arg === '--no-slice') {
33
51
  options[arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = true;
34
52
  continue;
35
53
  }
@@ -44,6 +62,12 @@ export function parseContainerArgs(args) {
44
62
  return { action: positionals[0], options };
45
63
  }
46
64
 
65
+ async function dockerDataRoot() {
66
+ const result = await runDocker(['info', '--format', '{{.DockerRootDir}}']);
67
+ if (!result.stdout) throw new Error('docker info did not report a data-root');
68
+ return result.stdout;
69
+ }
70
+
47
71
  export async function runContainerCli(args) {
48
72
  if (args.length === 0 || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') return help();
49
73
  const { action, options } = parseContainerArgs(args);
@@ -57,12 +81,47 @@ export async function runContainerCli(args) {
57
81
  if (!secret) throw new Error('--secret <secret> is required');
58
82
  const secretFile = join(homedir(), '.yeaft', 'container-agents', name, 'agent-secret');
59
83
  await writeAgentSecretFile(secretFile, secret);
84
+ let cgroupParent;
85
+ if (options.noSlice) {
86
+ cgroupParent = undefined;
87
+ } else if (options.cgroupParent) {
88
+ cgroupParent = String(options.cgroupParent).trim();
89
+ } else if (!(await isAgentSliceReady())) {
90
+ throw new Error(
91
+ `Agent slice '${DEFAULT_AGENT_SLICE}' is not initialized; ` +
92
+ 'run "sudo yeaft-agent container setup-limits" first, ' +
93
+ 'or pass --no-slice to install without shared resource limits',
94
+ );
95
+ } else {
96
+ cgroupParent = DEFAULT_AGENT_SLICE;
97
+ }
98
+ let diskSizeBytes;
99
+ if (options.diskSize) {
100
+ const value = String(options.diskSize).trim();
101
+ diskSizeBytes = await resolveDiskSize(value, {
102
+ dockerRoot: value.includes('%') ? await dockerDataRoot() : undefined,
103
+ });
104
+ }
60
105
  result = await createContainerAgent({
61
106
  name,
62
107
  serverUrl: options.server,
63
108
  secretFile,
64
109
  image: options.image,
110
+ cgroupParent,
111
+ diskSizeBytes,
112
+ });
113
+ } else if (action === 'setup-limits') {
114
+ if (typeof process.getuid === 'function' && process.getuid() !== 0) {
115
+ throw new Error('setup-limits must run as root; use sudo');
116
+ }
117
+ const resources = await detectHostResources();
118
+ result = await ensureAgentSlice({
119
+ cpuPercent: Number(options.cpuPercent) || 90,
120
+ memoryPercent: Number(options.memoryPercent) || 70,
121
+ pidsLimit: Number(options.pids) || 4096,
122
+ resources,
65
123
  });
124
+ result.resources = resources;
66
125
  } else if (action === 'start') {
67
126
  result = await startContainerAgent(name);
68
127
  } else if (action === 'stop') {
@@ -1,9 +1,13 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { chmod, mkdir, writeFile } from 'node:fs/promises';
2
+ import { chmod, mkdir, readFile, statfs, writeFile } from 'node:fs/promises';
3
+ import { cpus } from 'node:os';
3
4
  import { dirname, resolve } from 'node:path';
4
5
 
5
6
  export const DEFAULT_AGENT_IMAGE = 'ghcr.io/yeaft/yeaft-web-code-agent-agent:dev';
7
+ export const DEFAULT_AGENT_SLICE = 'yeaft.slice';
8
+ export const SLICE_READY_MARKER = 'yeaft-slice.ready';
6
9
  const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
10
+ const CPU_PERIOD_US = 100_000;
7
11
 
8
12
  export class ContainerAgentError extends Error {
9
13
  constructor(code, message = code) {
@@ -64,6 +68,8 @@ export function buildCreateArgs({
64
68
  dataVolume,
65
69
  workspaceVolume,
66
70
  restart = 'unless-stopped',
71
+ cgroupParent,
72
+ diskSizeBytes,
67
73
  }) {
68
74
  const agentName = normalizeContainerAgentName(name);
69
75
  if (!String(serverUrl || '').match(/^wss?:\/\//)) {
@@ -73,14 +79,29 @@ export function buildCreateArgs({
73
79
  const containerName = containerNameForAgent(agentName);
74
80
  const safeImage = String(image || '').trim();
75
81
  if (!safeImage || safeImage.startsWith('-')) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_IMAGE');
76
- return [
82
+ const args = [
77
83
  'create', '--name', containerName,
78
84
  '--label', 'io.yeaft.container-agent=true',
79
85
  '--label', `io.yeaft.agent-name=${agentName}`,
80
86
  '--restart', restart,
81
87
  '--init',
82
- '--mount', `type=volume,src=${dataVolume || `${containerName}-data`},dst=/home/yeaft/.yeaft`,
83
- '--mount', `type=volume,src=${workspaceVolume || `${containerName}-workspace`},dst=/workspace`,
88
+ ];
89
+ if (cgroupParent) {
90
+ const slice = String(cgroupParent).trim();
91
+ if (!slice) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_CGROUP_PARENT');
92
+ args.push('--cgroup-parent', slice);
93
+ }
94
+ let diskOpt = '';
95
+ if (diskSizeBytes) {
96
+ const size = Math.floor(Number(diskSizeBytes));
97
+ if (!Number.isFinite(size) || size <= 0) {
98
+ throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE');
99
+ }
100
+ diskOpt = `,volume-opt=size=${size}`;
101
+ }
102
+ args.push(
103
+ '--mount', `type=volume,src=${dataVolume || `${containerName}-data`},dst=/home/yeaft/.yeaft${diskOpt}`,
104
+ '--mount', `type=volume,src=${workspaceVolume || `${containerName}-workspace`},dst=/workspace${diskOpt}`,
84
105
  '--mount', `type=bind,src=${resolve(secretFile)},dst=/run/yeaft-host-secret,readonly`,
85
106
  '--env', `SERVER_URL=${serverUrl}`,
86
107
  '--env', `AGENT_NAME=${agentName}`,
@@ -88,7 +109,8 @@ export function buildCreateArgs({
88
109
  '--env', 'YEAFT_DIR=/home/yeaft/.yeaft',
89
110
  '--env', 'WORK_DIR=/workspace',
90
111
  safeImage,
91
- ];
112
+ );
113
+ return args;
92
114
  }
93
115
 
94
116
  /**
@@ -182,3 +204,161 @@ export async function logsContainerAgent(name, { follow = false, ...runtime } =
182
204
  args.push(containerNameForAgent(name));
183
205
  return runDocker(args, { ...runtime, stdout: follow ? 'inherit' : 'pipe' });
184
206
  }
207
+
208
+ function clampPercent(value, fallback) {
209
+ const n = Number(value);
210
+ const safe = Number.isFinite(n) ? n : fallback;
211
+ return Math.min(100, Math.max(1, safe));
212
+ }
213
+
214
+ /**
215
+ * Compute cgroup v2 slice limits from host resources. Pure function so the
216
+ * 90% CPU / 70% memory policy is directly testable.
217
+ *
218
+ * @param {object} policy cpuPercent (default 90), memoryPercent (default 70), pidsLimit (default 4096)
219
+ * @param {object} resources { cpuCores, memTotalBytes } from detectHostResources
220
+ * @returns {{cpuQuotaUs: number, cpuPeriodUs: number, memoryMaxBytes: number, pidsMax: number}}
221
+ */
222
+ export function buildSliceLimits(
223
+ { cpuPercent = 90, memoryPercent = 70, pidsLimit = 4096 } = {},
224
+ { cpuCores = 0, memTotalBytes = 0 } = {},
225
+ ) {
226
+ const cores = Math.max(1, Math.floor(Number(cpuCores) || 0) || 1);
227
+ const cpuPct = clampPercent(cpuPercent, 90);
228
+ const memPct = clampPercent(memoryPercent, 70);
229
+ return {
230
+ cpuQuotaUs: Math.floor((cores * cpuPct / 100) * CPU_PERIOD_US),
231
+ cpuPeriodUs: CPU_PERIOD_US,
232
+ memoryMaxBytes: Math.floor((Number(memTotalBytes) || 0) * memPct / 100),
233
+ pidsMax: Math.max(64, Math.floor(Number(pidsLimit) || 0) || 4096),
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Detect host resources: logical CPU count plus MemTotal from /proc/meminfo.
239
+ * MemTotal may be 0 on non-Linux or unreadable /proc; callers must treat a
240
+ * zero memory limit as "no memory constraint" rather than OOM-ing containers.
241
+ *
242
+ * @param {object} options { readFileImpl, cpuCount } overrides for tests
243
+ * @returns {Promise<{cpuCores: number, memTotalBytes: number}>}
244
+ */
245
+ export async function detectHostResources({ readFileImpl = readFile, cpuCount = cpus().length } = {}) {
246
+ let memTotalBytes = 0;
247
+ try {
248
+ const meminfo = await readFileImpl('/proc/meminfo', 'utf8');
249
+ const match = String(meminfo || '').match(/^MemTotal:\s+(\d+)\s*kB/m);
250
+ if (match) memTotalBytes = Number(match[1]) * 1024;
251
+ } catch {
252
+ // Non-Linux or restricted /proc; memory limiting is skipped downstream.
253
+ }
254
+ return { cpuCores: Math.max(1, Math.floor(Number(cpuCount) || 0) || 1), memTotalBytes };
255
+ }
256
+
257
+ /**
258
+ * Create or refresh a cgroup v2 slice that all Yeaft Agent containers share.
259
+ * Requires root (writes under /sys/fs/cgroup). Writes a ready marker so
260
+ * `container install` can refuse to create unprotected containers by default.
261
+ *
262
+ * @param {object} options slice, cpuPercent, memoryPercent, pidsLimit,
263
+ * resources (pre-detected), fsImpl { mkdir, readFile, writeFile } for tests
264
+ * @returns {Promise<{slice: string, limits: object, controllers: string[]}>}
265
+ */
266
+ export async function ensureAgentSlice({
267
+ slice = DEFAULT_AGENT_SLICE,
268
+ cpuPercent = 90,
269
+ memoryPercent = 70,
270
+ pidsLimit = 4096,
271
+ resources,
272
+ fsImpl = { mkdir, readFile, writeFile },
273
+ } = {}) {
274
+ const slicePath = `/sys/fs/cgroup/${String(slice).replace(/^\/+/, '')}`;
275
+ let controllers = [];
276
+ try {
277
+ const text = await fsImpl.readFile('/sys/fs/cgroup/cgroup.controllers', 'utf8');
278
+ controllers = String(text || '').trim().split(/\s+/).filter(Boolean);
279
+ } catch {
280
+ // Treat missing controller list as "no controllers writable".
281
+ }
282
+ const wanted = ['cpu', 'memory', 'pids'].filter(name => controllers.includes(name));
283
+ if (wanted.length === 0) {
284
+ throw new ContainerAgentError(
285
+ 'CONTAINER_AGENT_CGROUP_UNAVAILABLE',
286
+ 'cgroup v2 controllers (cpu/memory/pids) are unavailable; setup-limits requires a host running cgroup v2 and root access to /sys/fs/cgroup',
287
+ );
288
+ }
289
+ await fsImpl.mkdir(slicePath, { recursive: true, mode: 0o755 });
290
+ await fsImpl.writeFile(
291
+ `${slicePath}/cgroup.subtree_control`,
292
+ wanted.map(name => `+${name}`).join(' '),
293
+ 'utf8',
294
+ );
295
+ const detected = resources || await detectHostResources({ readFileImpl: fsImpl.readFile });
296
+ const limits = buildSliceLimits({ cpuPercent, memoryPercent, pidsLimit }, detected);
297
+ if (wanted.includes('cpu') && limits.cpuQuotaUs > 0) {
298
+ await fsImpl.writeFile(`${slicePath}/cpu.max`, `${limits.cpuQuotaUs} ${limits.cpuPeriodUs}`, 'utf8');
299
+ }
300
+ if (wanted.includes('memory') && limits.memoryMaxBytes > 0) {
301
+ await fsImpl.writeFile(`${slicePath}/memory.max`, String(limits.memoryMaxBytes), 'utf8');
302
+ // No swap escape hatch: the 70% RAM hard cap is the whole memory budget.
303
+ await fsImpl.writeFile(`${slicePath}/memory.swap.max`, '0', 'utf8');
304
+ }
305
+ if (wanted.includes('pids') && limits.pidsMax > 0) {
306
+ await fsImpl.writeFile(`${slicePath}/pids.max`, String(limits.pidsMax), 'utf8');
307
+ }
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
+ }
319
+
320
+ /**
321
+ * Whether `container setup-limits` has initialized the given slice.
322
+ *
323
+ * @param {object} options slice, readFileImpl for tests
324
+ * @returns {Promise<boolean>}
325
+ */
326
+ export async function isAgentSliceReady({ slice = DEFAULT_AGENT_SLICE, readFileImpl = readFile } = {}) {
327
+ const slicePath = `/sys/fs/cgroup/${String(slice).replace(/^\/+/, '')}`;
328
+ try {
329
+ await readFileImpl(`${slicePath}/${SLICE_READY_MARKER}`, 'utf8');
330
+ return true;
331
+ } catch {
332
+ return false;
333
+ }
334
+ }
335
+
336
+ const DISK_SIZE_RE = /^(\d+(?:\.\d+)?)\s*(%|[kmgt](?:i?b)?|b)?$/i;
337
+
338
+ /**
339
+ * Resolve a disk size like "20G" or "80%" to bytes. Percentages are computed
340
+ * against the Docker data-root filesystem and require statfsImpl (node:fs
341
+ * statfs) plus the docker root path.
342
+ *
343
+ * @param {string|number} value
344
+ * @param {object} options { dockerRoot, statfsImpl } for percent resolution and tests
345
+ * @returns {Promise<number>}
346
+ */
347
+ export async function resolveDiskSize(value, { dockerRoot, statfsImpl = statfs } = {}) {
348
+ const input = String(value ?? '').trim();
349
+ const match = input.match(DISK_SIZE_RE);
350
+ if (!match) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE', `Invalid disk size: ${input}`);
351
+ const amount = Number(match[1]);
352
+ const unit = (match[2] || 'b').toLowerCase();
353
+ if (unit === 'b') return Math.floor(amount);
354
+ if (unit === '%') {
355
+ if (!dockerRoot) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE', 'Percent disk size requires the Docker data-root');
356
+ const info = await statfsImpl(dockerRoot);
357
+ const totalBytes = Number(info.bsize) * Number(info.blocks);
358
+ return Math.floor(totalBytes * amount / 100);
359
+ }
360
+ const multipliers = { k: 1024, kb: 1024, kib: 1024, m: 1024 ** 2, mb: 1024 ** 2, mib: 1024 ** 2, g: 1024 ** 3, gb: 1024 ** 3, gib: 1024 ** 3, t: 1024 ** 4, tb: 1024 ** 4, tib: 1024 ** 4 };
361
+ const multiplier = multipliers[unit];
362
+ if (!multiplier) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE', `Invalid disk size: ${input}`);
363
+ return Math.floor(amount * multiplier);
364
+ }
@@ -1,9 +1,13 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { chmod, mkdir, writeFile } from 'node:fs/promises';
2
+ import { chmod, mkdir, readFile, statfs, writeFile } from 'node:fs/promises';
3
+ import { cpus } from 'node:os';
3
4
  import { dirname, resolve } from 'node:path';
4
5
 
5
6
  export const DEFAULT_AGENT_IMAGE = 'ghcr.io/yeaft/yeaft-web-code-agent-agent:dev';
7
+ export const DEFAULT_AGENT_SLICE = 'yeaft.slice';
8
+ export const SLICE_READY_MARKER = 'yeaft-slice.ready';
6
9
  const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
10
+ const CPU_PERIOD_US = 100_000;
7
11
 
8
12
  export class ContainerAgentError extends Error {
9
13
  constructor(code, message = code) {
@@ -64,6 +68,8 @@ export function buildCreateArgs({
64
68
  dataVolume,
65
69
  workspaceVolume,
66
70
  restart = 'unless-stopped',
71
+ cgroupParent,
72
+ diskSizeBytes,
67
73
  }) {
68
74
  const agentName = normalizeContainerAgentName(name);
69
75
  if (!String(serverUrl || '').match(/^wss?:\/\//)) {
@@ -73,14 +79,29 @@ export function buildCreateArgs({
73
79
  const containerName = containerNameForAgent(agentName);
74
80
  const safeImage = String(image || '').trim();
75
81
  if (!safeImage || safeImage.startsWith('-')) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_IMAGE');
76
- return [
82
+ const args = [
77
83
  'create', '--name', containerName,
78
84
  '--label', 'io.yeaft.container-agent=true',
79
85
  '--label', `io.yeaft.agent-name=${agentName}`,
80
86
  '--restart', restart,
81
87
  '--init',
82
- '--mount', `type=volume,src=${dataVolume || `${containerName}-data`},dst=/home/yeaft/.yeaft`,
83
- '--mount', `type=volume,src=${workspaceVolume || `${containerName}-workspace`},dst=/workspace`,
88
+ ];
89
+ if (cgroupParent) {
90
+ const slice = String(cgroupParent).trim();
91
+ if (!slice) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_CGROUP_PARENT');
92
+ args.push('--cgroup-parent', slice);
93
+ }
94
+ let diskOpt = '';
95
+ if (diskSizeBytes) {
96
+ const size = Math.floor(Number(diskSizeBytes));
97
+ if (!Number.isFinite(size) || size <= 0) {
98
+ throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE');
99
+ }
100
+ diskOpt = `,volume-opt=size=${size}`;
101
+ }
102
+ args.push(
103
+ '--mount', `type=volume,src=${dataVolume || `${containerName}-data`},dst=/home/yeaft/.yeaft${diskOpt}`,
104
+ '--mount', `type=volume,src=${workspaceVolume || `${containerName}-workspace`},dst=/workspace${diskOpt}`,
84
105
  '--mount', `type=bind,src=${resolve(secretFile)},dst=/run/yeaft-host-secret,readonly`,
85
106
  '--env', `SERVER_URL=${serverUrl}`,
86
107
  '--env', `AGENT_NAME=${agentName}`,
@@ -88,7 +109,8 @@ export function buildCreateArgs({
88
109
  '--env', 'YEAFT_DIR=/home/yeaft/.yeaft',
89
110
  '--env', 'WORK_DIR=/workspace',
90
111
  safeImage,
91
- ];
112
+ );
113
+ return args;
92
114
  }
93
115
 
94
116
  /**
@@ -182,3 +204,161 @@ export async function logsContainerAgent(name, { follow = false, ...runtime } =
182
204
  args.push(containerNameForAgent(name));
183
205
  return runDocker(args, { ...runtime, stdout: follow ? 'inherit' : 'pipe' });
184
206
  }
207
+
208
+ function clampPercent(value, fallback) {
209
+ const n = Number(value);
210
+ const safe = Number.isFinite(n) ? n : fallback;
211
+ return Math.min(100, Math.max(1, safe));
212
+ }
213
+
214
+ /**
215
+ * Compute cgroup v2 slice limits from host resources. Pure function so the
216
+ * 90% CPU / 70% memory policy is directly testable.
217
+ *
218
+ * @param {object} policy cpuPercent (default 90), memoryPercent (default 70), pidsLimit (default 4096)
219
+ * @param {object} resources { cpuCores, memTotalBytes } from detectHostResources
220
+ * @returns {{cpuQuotaUs: number, cpuPeriodUs: number, memoryMaxBytes: number, pidsMax: number}}
221
+ */
222
+ export function buildSliceLimits(
223
+ { cpuPercent = 90, memoryPercent = 70, pidsLimit = 4096 } = {},
224
+ { cpuCores = 0, memTotalBytes = 0 } = {},
225
+ ) {
226
+ const cores = Math.max(1, Math.floor(Number(cpuCores) || 0) || 1);
227
+ const cpuPct = clampPercent(cpuPercent, 90);
228
+ const memPct = clampPercent(memoryPercent, 70);
229
+ return {
230
+ cpuQuotaUs: Math.floor((cores * cpuPct / 100) * CPU_PERIOD_US),
231
+ cpuPeriodUs: CPU_PERIOD_US,
232
+ memoryMaxBytes: Math.floor((Number(memTotalBytes) || 0) * memPct / 100),
233
+ pidsMax: Math.max(64, Math.floor(Number(pidsLimit) || 0) || 4096),
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Detect host resources: logical CPU count plus MemTotal from /proc/meminfo.
239
+ * MemTotal may be 0 on non-Linux or unreadable /proc; callers must treat a
240
+ * zero memory limit as "no memory constraint" rather than OOM-ing containers.
241
+ *
242
+ * @param {object} options { readFileImpl, cpuCount } overrides for tests
243
+ * @returns {Promise<{cpuCores: number, memTotalBytes: number}>}
244
+ */
245
+ export async function detectHostResources({ readFileImpl = readFile, cpuCount = cpus().length } = {}) {
246
+ let memTotalBytes = 0;
247
+ try {
248
+ const meminfo = await readFileImpl('/proc/meminfo', 'utf8');
249
+ const match = String(meminfo || '').match(/^MemTotal:\s+(\d+)\s*kB/m);
250
+ if (match) memTotalBytes = Number(match[1]) * 1024;
251
+ } catch {
252
+ // Non-Linux or restricted /proc; memory limiting is skipped downstream.
253
+ }
254
+ return { cpuCores: Math.max(1, Math.floor(Number(cpuCount) || 0) || 1), memTotalBytes };
255
+ }
256
+
257
+ /**
258
+ * Create or refresh a cgroup v2 slice that all Yeaft Agent containers share.
259
+ * Requires root (writes under /sys/fs/cgroup). Writes a ready marker so
260
+ * `container install` can refuse to create unprotected containers by default.
261
+ *
262
+ * @param {object} options slice, cpuPercent, memoryPercent, pidsLimit,
263
+ * resources (pre-detected), fsImpl { mkdir, readFile, writeFile } for tests
264
+ * @returns {Promise<{slice: string, limits: object, controllers: string[]}>}
265
+ */
266
+ export async function ensureAgentSlice({
267
+ slice = DEFAULT_AGENT_SLICE,
268
+ cpuPercent = 90,
269
+ memoryPercent = 70,
270
+ pidsLimit = 4096,
271
+ resources,
272
+ fsImpl = { mkdir, readFile, writeFile },
273
+ } = {}) {
274
+ const slicePath = `/sys/fs/cgroup/${String(slice).replace(/^\/+/, '')}`;
275
+ let controllers = [];
276
+ try {
277
+ const text = await fsImpl.readFile('/sys/fs/cgroup/cgroup.controllers', 'utf8');
278
+ controllers = String(text || '').trim().split(/\s+/).filter(Boolean);
279
+ } catch {
280
+ // Treat missing controller list as "no controllers writable".
281
+ }
282
+ const wanted = ['cpu', 'memory', 'pids'].filter(name => controllers.includes(name));
283
+ if (wanted.length === 0) {
284
+ throw new ContainerAgentError(
285
+ 'CONTAINER_AGENT_CGROUP_UNAVAILABLE',
286
+ 'cgroup v2 controllers (cpu/memory/pids) are unavailable; setup-limits requires a host running cgroup v2 and root access to /sys/fs/cgroup',
287
+ );
288
+ }
289
+ await fsImpl.mkdir(slicePath, { recursive: true, mode: 0o755 });
290
+ await fsImpl.writeFile(
291
+ `${slicePath}/cgroup.subtree_control`,
292
+ wanted.map(name => `+${name}`).join(' '),
293
+ 'utf8',
294
+ );
295
+ const detected = resources || await detectHostResources({ readFileImpl: fsImpl.readFile });
296
+ const limits = buildSliceLimits({ cpuPercent, memoryPercent, pidsLimit }, detected);
297
+ if (wanted.includes('cpu') && limits.cpuQuotaUs > 0) {
298
+ await fsImpl.writeFile(`${slicePath}/cpu.max`, `${limits.cpuQuotaUs} ${limits.cpuPeriodUs}`, 'utf8');
299
+ }
300
+ if (wanted.includes('memory') && limits.memoryMaxBytes > 0) {
301
+ await fsImpl.writeFile(`${slicePath}/memory.max`, String(limits.memoryMaxBytes), 'utf8');
302
+ // No swap escape hatch: the 70% RAM hard cap is the whole memory budget.
303
+ await fsImpl.writeFile(`${slicePath}/memory.swap.max`, '0', 'utf8');
304
+ }
305
+ if (wanted.includes('pids') && limits.pidsMax > 0) {
306
+ await fsImpl.writeFile(`${slicePath}/pids.max`, String(limits.pidsMax), 'utf8');
307
+ }
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
+ }
319
+
320
+ /**
321
+ * Whether `container setup-limits` has initialized the given slice.
322
+ *
323
+ * @param {object} options slice, readFileImpl for tests
324
+ * @returns {Promise<boolean>}
325
+ */
326
+ export async function isAgentSliceReady({ slice = DEFAULT_AGENT_SLICE, readFileImpl = readFile } = {}) {
327
+ const slicePath = `/sys/fs/cgroup/${String(slice).replace(/^\/+/, '')}`;
328
+ try {
329
+ await readFileImpl(`${slicePath}/${SLICE_READY_MARKER}`, 'utf8');
330
+ return true;
331
+ } catch {
332
+ return false;
333
+ }
334
+ }
335
+
336
+ const DISK_SIZE_RE = /^(\d+(?:\.\d+)?)\s*(%|[kmgt](?:i?b)?|b)?$/i;
337
+
338
+ /**
339
+ * Resolve a disk size like "20G" or "80%" to bytes. Percentages are computed
340
+ * against the Docker data-root filesystem and require statfsImpl (node:fs
341
+ * statfs) plus the docker root path.
342
+ *
343
+ * @param {string|number} value
344
+ * @param {object} options { dockerRoot, statfsImpl } for percent resolution and tests
345
+ * @returns {Promise<number>}
346
+ */
347
+ export async function resolveDiskSize(value, { dockerRoot, statfsImpl = statfs } = {}) {
348
+ const input = String(value ?? '').trim();
349
+ const match = input.match(DISK_SIZE_RE);
350
+ if (!match) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE', `Invalid disk size: ${input}`);
351
+ const amount = Number(match[1]);
352
+ const unit = (match[2] || 'b').toLowerCase();
353
+ if (unit === 'b') return Math.floor(amount);
354
+ if (unit === '%') {
355
+ if (!dockerRoot) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE', 'Percent disk size requires the Docker data-root');
356
+ const info = await statfsImpl(dockerRoot);
357
+ const totalBytes = Number(info.bsize) * Number(info.blocks);
358
+ return Math.floor(totalBytes * amount / 100);
359
+ }
360
+ const multipliers = { k: 1024, kb: 1024, kib: 1024, m: 1024 ** 2, mb: 1024 ** 2, mib: 1024 ** 2, g: 1024 ** 3, gb: 1024 ** 3, gib: 1024 ** 3, t: 1024 ** 4, tb: 1024 ** 4, tib: 1024 ** 4 };
361
+ const multiplier = multipliers[unit];
362
+ if (!multiplier) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE', `Invalid disk size: ${input}`);
363
+ return Math.floor(amount * multiplier);
364
+ }
@@ -140,7 +140,11 @@ export const CONFIG = {
140
140
  enabled: process.env.SANDBOX_ENABLED === 'true',
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
- stateDir: process.env.SANDBOX_STATE_DIR || join(homedir(), '.yeaft', 'container-agents')
143
+ stateDir: process.env.SANDBOX_STATE_DIR || join(homedir(), '.yeaft', 'container-agents'),
144
+ // Shared cgroup slice (created by `yeaft-agent container setup-limits` on the
145
+ // Host) that caps the sum of all sandbox containers' CPU/memory usage. Empty
146
+ // means no cgroup-parent is passed and sandbox containers are unprotected.
147
+ cgroupParent: process.env.SANDBOX_CGROUP_PARENT || ''
144
148
  },
145
149
 
146
150
  // File upload settings
@@ -105,6 +105,7 @@ export class ContainerAgentService {
105
105
  serverUrl: this.config.serverUrl,
106
106
  secretFile,
107
107
  image: this.config.image,
108
+ ...(this.config.cgroupParent ? { cgroupParent: this.config.cgroupParent } : {}),
108
109
  });
109
110
  return { snapshot: await this.snapshot(user.id), replayed: false };
110
111
  });
@@ -1 +1 @@
1
- {"version":"1.0.437"}
1
+ {"version":"1.0.438"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.437",
3
+ "version": "1.0.438",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",