@yeaft/webchat-agent 1.0.437 → 1.0.439

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
@@ -1,11 +1,18 @@
1
1
  #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
2
3
  import { homedir } from 'node:os';
3
4
  import { join, resolve } from 'node:path';
4
5
  import {
5
6
  createContainerAgent,
7
+ DEFAULT_AGENT_SLICE,
8
+ detectHostResources,
9
+ ensureAgentSlice,
6
10
  inspectContainerAgent,
11
+ isAgentSliceReady,
7
12
  logsContainerAgent,
8
13
  removeContainerAgent,
14
+ resolveDiskSize,
15
+ runDocker,
9
16
  startContainerAgent,
10
17
  stopContainerAgent,
11
18
  writeAgentSecretFile,
@@ -14,12 +21,25 @@ import {
14
21
  function help() {
15
22
  console.log(`
16
23
  Usage:
17
- yeaft-agent container install --server <ws-url> --name <name> --secret <secret> [--image <image>]
24
+ yeaft-agent container install --server <ws-url> --name <name> --secret <secret> [--image <image>] [--cgroup-parent <slice>] [--no-slice] [--disk-size <size>]
25
+ yeaft-agent container setup-limits [--cpu-percent <pct>] [--memory-percent <pct>] [--pids <n>]
18
26
  yeaft-agent container start|stop|status|remove|logs --name <name>
19
27
 
20
28
  The container is an ordinary yeaft-agent. This command only manages its Docker lifecycle.
21
29
  The secret is passed as an argument, like 'yeaft-agent install', and persisted by this
22
30
  command to a private 0600 file before the container is created.
31
+
32
+ Resource protection (default): install attaches the container to the shared
33
+ '${DEFAULT_AGENT_SLICE}' cgroup slice, which caps the sum of all Yeaft Agent
34
+ containers at 90% of host CPU and 70% of host memory. The slice is initialized
35
+ automatically on first install (as root directly, otherwise via sudo; you are
36
+ prompted for the sudo password once). Pass --no-slice to opt out of protection.
37
+
38
+ --disk-size <size>: per-volume capacity quota for the data and workspace volumes, e.g.
39
+ 20G or 80% of the Docker data-root filesystem. Requires Docker overlay2 on xfs with
40
+ project quotas; unsupported filesystems fail docker create. Applies to newly created
41
+ volumes only (Docker ignores size for existing volumes).
42
+
23
43
  Use --keep-volumes with remove to preserve its Yeaft data and workspace volumes.
24
44
  `);
25
45
  }
@@ -29,7 +49,7 @@ export function parseContainerArgs(args) {
29
49
  const positionals = [];
30
50
  for (let i = 0; i < args.length; i++) {
31
51
  const arg = args[i];
32
- if (arg === '--keep-volumes' || arg === '--follow') {
52
+ if (arg === '--keep-volumes' || arg === '--follow' || arg === '--no-slice') {
33
53
  options[arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = true;
34
54
  continue;
35
55
  }
@@ -44,6 +64,62 @@ export function parseContainerArgs(args) {
44
64
  return { action: positionals[0], options };
45
65
  }
46
66
 
67
+ async function dockerDataRoot() {
68
+ const result = await runDocker(['info', '--format', '{{.DockerRootDir}}']);
69
+ if (!result.stdout) throw new Error('docker info did not report a data-root');
70
+ return result.stdout;
71
+ }
72
+
73
+ /**
74
+ * Initialize the shared agent slice during `container install` so users never
75
+ * have to run `setup-limits` by hand. Root can write the slice directly; a
76
+ * non-root user re-invokes this CLI via an interactive sudo so the password
77
+ * prompt happens inline. Non-interactive shells skip sudo and fail with
78
+ * guidance instead of hanging.
79
+ *
80
+ * @param {object} options { spawnImpl, interactive } overrides for tests
81
+ */
82
+ export async function ensureAgentSliceAuto({
83
+ spawnImpl = spawn,
84
+ interactive = Boolean(process.stdin?.isTTY),
85
+ } = {}) {
86
+ if (typeof process.getuid === 'function' && process.getuid() === 0) {
87
+ await ensureAgentSlice();
88
+ return;
89
+ }
90
+ if (!interactive) {
91
+ throw new Error(
92
+ `Agent slice '${DEFAULT_AGENT_SLICE}' is not initialized and this shell is not interactive; ` +
93
+ 'run "sudo yeaft-agent container setup-limits" once, ' +
94
+ 'or pass --no-slice to install without shared resource limits',
95
+ );
96
+ }
97
+ const code = await runSudoSetupLimits({ spawnImpl });
98
+ if (code !== 0) {
99
+ throw new Error(
100
+ `Failed to initialize Agent slice '${DEFAULT_AGENT_SLICE}' via sudo (exit ${code}); ` +
101
+ 'run "sudo yeaft-agent container setup-limits" manually, ' +
102
+ 'or pass --no-slice to install without shared resource limits',
103
+ );
104
+ }
105
+ }
106
+
107
+ function runSudoSetupLimits({ spawnImpl = spawn } = {}) {
108
+ return new Promise((resolvePromise, reject) => {
109
+ // `sudo env PATH=...` keeps user-installed runtimes (nvm etc.) reachable
110
+ // even when sudoers sets a secure_path. Re-invoking the current entry
111
+ // script avoids depending on the npm bin being in sudo's PATH.
112
+ const entry = process.argv[1] ? resolve(process.argv[1]) : null;
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');
117
+ const child = spawnImpl('sudo', args, { stdio: 'inherit' });
118
+ child.once('error', reject);
119
+ child.once('close', code => resolvePromise(code ?? 1));
120
+ });
121
+ }
122
+
47
123
  export async function runContainerCli(args) {
48
124
  if (args.length === 0 || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') return help();
49
125
  const { action, options } = parseContainerArgs(args);
@@ -57,12 +133,44 @@ export async function runContainerCli(args) {
57
133
  if (!secret) throw new Error('--secret <secret> is required');
58
134
  const secretFile = join(homedir(), '.yeaft', 'container-agents', name, 'agent-secret');
59
135
  await writeAgentSecretFile(secretFile, secret);
136
+ let cgroupParent;
137
+ if (options.noSlice) {
138
+ cgroupParent = undefined;
139
+ } else if (options.cgroupParent) {
140
+ cgroupParent = String(options.cgroupParent).trim();
141
+ } else if (!(await isAgentSliceReady())) {
142
+ await ensureAgentSliceAuto();
143
+ cgroupParent = DEFAULT_AGENT_SLICE;
144
+ } else {
145
+ cgroupParent = DEFAULT_AGENT_SLICE;
146
+ }
147
+ let diskSizeBytes;
148
+ if (options.diskSize) {
149
+ const value = String(options.diskSize).trim();
150
+ diskSizeBytes = await resolveDiskSize(value, {
151
+ dockerRoot: value.includes('%') ? await dockerDataRoot() : undefined,
152
+ });
153
+ }
60
154
  result = await createContainerAgent({
61
155
  name,
62
156
  serverUrl: options.server,
63
157
  secretFile,
64
158
  image: options.image,
159
+ cgroupParent,
160
+ diskSizeBytes,
161
+ });
162
+ } else if (action === 'setup-limits') {
163
+ if (typeof process.getuid === 'function' && process.getuid() !== 0) {
164
+ throw new Error('setup-limits must run as root; use sudo');
165
+ }
166
+ const resources = await detectHostResources();
167
+ result = await ensureAgentSlice({
168
+ cpuPercent: Number(options.cpuPercent) || 90,
169
+ memoryPercent: Number(options.memoryPercent) || 70,
170
+ pidsLimit: Number(options.pids) || 4096,
171
+ resources,
65
172
  });
173
+ result.resources = resources;
66
174
  } else if (action === 'start') {
67
175
  result = await startContainerAgent(name);
68
176
  } 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