@yeaft/webchat-agent 1.0.372 → 1.0.374

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.
Files changed (34) hide show
  1. package/cli.js +8 -0
  2. package/connection/index.js +11 -1
  3. package/index.js +4 -0
  4. package/local-runtime/server/api.js +2 -0
  5. package/local-runtime/server/auth/login.js +1 -0
  6. package/local-runtime/server/auth/oauth-flow.js +2 -2
  7. package/local-runtime/server/auth/token.js +4 -0
  8. package/local-runtime/server/config.js +47 -1
  9. package/local-runtime/server/database.js +1 -0
  10. package/local-runtime/server/db/connection.js +346 -4
  11. package/local-runtime/server/db/sandbox-db.js +673 -0
  12. package/local-runtime/server/db/user-db.js +137 -7
  13. package/local-runtime/server/index.js +60 -2
  14. package/local-runtime/server/routes/sandbox-routes.js +124 -0
  15. package/local-runtime/server/routes/user-routes.js +30 -15
  16. package/local-runtime/server/sandbox-agent-auth.js +66 -0
  17. package/local-runtime/server/sandbox-attestation-listener.js +128 -0
  18. package/local-runtime/server/sandbox-config.js +42 -0
  19. package/local-runtime/server/sandbox-host-attestation.js +175 -0
  20. package/local-runtime/server/sandbox-reconciler.js +355 -0
  21. package/local-runtime/server/ws-agent.js +34 -11
  22. package/local-runtime/server/ws-client.js +18 -5
  23. package/local-runtime/version.json +1 -1
  24. package/local-runtime/web/app.bundle.js +90 -29
  25. package/local-runtime/web/app.bundle.js.gz +0 -0
  26. package/local-runtime/web/index.html +2 -2
  27. package/local-runtime/web/style.bundle.css +1 -1
  28. package/local-runtime/web/style.bundle.css.gz +0 -0
  29. package/managed-sandbox/agent-runtime.js +73 -0
  30. package/managed-sandbox/controller.js +118 -0
  31. package/managed-sandbox/helper.js +437 -0
  32. package/managed-sandbox/identity-store.js +9 -0
  33. package/managed-sandbox/runtime-executor.js +387 -0
  34. package/package.json +1 -1
@@ -0,0 +1,387 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { constants as fsConstants } from 'node:fs';
4
+ import { lstat, mkdir, open, rm, statfs, writeFile } from 'node:fs/promises';
5
+ import { freemem } from 'node:os';
6
+ import { join, parse, resolve, sep } from 'node:path';
7
+ const SANDBOX_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
8
+ const PRIVATE_IPV4_DESTINATIONS = [
9
+ '0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8',
10
+ '169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/24', '192.168.0.0/16',
11
+ '198.18.0.0/15', '224.0.0.0/4', '240.0.0.0/4'
12
+ ];
13
+ const PRIVATE_IPV6_DESTINATIONS = [
14
+ '::/128', '::1/128', 'fc00::/7', 'fe80::/10', 'ff00::/8', '2001:db8::/32', '2001:10::/28'
15
+ ];
16
+
17
+ function commandRunner(command, args, options = {}) {
18
+ return new Promise((resolveCommand, rejectCommand) => {
19
+ const child = spawn(command, args, {
20
+ env: options.env,
21
+ stdio: [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
22
+ windowsHide: true
23
+ });
24
+ if (options.input !== undefined) child.stdin.end(options.input);
25
+ const stdout = [];
26
+ const stderr = [];
27
+ let stdoutBytes = 0;
28
+ let stderrBytes = 0;
29
+ const maxBuffer = 1024 * 1024;
30
+ const timer = setTimeout(() => child.kill('SIGKILL'), options.timeoutMs || 30_000);
31
+ child.stdout.on('data', chunk => {
32
+ stdoutBytes += chunk.length;
33
+ if (stdoutBytes > maxBuffer) child.kill('SIGKILL');
34
+ else stdout.push(chunk);
35
+ });
36
+ child.stderr.on('data', chunk => {
37
+ stderrBytes += chunk.length;
38
+ if (stderrBytes > maxBuffer) child.kill('SIGKILL');
39
+ else stderr.push(chunk);
40
+ });
41
+ child.on('error', error => {
42
+ clearTimeout(timer);
43
+ rejectCommand(error);
44
+ });
45
+ child.on('close', code => {
46
+ clearTimeout(timer);
47
+ const result = {
48
+ stdout: Buffer.concat(stdout).toString('utf8'),
49
+ stderr: Buffer.concat(stderr).toString('utf8'),
50
+ code
51
+ };
52
+ if (code === 0) resolveCommand(result);
53
+ else rejectCommand(Object.assign(new Error(`${command} exited with code ${code}`), result));
54
+ });
55
+ });
56
+ }
57
+
58
+ function assertSandboxId(value) {
59
+ if (!SANDBOX_ID.test(String(value || ''))) {
60
+ throw new Error('Sandbox runtime rejected an invalid Sandbox identity');
61
+ }
62
+ }
63
+
64
+ function sandboxName(sandboxId) {
65
+ return `yeaft-sandbox-${createHash('sha256').update(sandboxId).digest('hex').slice(0, 24)}`;
66
+ }
67
+
68
+ function within(root, path) {
69
+ const normalizedRoot = resolve(root);
70
+ const normalizedPath = resolve(path);
71
+ return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}${sep}`);
72
+ }
73
+
74
+ async function assertNoSymlink(path, { allowMissing = false } = {}) {
75
+ const absolute = resolve(path);
76
+ const root = parse(absolute).root;
77
+ const relative = absolute.slice(root.length).split(sep).filter(Boolean);
78
+ let current = root;
79
+ for (const part of relative) {
80
+ current = join(current, part);
81
+ try {
82
+ const stat = await lstat(current);
83
+ if (stat.isSymbolicLink()) throw new Error('Sandbox runtime rejected a symbolic-link path');
84
+ } catch (error) {
85
+ if (error.code === 'ENOENT' && allowMissing) return;
86
+ throw error;
87
+ }
88
+ }
89
+ }
90
+
91
+ function parseInspect(stdout) {
92
+ const parsed = JSON.parse(stdout);
93
+ const value = Array.isArray(parsed) ? parsed[0] : parsed;
94
+ if (!value || typeof value !== 'object') throw new Error('Sandbox runtime inspect returned no object');
95
+ return value;
96
+ }
97
+
98
+ function exactImage(inspect, digest) {
99
+ return inspect.ImageDigest === digest || inspect.ImageName === digest || inspect.Config?.Image === digest;
100
+ }
101
+
102
+ /**
103
+ * Concrete dedicated-Host runtime executor. It accepts only the signed Helper
104
+ * operation schema and translates it to fixed Podman, XFS and nftables calls.
105
+ * No caller-controlled command, path, mount, capability or network argument is
106
+ * accepted.
107
+ */
108
+ export function createSandboxRuntimeExecutor({
109
+ config,
110
+ run = commandRunner,
111
+ availableMemoryBytes = freemem,
112
+ statfsImpl = statfs,
113
+ }) {
114
+ if (!config?.dedicatedHost || !config.dataRoot || !config.secretRoot || !config.imageDigest
115
+ || !config.serverUrl || !config.runtimeBinary || !config.xfsQuotaBinary
116
+ || !config.nftBinary || !config.networkName || !config.networkBridge
117
+ || !Number.isInteger(config.pidsLimit) || config.pidsLimit <= 0
118
+ || !Number.isInteger(config.ioWeight) || config.ioWeight <= 0
119
+ || !Number.isInteger(config.hostMemoryReserveMiB) || config.hostMemoryReserveMiB <= 0
120
+ || typeof availableMemoryBytes !== 'function') {
121
+ throw new Error('Sandbox runtime requires a complete dedicated Host configuration');
122
+ }
123
+
124
+ const dataRoot = resolve(config.dataRoot);
125
+ const secretRoot = resolve(config.secretRoot);
126
+ const policyRoot = resolve(config.policyRoot || join(dataRoot, '.policy'));
127
+ if (within(dataRoot, secretRoot) || within(secretRoot, dataRoot)) {
128
+ throw new Error('Sandbox runtime secret root must be isolated from persistent data');
129
+ }
130
+
131
+ async function prepareSecretDirectory(operation) {
132
+ await assertNoSymlink(secretRoot, { allowMissing: true });
133
+ await mkdir(secretRoot, { recursive: true, mode: 0o700 });
134
+ await assertNoSymlink(secretRoot);
135
+ const filesystem = await statfsImpl(secretRoot);
136
+ // Linux TMPFS_MAGIC. Secret material must never fall back to persistent storage.
137
+ if (Number(filesystem.type) !== 0x01021994) {
138
+ throw new Error('Sandbox runtime secret root is not tmpfs');
139
+ }
140
+ const directory = join(secretRoot, operation.sandboxId);
141
+ await assertNoSymlink(directory, { allowMissing: true });
142
+ await mkdir(directory, { mode: 0o700 });
143
+ await assertNoSymlink(directory);
144
+ return directory;
145
+ }
146
+
147
+ function paths(operation) {
148
+ assertSandboxId(operation.sandboxId);
149
+ const root = resolve(dataRoot, operation.sandboxId);
150
+ const home = join(root, 'home');
151
+ const workspace = join(root, 'workspace');
152
+ const policy = resolve(policyRoot, `${operation.sandboxId}.nft`);
153
+ if (![root, home, workspace].every(path => within(dataRoot, path)) || !within(policyRoot, policy)) {
154
+ throw new Error('Sandbox runtime rejected a path outside its data root');
155
+ }
156
+ return { root, home, workspace, policy };
157
+ }
158
+
159
+ async function inspectContainer(name) {
160
+ try {
161
+ const result = await run(config.runtimeBinary, ['inspect', name]);
162
+ return parseInspect(result.stdout);
163
+ } catch (error) {
164
+ if (error.code === 125 || /no such (container|object)/i.test(String(error.stderr || error.message))) return null;
165
+ throw error;
166
+ }
167
+ }
168
+
169
+ function quotaProjectId(operation) {
170
+ if (!Number.isInteger(config.quotaProjectBase) || config.quotaProjectBase <= 0) {
171
+ throw new Error('Sandbox runtime requires a valid XFS quota project range');
172
+ }
173
+ return config.quotaProjectBase + Number.parseInt(
174
+ createHash('sha256').update(operation.sandboxId).digest('hex').slice(0, 7), 16
175
+ );
176
+ }
177
+
178
+ async function inspectQuota(operation) {
179
+ const projectId = quotaProjectId(operation);
180
+ const hardLimit = `${operation.resources.diskGiB}g`;
181
+ const report = await run(config.xfsQuotaBinary, ['-x', '-c', `report -p -n -N ${projectId}`, dataRoot]);
182
+ if (!String(report.stdout).includes(String(projectId)) || !String(report.stdout).toLowerCase().includes(hardLimit)) {
183
+ throw new Error('Sandbox XFS hard quota inspection failed');
184
+ }
185
+ }
186
+
187
+ async function applyQuota(operation, root) {
188
+ const projectId = quotaProjectId(operation);
189
+ const hardLimit = `${operation.resources.diskGiB}g`;
190
+ await run(config.xfsQuotaBinary, ['-x', '-c', `project -s -p ${root} ${projectId}`, dataRoot]);
191
+ await run(config.xfsQuotaBinary, ['-x', '-c', `limit -p bhard=${hardLimit} ${projectId}`, dataRoot]);
192
+ await inspectQuota(operation);
193
+ }
194
+
195
+ function networkTable(operation) {
196
+ return `yeaft_sbx_${createHash('sha256').update(operation.sandboxId).digest('hex').slice(0, 16)}`;
197
+ }
198
+
199
+ function networkPolicy(operation, name) {
200
+ const marker = `yeaft:${operation.sandboxId}:${operation.generation}`;
201
+ const table = networkTable(operation);
202
+ const blockedIpv4 = PRIVATE_IPV4_DESTINATIONS
203
+ .map(cidr => ` iifname \"${config.networkBridge}\" ip daddr ${cidr} reject comment \"${marker}\"`)
204
+ .join('\n');
205
+ const blockedIpv6 = PRIVATE_IPV6_DESTINATIONS
206
+ .map(cidr => ` iifname \"${config.networkBridge}\" ip6 daddr ${cidr} reject comment \"${marker}\"`)
207
+ .join('\n');
208
+ return `table inet ${table} {\n chain forward { type filter hook forward priority -10; policy accept;\n iifname \"${config.networkBridge}\" ct state established,related accept\n${blockedIpv4}\n${blockedIpv6}\n iifname \"${config.networkBridge}\" oifname \"${config.networkBridge}\" reject comment \"${marker}\"\n oifname \"${config.networkBridge}\" ct state new reject comment \"${marker}\"\n }\n}\n# ${name}\n`;
209
+ }
210
+
211
+ async function inspectNetwork(operation) {
212
+ const inspected = await run(config.nftBinary, ['list', 'table', 'inet', networkTable(operation)]);
213
+ const marker = `yeaft:${operation.sandboxId}:${operation.generation}`;
214
+ if (!String(inspected.stdout).includes(marker)) throw new Error('Sandbox network policy inspection failed');
215
+ }
216
+
217
+ async function applyNetwork(operation, name, policy) {
218
+ await mkdir(policyRoot, { recursive: true, mode: 0o700 });
219
+ const rules = networkPolicy(operation, name);
220
+ await writeFile(policy, rules, { mode: 0o600 });
221
+ await run(config.nftBinary, ['-c', '-f', policy]);
222
+ await run(config.nftBinary, ['-f', policy]);
223
+ await inspectNetwork(operation);
224
+ }
225
+
226
+ function assertMemoryAvailable(operation) {
227
+ const bytes = Number(availableMemoryBytes());
228
+ const availableMiB = Math.floor(bytes / 1024 / 1024);
229
+ if (!Number.isSafeInteger(availableMiB)
230
+ || availableMiB - config.hostMemoryReserveMiB < operation.resources.memoryMiB) {
231
+ const error = new Error('Sandbox runtime memory admission rejected');
232
+ error.code = 'SANDBOX_CAPACITY_UNAVAILABLE';
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ async function create(operation) {
238
+ if (operation.imageDigest !== config.imageDigest) throw new Error('Sandbox runtime rejected an unpinned image');
239
+ const name = sandboxName(operation.sandboxId);
240
+ const target = paths(operation);
241
+ await assertNoSymlink(dataRoot);
242
+ await assertNoSymlink(target.root, { allowMissing: true });
243
+ await mkdir(target.home, { recursive: true, mode: 0o700 });
244
+ await mkdir(target.workspace, { recursive: true, mode: 0o700 });
245
+ await assertNoSymlink(target.home);
246
+ await assertNoSymlink(target.workspace);
247
+ await applyQuota(operation, target.root);
248
+ await applyNetwork(operation, name, target.policy);
249
+
250
+ let inspect = await inspectContainer(name);
251
+ assertMemoryAvailable(operation);
252
+ let secretDirectory = null;
253
+ try {
254
+ if (!inspect) {
255
+ if (!operation.bootstrap?.token || !Number.isFinite(operation.bootstrap.expiresAt)) {
256
+ throw new Error('Sandbox runtime requires a scoped bootstrap envelope');
257
+ }
258
+ secretDirectory = await prepareSecretDirectory(operation);
259
+ const bootstrapPath = join(secretDirectory, 'bootstrap.json');
260
+ const file = await open(
261
+ bootstrapPath,
262
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
263
+ 0o600,
264
+ );
265
+ try {
266
+ await file.writeFile(JSON.stringify({
267
+ serverUrl: config.serverUrl,
268
+ token: operation.bootstrap.token,
269
+ claims: {
270
+ sandboxId: operation.sandboxId,
271
+ instanceId: operation.instanceId,
272
+ generation: operation.generation,
273
+ imageDigest: operation.imageDigest
274
+ }
275
+ }));
276
+ } finally {
277
+ await file.close();
278
+ }
279
+ await run(config.runtimeBinary, [
280
+ 'create', '--name', name,
281
+ '--label', `io.yeaft.sandbox-id=${operation.sandboxId}`,
282
+ '--label', `io.yeaft.instance-id=${operation.instanceId}`,
283
+ '--runtime', config.isolationRuntime || 'runsc', '--read-only',
284
+ '--cap-drop=ALL', '--security-opt=no-new-privileges', '--userns=auto',
285
+ '--user', String(config.containerUid || 10001), '--network', config.networkName,
286
+ '--cpus', String(operation.resources.cpuMillis / 1000),
287
+ '--memory', `${operation.resources.memoryMiB}m`, '--pids-limit', String(config.pidsLimit),
288
+ '--blkio-weight', String(config.ioWeight), '--tmpfs', '/tmp:rw,noexec,nosuid,size=256m',
289
+ '--tmpfs', '/run:rw,noexec,nosuid,size=64m', '--tmpfs', '/dev/shm:rw,noexec,nosuid,size=64m',
290
+ '--mount', `type=bind,src=${target.home},dst=/home/yeaft,rw,nosuid,nodev`,
291
+ '--mount', `type=bind,src=${target.workspace},dst=/workspace,rw,nosuid,nodev`,
292
+ '--mount', `type=bind,src=${bootstrapPath},dst=/run/yeaft/bootstrap.json,ro,nosuid,nodev,noexec`,
293
+ config.imageDigest,
294
+ 'yeaft-agent', 'managed-sandbox', '--bootstrap-file', '/run/yeaft/bootstrap.json'
295
+ ]);
296
+ inspect = await inspectContainer(name);
297
+ }
298
+ if (!inspect || !exactImage(inspect, config.imageDigest)) throw new Error('Sandbox fixed image inspection failed');
299
+ await run(config.runtimeBinary, ['start', name]);
300
+ return await runtimeProof(operation, inspect);
301
+ } finally {
302
+ if (secretDirectory) await rm(secretDirectory, { recursive: true, force: true });
303
+ }
304
+ }
305
+
306
+ async function runtimeProof(operation, inspect) {
307
+ const host = inspect.HostConfig || {};
308
+ const container = inspect.Config || {};
309
+ const cpuMillis = Math.round(Number(host.NanoCpus || 0) / 1_000_000);
310
+ const memoryMiB = Math.round(Number(host.Memory || 0) / 1024 / 1024);
311
+ const pidsLimit = Number(host.PidsLimit || 0);
312
+ const ioWeight = Number(host.BlkioWeight || 0);
313
+ const capDrop = host.CapDrop || [];
314
+ const securityOpt = host.SecurityOpt || [];
315
+ const mounts = inspect.Mounts || [];
316
+ const valid = exactImage(inspect, config.imageDigest)
317
+ && cpuMillis === operation.resources.cpuMillis
318
+ && memoryMiB === operation.resources.memoryMiB
319
+ && pidsLimit === config.pidsLimit && ioWeight === config.ioWeight
320
+ && host.ReadonlyRootfs === true && capDrop.includes('ALL')
321
+ && securityOpt.some(value => String(value).includes('no-new-privileges'))
322
+ && String(host.UsernsMode || '').startsWith('auto')
323
+ && String(container.User || '') === String(config.containerUid || 10001)
324
+ && host.NetworkMode === config.networkName
325
+ && mounts.length >= 3
326
+ && mounts.every(mount => mount.Type === 'bind'
327
+ && (within(dataRoot, mount.Source) || within(secretRoot, mount.Source)));
328
+ if (!valid) throw new Error('Sandbox runtime isolation inspection failed');
329
+ await inspectQuota(operation);
330
+ await inspectNetwork(operation);
331
+ return {
332
+ success: true,
333
+ readinessProof: { image: true, cpu: true, memory: true, pid: true, io: true, quota: true, network: true, credential: true },
334
+ resourceInspection: {
335
+ cpuMillis, memoryMiB, diskGiB: operation.resources.diskGiB,
336
+ pidsLimit, ioWeight, quotaHard: true, networkPolicy: 'public-egress-isolated'
337
+ }
338
+ };
339
+ }
340
+
341
+ async function execute(operation) {
342
+ const name = sandboxName(operation.sandboxId);
343
+ const target = paths(operation);
344
+ if (operation.action === 'create' || operation.action === 'retry') return create(operation);
345
+ if (operation.action === 'start') {
346
+ const inspect = await inspectContainer(name);
347
+ if (!inspect) throw new Error('Sandbox container is absent');
348
+ assertMemoryAvailable(operation);
349
+ await run(config.runtimeBinary, ['start', name]);
350
+ return runtimeProof(operation, inspect);
351
+ }
352
+ if (operation.action === 'stop') {
353
+ const inspect = await inspectContainer(name);
354
+ if (!inspect) throw new Error('Sandbox container is absent');
355
+ await run(config.runtimeBinary, ['stop', '--time', String(config.stopTimeoutSeconds || 20), name]);
356
+ return runtimeProof(operation, inspect);
357
+ }
358
+ if (operation.action === 'remove') {
359
+ await run(config.runtimeBinary, ['rm', '--force', '--ignore', name]);
360
+ const table = networkTable(operation);
361
+ await run(config.nftBinary, ['delete', 'table', 'inet', table]).catch(() => {});
362
+ await rm(target.policy, { force: true });
363
+ const projectId = quotaProjectId(operation);
364
+ await run(config.xfsQuotaBinary, ['-x', '-c', `project -C -p ${target.root}`, dataRoot]);
365
+ await rm(target.root, { recursive: true, force: true });
366
+ const inspect = await inspectContainer(name);
367
+ let storageAbsent = false;
368
+ try { await lstat(target.root); } catch (error) { storageAbsent = error.code === 'ENOENT'; }
369
+ const quota = await run(config.xfsQuotaBinary, ['-x', '-c', `report -p -n -N ${projectId}`, dataRoot]);
370
+ let networkAbsent = false;
371
+ try {
372
+ await run(config.nftBinary, ['list', 'table', 'inet', table]);
373
+ } catch {
374
+ networkAbsent = true;
375
+ }
376
+ if (inspect || !storageAbsent || String(quota.stdout).includes(String(projectId)) || !networkAbsent) {
377
+ throw new Error('Sandbox resource removal inspection failed');
378
+ }
379
+ return { success: true, absenceProof: { container: true, storage: true, quota: true, network: true, credential: true } };
380
+ }
381
+ throw new Error('Sandbox runtime rejected an unsupported lifecycle action');
382
+ }
383
+
384
+ return { execute };
385
+ }
386
+
387
+ export { commandRunner, sandboxName };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.372",
3
+ "version": "1.0.374",
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",