@desplega.ai/agent-env 0.0.0-stage → 0.4.0

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +364 -2
  3. package/assets/icon.svg +5 -0
  4. package/bin/agent-env.mjs +214 -0
  5. package/docs/agent-swarm.md +146 -0
  6. package/docs/releases.md +210 -0
  7. package/docs/screenshots/dashboard-desktop.png +0 -0
  8. package/docs/screenshots/dashboard-mobile.png +0 -0
  9. package/docs/snapshots-and-worktrees.md +221 -0
  10. package/examples/agent-swarm/README.md +5 -0
  11. package/examples/agent-swarm/agent-env.json +11 -0
  12. package/examples/agent-swarm/compose.agent-env.yaml +101 -0
  13. package/examples/agent-swarm/verify.mjs +55 -0
  14. package/examples/compose/.dockerignore +3 -0
  15. package/examples/compose/Dockerfile +4 -0
  16. package/examples/compose/README.md +109 -0
  17. package/examples/compose/compose.yaml +36 -0
  18. package/examples/compose/server.mjs +6 -0
  19. package/examples/hello/agent-env.json +20 -0
  20. package/examples/hello/index.html +12 -0
  21. package/package.json +50 -4
  22. package/skills/agent-env/SKILL.md +156 -0
  23. package/skills/agent-env/references/remote-and-data.md +140 -0
  24. package/src/cloud.mjs +314 -0
  25. package/src/config.mjs +133 -0
  26. package/src/daemon.mjs +484 -0
  27. package/src/environments.mjs +207 -0
  28. package/src/goodies.mjs +71 -0
  29. package/src/init.mjs +68 -0
  30. package/src/locks.mjs +56 -0
  31. package/src/maintenance.mjs +74 -0
  32. package/src/paths.mjs +5 -0
  33. package/src/providers/CLOUD_SANDBOX.md +240 -0
  34. package/src/providers/README.md +62 -0
  35. package/src/providers/compose.mjs +460 -0
  36. package/src/providers/docker.mjs +207 -0
  37. package/src/providers/index.mjs +13 -0
  38. package/src/providers/sandbox.mjs +170 -0
  39. package/src/proxy.mjs +149 -0
  40. package/src/runtime.mjs +83 -0
  41. package/src/skill.mjs +51 -0
  42. package/src/snapshots.mjs +394 -0
  43. package/src/source-paths.mjs +15 -0
  44. package/src/system.mjs +39 -0
  45. package/src/tls.mjs +57 -0
  46. package/src/ui-client.js +51 -0
  47. package/src/ui.html +12 -0
  48. package/src/ui.mjs +32 -0
  49. package/src/watch.mjs +20 -0
  50. package/src/worktrees.mjs +154 -0
package/src/skill.mjs ADDED
@@ -0,0 +1,51 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const directory = new URL('../skills/agent-env/', import.meta.url);
6
+ const help = `agent-env skill: instructions for coding agents
7
+
8
+ agent-env skill Print the bundled guide and reference
9
+ agent-env skill --json Print the guide as JSON
10
+ agent-env skill install Install the bundled skill into this project
11
+ agent-env skill install --agent codex --yes
12
+ agent-env skill install --agent claude-code --global
13
+
14
+ Install options:
15
+ --agent NAME Select an agent (repeat for multiple agents)
16
+ -g, --global Install across projects
17
+ -y, --yes Accept the skills installer's prompts
18
+
19
+ Reading needs no network, Docker, or daemon.
20
+ Installation runs npx skills@1.7.6 and requires Node 22.20+ and npm.
21
+ The installer uses the skill files bundled with this CLI version.
22
+ `;
23
+
24
+ export async function skillCommand(positionals, values, command) {
25
+ if (values.help) { console.log(help); return; }
26
+ const [action] = positionals;
27
+ if (positionals.length > 1 || command.length || action && action !== 'install') {
28
+ throw new Error('Use agent-env skill or agent-env skill install.');
29
+ }
30
+ const allowed = action ? ['agent', 'global', 'yes'] : ['json'];
31
+ for (const key of Object.keys(values)) {
32
+ if (!allowed.includes(key)) throw new Error(`Option --${key} is not supported here. Run agent-env skill --help.`);
33
+ }
34
+ if (!action) {
35
+ const guide = await readFile(new URL('SKILL.md', directory), 'utf8');
36
+ const reference = await readFile(new URL('references/remote-and-data.md', directory), 'utf8');
37
+ const content = `${guide.replace('(references/remote-and-data.md)', '(#remote-services-and-data-operations)')}\n${reference}`;
38
+ if (values.json) console.log(JSON.stringify({ name: 'agent-env', content }, null, 2));
39
+ else process.stdout.write(content);
40
+ return;
41
+ }
42
+ const args = ['--yes', 'skills@1.7.6', 'add', fileURLToPath(directory), '--skill', 'agent-env'];
43
+ for (const agent of values.agent ?? []) args.push('--agent', agent);
44
+ if (values.global) args.push('--global');
45
+ if (values.yes) args.push('--yes');
46
+ process.exitCode = await new Promise((resolve, reject) => {
47
+ const child = spawn('npx', args, { stdio: 'inherit' });
48
+ child.once('error', error => reject(new Error(`Cannot start npx: ${error.message}. Install npm, then retry.`)));
49
+ child.once('close', code => resolve(code ?? 1));
50
+ });
51
+ }
@@ -0,0 +1,394 @@
1
+ import { createReadStream, createWriteStream } from 'node:fs';
2
+ import { mkdir, mkdtemp, readFile, writeFile, rm, stat, open } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, resolve, posix } from 'node:path';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { run } from './system.mjs';
8
+
9
+ const FORMAT = 'agent-env-snapshot';
10
+ const VERSION = 1;
11
+ const IMAGE = 'busybox:1.37';
12
+ const VOLUME_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
13
+
14
+ function locationInfo(location) {
15
+ if (typeof location !== 'string' || !location) throw new Error('Specify a snapshot directory or an S3 URL.');
16
+ if (!location.startsWith('s3://')) return { directory: resolve(location) };
17
+ const match = /^s3:\/\/([^/]+)\/(.+?)\/*$/.exec(location);
18
+ if (!match || /[\r\n?#]/.test(location) || match[2].split('/').some(part => part === '..' || part === '.')) throw new Error('Use s3://bucket/nonempty-prefix without query parameters.');
19
+ return { bucket: match[1], prefix: `${match[2]}/` };
20
+ }
21
+
22
+ function aws(options, args) {
23
+ return run(options.awsCommand ?? 'aws', [
24
+ ...(options.endpointUrl ? ['--endpoint-url', options.endpointUrl] : []),
25
+ '--no-cli-pager', 's3api', ...args,
26
+ ], { env: { ...process.env, ...options.awsEnv, AWS_PAGER: '' } });
27
+ }
28
+
29
+ async function matchingContainers(provider, spec, filter) {
30
+ const ids = (await provider.docker(spec, ['container', 'ls', '-aq', '--filter', filter])).split(/\s+/).filter(Boolean);
31
+ if (!ids.length) return [];
32
+ try { return JSON.parse(await provider.docker(spec, ['inspect', ...ids])); }
33
+ catch {
34
+ const containers = [];
35
+ for (const id of ids) {
36
+ try { containers.push(...JSON.parse(await provider.docker(spec, ['inspect', id]))); }
37
+ catch (error) {
38
+ // An unrelated temporary container can disappear between listing and inspection.
39
+ const remaining = await provider.docker(spec, ['container', 'ls', '-aq', '--filter', `id=${id}`]);
40
+ if (remaining.trim()) throw error;
41
+ }
42
+ }
43
+ return containers;
44
+ }
45
+ }
46
+
47
+ function serviceContainers(service) {
48
+ return service.containers ?? (service.container ? [service.container] : []);
49
+ }
50
+
51
+ function checkStopped(info) {
52
+ if (info.State?.Running || info.State?.Restarting || info.State?.Paused) throw new Error(`Stop service container ${info.Name ?? info.Id} before snapshot or restore.`);
53
+ if (!['exited', 'created', 'dead'].includes(info.State?.Status)) throw new Error('The service state is unknown. Check the Docker host before snapshot or restore.');
54
+ }
55
+
56
+ async function discover(handle, provider, options) {
57
+ const entries = Object.entries(handle.services ?? {});
58
+ if (!entries.length) throw new Error('The handle must identify its service containers.');
59
+ const containers = [];
60
+ for (const [service, value] of entries) {
61
+ const ids = serviceContainers(value);
62
+ if (!Array.isArray(ids) || !ids.length || ids.some(id => typeof id !== 'string' || !id)) throw new Error(`Service ${service} must identify its container or containers.`);
63
+ for (const info of JSON.parse(await provider.docker(handle.spec, ['inspect', ...ids]))) {
64
+ checkStopped(info);
65
+ containers.push({ service, info });
66
+ }
67
+ }
68
+ const ownIds = new Set(containers.map(({ info }) => info.Id));
69
+ const volumeUsers = new Map();
70
+ const volumes = new Map(), omittedMounts = [];
71
+ for (const { service, info } of containers) {
72
+ for (const mount of info.Mounts ?? []) {
73
+ const omit = reason => {
74
+ omittedMounts.push({ service, target: mount.Destination, type: mount.Type, reason });
75
+ };
76
+ if (mount.Type === 'bind' && (mount.Source === (handle.workspace ?? handle.spec.cwd) || mount.Source?.startsWith(`${handle.workspace ?? handle.spec.cwd}/`))) {
77
+ omit('Source files require a separate Git worktree or source synchronization.');
78
+ continue;
79
+ }
80
+ let reason;
81
+ if (mount.Type !== 'volume' || !mount.Name) reason = 'This mount is not a managed named volume.';
82
+ else {
83
+ const volume = JSON.parse(await provider.docker(handle.spec, ['volume', 'inspect', mount.Name]))[0];
84
+ const labels = volume.Labels ?? {};
85
+ const project = info.Config?.Labels?.['com.docker.compose.project'];
86
+ const managed = labels['agent-env.owner'] && labels['agent-env.owner'] === handle.spec.owner
87
+ || project && labels['com.docker.compose.project'] === project;
88
+ if (!managed) reason = 'This volume is external or has no matching ownership label.';
89
+ else if (volume.Driver !== 'local' || Object.keys(volume.Options ?? {}).length || volume.Scope !== 'local') reason = 'This volume uses an external driver or mount options.';
90
+ else {
91
+ if (!volumeUsers.has(mount.Name)) volumeUsers.set(mount.Name, await matchingContainers(provider, handle.spec, `volume=${mount.Name}`));
92
+ if (volumeUsers.get(mount.Name).some(other => !ownIds.has(other.Id) && (other.Mounts ?? []).some(item => item.Type === 'volume' && item.Name === mount.Name))) reason = 'Another container shares this volume.';
93
+ }
94
+ }
95
+ if (!reason && provider.snapshotVolumeNames) {
96
+ try { await provider.snapshotVolumeNames(handle.spec, { volumes: [{ id: 'check', mounts: [{ service, target: mount.Destination }] }] }); }
97
+ catch (error) { reason = error.message; }
98
+ }
99
+ if (reason) {
100
+ if (!options.omitUnsupportedMounts) throw new Error(`${service}:${mount.Destination}: ${reason} Create a separate backup for this mount or select omitUnsupportedMounts.`);
101
+ omit(reason);
102
+ continue;
103
+ }
104
+ let volume = volumes.get(mount.Name);
105
+ if (!volume) {
106
+ volume = { id: `v${volumes.size}`, sourceName: mount.Name, archive: `v${volumes.size}.tar`, mounts: [] };
107
+ volumes.set(mount.Name, volume);
108
+ }
109
+ const key = Object.entries(handle.spec.services?.[service]?.volumes ?? {}).find(([, target]) => target === mount.Destination)?.[0];
110
+ volume.mounts.push({ service, target: mount.Destination, ...(key ? { key } : {}) });
111
+ }
112
+ }
113
+ return { volumes: [...volumes.values()], omittedMounts };
114
+ }
115
+
116
+ async function streamDocker(provider, spec, args, { input, output }) {
117
+ const file = input ? await open(input, 'r') : await open(output, 'wx', 0o600);
118
+ try {
119
+ // Redirect bytes through OS file descriptors. The provider only captures text on stderr.
120
+ await provider.docker(spec, args, { stdio: [input ? file.fd : 'ignore', output ? file.fd : 'ignore', 'pipe'] });
121
+ } finally { await file.close(); }
122
+ }
123
+
124
+ async function archiveVolume(provider, spec, volume, archive, options, restore = false) {
125
+ const token = randomUUID();
126
+ const name = `agent-env-snapshot-${token}`;
127
+ const args = ['run', '--rm', ...(restore ? ['-i'] : []), '--name', name, '--label', `agent-env.snapshot=${token}`, '--network', 'none', '--read-only', '--mount', `type=volume,src=${volume},dst=/data,volume-nocopy${restore ? '' : ',readonly'}`, options.image ?? IMAGE, 'tar', '-C', '/data', restore ? '-xf' : '-cf', '-', ...(restore ? [] : ['.'])];
128
+ try { await streamDocker(provider, spec, args, restore ? { input: archive } : { output: archive }); }
129
+ finally {
130
+ // A failed transport can leave a helper on the remote Docker host.
131
+ const info = await provider.docker(spec, ['inspect', name]).then(value => JSON.parse(value)[0], () => null);
132
+ if (info?.Config?.Labels?.['agent-env.snapshot'] === token) await provider.docker(spec, ['rm', '-f', name]);
133
+ }
134
+ }
135
+
136
+ function tarString(buffer) { return buffer.subarray(0, buffer.indexOf(0) < 0 ? buffer.length : buffer.indexOf(0)).toString('utf8'); }
137
+
138
+ function archivePath(value) {
139
+ if (!value || value.includes('\\') || value.includes('\ufffd') || posix.isAbsolute(value) || value.split('/').includes('..')) throw new Error('The archive contains an unsafe extraction path.');
140
+ return posix.normalize(value).replace(/\/$/, '');
141
+ }
142
+
143
+ // Validate tar records without buffering file bodies or invoking an extractor.
144
+ async function verifyTar(file) {
145
+ let pending = Buffer.alloc(0), remaining = 0, zeros = 0, ended = false;
146
+ let extension, longName, longLink;
147
+ const paths = new Map();
148
+ for await (const chunk of createReadStream(file)) {
149
+ pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
150
+ while (pending.length) {
151
+ if (remaining) {
152
+ const consumed = Math.min(remaining, pending.length);
153
+ if (extension && extension.read < extension.size) {
154
+ const size = Math.min(consumed, extension.size - extension.read);
155
+ extension.chunks.push(Buffer.from(pending.subarray(0, size)));
156
+ extension.read += size;
157
+ }
158
+ remaining -= consumed;
159
+ pending = pending.subarray(consumed);
160
+ if (!remaining && extension) {
161
+ const body = Buffer.concat(extension.chunks);
162
+ if (body.indexOf(0) !== body.length - 1) throw new Error('The archive contains an invalid GNU path extension.');
163
+ const value = tarString(body);
164
+ if (extension.type === 'L') longName = value;
165
+ else longLink = value;
166
+ extension = undefined;
167
+ }
168
+ continue;
169
+ }
170
+ if (pending.length < 512) break;
171
+ const header = pending.subarray(0, 512);
172
+ pending = pending.subarray(512);
173
+ if (header.every(byte => byte === 0)) { zeros++; ended = true; continue; }
174
+ if (ended) throw new Error('The archive contains data after its end marker.');
175
+ let sum = 0;
176
+ for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : header[i];
177
+ const checksum = tarString(header.subarray(148, 156)).trim();
178
+ const sizeField = tarString(header.subarray(124, 136)).trim();
179
+ if (!/^[0-7]+$/.test(checksum) || parseInt(checksum, 8) !== sum || !/^[0-7]+$/.test(sizeField)) throw new Error('The archive contains an invalid tar header.');
180
+ const size = parseInt(sizeField, 8);
181
+ if (!Number.isSafeInteger(size)) throw new Error('The archive file size is not supported.');
182
+ const magic = header.subarray(257, 265).toString('latin1');
183
+ const gnu = magic === 'ustar \0';
184
+ if (magic !== `ustar${String.fromCharCode(0)}00` && !gnu) throw new Error('The archive tar format is not supported.');
185
+ if (gnu && header.subarray(345, 500).some(byte => byte !== 0)) throw new Error('The archive contains unsupported GNU metadata.');
186
+ const type = String.fromCharCode(header[156] || 48);
187
+ if (['L', 'K'].includes(type)) {
188
+ if (size < 1 || size > 1024 * 1024 || type === 'L' && longName !== undefined || type === 'K' && longLink !== undefined) throw new Error('The archive contains an invalid GNU path extension.');
189
+ extension = { type, size, read: 0, chunks: [] };
190
+ remaining = Math.ceil(size / 512) * 512;
191
+ continue;
192
+ }
193
+ if (!['0', '1', '2', '5'].includes(type)) throw new Error('The archive contains an unsupported entry. Only files, directories, and internal links are supported.');
194
+ if (type !== '0' && size !== 0) throw new Error('The archive contains an invalid directory or link size.');
195
+ const prefix = gnu ? '' : tarString(header.subarray(345, 500));
196
+ const rawName = tarString(header.subarray(0, 100));
197
+ archivePath(rawName);
198
+ if (prefix) archivePath(prefix);
199
+ const path = archivePath(longName ?? `${prefix ? `${prefix}/` : ''}${rawName}`);
200
+ const target = longLink ?? tarString(header.subarray(157, 257));
201
+ longName = undefined; longLink = undefined;
202
+ if (path === '.' && type !== '5') throw new Error('The archive root must be a directory.');
203
+ if (paths.has(path)) throw new Error('The archive contains duplicate extraction paths.');
204
+ if (type === '1') {
205
+ const linked = paths.get(archivePath(target));
206
+ if (!linked || !['0', '1'].includes(linked.type)) throw new Error('The archive hard link does not reference an earlier regular file.');
207
+ }
208
+ if (type === '2') {
209
+ if (!target || posix.isAbsolute(target) || target.includes('\\') || target.includes('\ufffd')) throw new Error('The archive contains an unsafe symbolic link.');
210
+ }
211
+ paths.set(path, { type, target });
212
+ remaining = Math.ceil(size / 512) * 512;
213
+ }
214
+ }
215
+ if (remaining || pending.length || zeros < 2 || longName !== undefined || longLink !== undefined) throw new Error('The archive is truncated.');
216
+ const resolveLink = (target, base, visited) => {
217
+ const stack = [...base];
218
+ for (const part of target.split('/')) {
219
+ if (part === '.' || part === '') continue;
220
+ if (part === '..') {
221
+ if (!stack.length) throw new Error('The symbolic link leaves the volume.');
222
+ stack.pop();
223
+ continue;
224
+ }
225
+ stack.push(part);
226
+ const path = stack.join('/'), entry = paths.get(path);
227
+ if (entry?.type === '2') {
228
+ if (visited.has(path) || visited.size > 64) throw new Error('The archive contains a symbolic link cycle or an excessive link chain.');
229
+ const resolved = resolveLink(entry.target, stack.slice(0, -1), new Set([...visited, path]));
230
+ stack.splice(0, stack.length, ...resolved);
231
+ }
232
+ }
233
+ return stack;
234
+ };
235
+ for (const [path, entry] of paths) {
236
+ if (entry.type === '2') resolveLink(entry.target, posix.dirname(path).split('/').filter(part => part !== '.'), new Set([path]));
237
+ let ancestor = posix.dirname(path);
238
+ while (ancestor !== '.') {
239
+ if (paths.has(ancestor) && paths.get(ancestor).type !== '5') throw new Error('An archive path traverses a symbolic link or file.');
240
+ ancestor = posix.dirname(ancestor);
241
+ }
242
+ }
243
+ }
244
+
245
+ async function digest(file) {
246
+ const hash = createHash('sha256');
247
+ let bytes = 0;
248
+ for await (const chunk of createReadStream(file)) { hash.update(chunk); bytes += chunk.length; }
249
+ return { sha256: hash.digest('hex'), bytes };
250
+ }
251
+
252
+ function validateManifest(manifest) {
253
+ if (manifest.format !== FORMAT || manifest.version !== VERSION || !Array.isArray(manifest.volumes) || !Array.isArray(manifest.omittedMounts)) throw new Error('The snapshot manifest format or version is not supported.');
254
+ const ids = new Set(), archives = new Set(), sources = new Set();
255
+ for (const volume of manifest.volumes) {
256
+ if (!/^v\d+$/.test(volume.id) || volume.archive !== `${volume.id}.tar` || !VOLUME_NAME.test(volume.sourceName)
257
+ || ids.has(volume.id) || archives.has(volume.archive) || sources.has(volume.sourceName)
258
+ || !Number.isSafeInteger(volume.bytes) || volume.bytes < 1024 || !/^[a-f0-9]{64}$/.test(volume.sha256)
259
+ || !Array.isArray(volume.mounts) || !volume.mounts.length
260
+ || volume.mounts.some(mount => typeof mount.service !== 'string' || typeof mount.target !== 'string' || !mount.target.startsWith('/'))) throw new Error('The snapshot contains an invalid volume record.');
261
+ ids.add(volume.id); archives.add(volume.archive); sources.add(volume.sourceName);
262
+ }
263
+ return manifest;
264
+ }
265
+
266
+ /** Export stopped, managed Docker volumes to a new local directory or S3 prefix. */
267
+ export async function snapshotInstance(handle, location, provider, options = {}) {
268
+ const storage = locationInfo(location);
269
+ const discovered = await discover(handle, provider, options);
270
+ let directory, owned = false;
271
+ try {
272
+ if (storage.bucket) {
273
+ const existing = JSON.parse(await aws(options, ['list-objects-v2', '--bucket', storage.bucket, '--prefix', storage.prefix, '--max-keys', '1', '--output', 'json']));
274
+ if (existing.Contents?.length) throw new Error('The S3 snapshot prefix already contains data. Choose a new prefix.');
275
+ directory = await mkdtemp(join(tmpdir(), 'agent-env-snapshot-'));
276
+ } else {
277
+ directory = storage.directory;
278
+ await mkdir(directory, { mode: 0o700 });
279
+ }
280
+ owned = true;
281
+ const manifest = {
282
+ format: FORMAT, version: VERSION, createdAt: new Date().toISOString(),
283
+ source: { name: handle.spec.name, owner: handle.spec.owner, provider: handle.spec.provider },
284
+ volumes: discovered.volumes, omittedMounts: discovered.omittedMounts,
285
+ };
286
+ for (const volume of manifest.volumes) {
287
+ const file = join(directory, volume.archive);
288
+ await archiveVolume(provider, handle.spec, volume.sourceName, file, options);
289
+ await verifyTar(file);
290
+ Object.assign(volume, await digest(file));
291
+ }
292
+ // Reject a snapshot if a service restarted during export.
293
+ await discover(handle, provider, options);
294
+ await writeFile(join(directory, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
295
+ if (storage.bucket) {
296
+ // Conditional writes prevent races from overwriting another writer's objects.
297
+ for (const name of [...manifest.volumes.map(volume => volume.archive), 'manifest.json']) {
298
+ await aws(options, ['put-object', '--bucket', storage.bucket, '--key', `${storage.prefix}${name}`, '--body', join(directory, name), '--if-none-match', '*']);
299
+ }
300
+ }
301
+ return { location, manifest };
302
+ } catch (error) {
303
+ if (owned && !storage.bucket) await rm(directory, { recursive: true, force: true });
304
+ throw error;
305
+ } finally {
306
+ if (owned && storage.bucket) await rm(directory, { recursive: true, force: true });
307
+ }
308
+ }
309
+
310
+ async function destinationVolumes(spec, manifest, provider, options) {
311
+ const names = new Set();
312
+ const mapped = options.volumeNames ?? await provider.snapshotVolumeNames?.(spec, manifest);
313
+ return manifest.volumes.map(volume => {
314
+ let name = mapped?.[volume.id];
315
+ if (!name) {
316
+ const destinations = volume.mounts.map(mount => {
317
+ const key = Object.entries(spec.services?.[mount.service]?.volumes ?? {}).find(([, target]) => target === mount.target)?.[0];
318
+ return key && typeof provider.volume === 'function' ? provider.volume(spec, mount.service, key) : undefined;
319
+ });
320
+ if (destinations.some(item => !item) || new Set(destinations).size !== 1) throw new Error(`Set volumeNames.${volume.id} to the destination volume name. Match all mounts of this volume.`);
321
+ name = destinations[0];
322
+ }
323
+ if (!VOLUME_NAME.test(name) || names.has(name)) throw new Error('Destination volumes must have distinct valid Docker volume names.');
324
+ names.add(name);
325
+ return { ...volume, destinationName: name };
326
+ });
327
+ }
328
+
329
+ /** Restore verified archives into new volumes. This function never starts services. */
330
+ export async function restoreSnapshot(spec, location, provider, options = {}) {
331
+ const storage = locationInfo(location);
332
+ const directory = await mkdtemp(join(tmpdir(), 'agent-env-restore-'));
333
+ const created = [], token = randomUUID();
334
+ try {
335
+ const fetch = async name => {
336
+ const file = join(directory, name);
337
+ if (storage.bucket) await aws(options, ['get-object', '--bucket', storage.bucket, '--key', `${storage.prefix}${name}`, file]);
338
+ else await pipeline(createReadStream(join(storage.directory, name)), createWriteStream(file, { flags: 'wx', mode: 0o600 }));
339
+ return file;
340
+ };
341
+ const manifestFile = await fetch('manifest.json');
342
+ if ((await stat(manifestFile)).size > 4 * 1024 * 1024) throw new Error('The snapshot manifest exceeds 4 MiB.');
343
+ const manifest = validateManifest(JSON.parse(await readFile(manifestFile, 'utf8')));
344
+ // Verify every archive before allocating any destination resource.
345
+ for (const volume of manifest.volumes) {
346
+ const file = await fetch(volume.archive);
347
+ const actual = await digest(file);
348
+ if (actual.sha256 !== volume.sha256 || actual.bytes !== volume.bytes) throw new Error(`Checksum or size mismatch for ${volume.archive}.`);
349
+ await verifyTar(file);
350
+ }
351
+ if (options.destinationHandle) await discover(options.destinationHandle, provider, { omitUnsupportedMounts: true });
352
+ const volumes = await destinationVolumes(spec, manifest, provider, options);
353
+ const prefix = typeof provider.prefix === 'function' ? provider.prefix(spec) : undefined;
354
+ const containers = [
355
+ ...(prefix ? await matchingContainers(provider, spec, `name=${prefix}`) : []),
356
+ ...(options.composeProject ? await matchingContainers(provider, spec, `label=com.docker.compose.project=${options.composeProject}`) : []),
357
+ ];
358
+ for (const info of containers) {
359
+ if (prefix && (info.Name === `/${prefix}` || info.Name?.startsWith(`/${prefix}-`))
360
+ || options.composeProject && info.Config?.Labels?.['com.docker.compose.project'] === options.composeProject) checkStopped(info);
361
+ }
362
+ const existing = new Set((await provider.docker(spec, ['volume', 'ls', '-q'])).split(/\s+/).filter(Boolean));
363
+ for (const volume of volumes) if (existing.has(volume.destinationName)) throw new Error(`Volume ${volume.destinationName} already exists. Restore requires new destination volumes.`);
364
+ for (const volume of volumes) {
365
+ // Docker volume create is idempotent. A unique claim label detects a concurrent creator.
366
+ created.push(volume.destinationName);
367
+ const labels = provider.snapshotVolumeLabels?.(spec, volume.destinationName) ?? {};
368
+ await provider.docker(spec, ['volume', 'create', '--label', `agent-env.owner=${spec.owner}`, '--label', `agent-env.restore=${token}`,
369
+ ...Object.entries(labels).flatMap(([key, value]) => ['--label', `${key}=${value}`]), volume.destinationName]);
370
+ const info = JSON.parse(await provider.docker(spec, ['volume', 'inspect', volume.destinationName]))[0];
371
+ if (info.Labels?.['agent-env.restore'] !== token) throw new Error('Another operation created the destination volume. Restore stopped without modifying it.');
372
+ await archiveVolume(provider, spec, volume.destinationName, join(directory, volume.archive), options, true);
373
+ }
374
+ return { location, manifest, volumes, restoreId: token, volumeNames: Object.fromEntries(volumes.map(volume => [volume.id, volume.destinationName])) };
375
+ } catch (error) {
376
+ const cleanupErrors = [];
377
+ for (const name of created.reverse()) {
378
+ try {
379
+ const info = JSON.parse(await provider.docker(spec, ['volume', 'inspect', name]))[0];
380
+ if (info.Labels?.['agent-env.restore'] === token) await provider.docker(spec, ['volume', 'rm', name]);
381
+ } catch (cleanupError) { cleanupErrors.push(cleanupError); }
382
+ }
383
+ if (cleanupErrors.length) error.cleanupErrors = cleanupErrors;
384
+ throw error;
385
+ } finally { await rm(directory, { recursive: true, force: true }); }
386
+ }
387
+
388
+ export async function disposeRestoredVolumes(result, spec, provider) {
389
+ for (const volume of result.volumes) {
390
+ const info = JSON.parse(await provider.docker(spec, ['volume', 'inspect', volume.destinationName]))[0];
391
+ if (info.Labels?.['agent-env.restore'] !== result.restoreId) throw new Error('The restored volume ownership changed. Cleanup stopped.');
392
+ await provider.docker(spec, ['volume', 'rm', volume.destinationName]);
393
+ }
394
+ }
@@ -0,0 +1,15 @@
1
+ // Unanchored rules apply at every depth. Leading slashes anchor rules to the checkout root.
2
+ export const sourceExclusions = Object.freeze(['.git', 'node_modules', '.agent-env', '.env', '.env.*', '/.next', '/dist', '/build', '/coverage', '/.cache']);
3
+ export const worktreeExclusions = Object.freeze(['/vendor', '/target', '/.nuxt', '/.turbo', '/.venv', '/venv', '/__pycache__']);
4
+
5
+ export function shouldSync(file, exclusions = []) {
6
+ if (!file) return true;
7
+ const path = String(file).replaceAll('\\', '/').replace(/^\.\//, '');
8
+ const parts = path.split('/');
9
+ const excluded = [...sourceExclusions, ...exclusions].some(rule => {
10
+ if (rule.startsWith('/')) return path === rule.slice(1) || path.startsWith(`${rule.slice(1)}/`);
11
+ if (rule.endsWith('*')) return parts.some(part => part.startsWith(rule.slice(0, -1)));
12
+ return parts.includes(rule) || path.startsWith(`${rule}/`);
13
+ });
14
+ return !excluded;
15
+ }
package/src/system.mjs ADDED
@@ -0,0 +1,39 @@
1
+ import { spawn } from 'node:child_process';
2
+ import net from 'node:net';
3
+ import { once } from 'node:events';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+
6
+ export { delay };
7
+ export const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
8
+ export function run(command, args, options = {}) {
9
+ return new Promise((resolve, reject) => {
10
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], ...options });
11
+ let stdout = '', stderr = '';
12
+ child.stdout?.on('data', data => { stdout += data; });
13
+ child.stderr?.on('data', data => { stderr += data; if (options.combined) stdout += data; });
14
+ child.on('error', reject);
15
+ child.on('close', code => {
16
+ if (code === 0) return resolve(stdout.trimEnd());
17
+ const output = options.errorStdout === false ? stderr.trimEnd()
18
+ : options.combined ? stdout.trimEnd() : [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join('\n');
19
+ reject(Object.assign(new Error(`${command} failed (${code}): ${output}`), { exitCode: code ?? 1 }));
20
+ });
21
+ });
22
+ }
23
+ export async function freePort() {
24
+ const server = net.createServer();
25
+ server.listen(0, '127.0.0.1');
26
+ await once(server, 'listening');
27
+ const port = server.address().port;
28
+ await new Promise(resolve => server.close(resolve));
29
+ return port;
30
+ }
31
+ export async function reachable(port) {
32
+ return new Promise(resolve => {
33
+ const socket = net.connect(port, '127.0.0.1');
34
+ const finish = value => { socket.destroy(); resolve(value); };
35
+ socket.setTimeout(500, () => finish(false));
36
+ socket.on('connect', () => finish(true));
37
+ socket.on('error', () => finish(false));
38
+ });
39
+ }
package/src/tls.mjs ADDED
@@ -0,0 +1,57 @@
1
+ import { mkdir, readFile, writeFile, chmod, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { homedir, platform } from 'node:os';
4
+ import { run } from './system.mjs';
5
+ import { home } from './paths.mjs';
6
+ import { createPrivateKey, createPublicKey, X509Certificate } from 'node:crypto';
7
+ import { withFileLock } from './locks.mjs';
8
+
9
+ export async function ensureTls(directory = join(home, 'tls'), hosts = []) {
10
+ if (hosts.some(host => !/^[a-z0-9][a-z0-9-]{0,62}\.localhost$/.test(host))) throw new Error('TLS names must be valid localhost hostnames.');
11
+ hosts = [...new Set(hosts)].sort();
12
+ await mkdir(directory, { recursive: true, mode: 0o700 });
13
+ await chmod(directory, 0o700);
14
+ return withFileLock(join(directory, 'generation.lock'), () => generateTls(directory, hosts), { waitMs: 15000 });
15
+ }
16
+ async function generateTls(directory, hosts) {
17
+ const ca = join(directory, 'ca.pem'), caKey = join(directory, 'ca.key');
18
+ const cert = join(directory, 'server.pem'), key = join(directory, 'server.key');
19
+ const exists = path => readFile(path).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; });
20
+ if (await exists(ca) !== await exists(caKey)) throw new Error('The local CA is incomplete. Restore its certificate and key before enabling HTTPS.');
21
+ const config = join(directory, 'openssl.cnf');
22
+ await writeFile(config, `[req]\ndistinguished_name=dn\nx509_extensions=ca\nprompt=no\n[dn]\nCN=agent-env local development\n[ca]\nbasicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign\nnameConstraints=critical,permitted;DNS:localhost,permitted;DNS:.localhost,permitted;IP:127.0.0.1/255.255.255.255,permitted;IP:::1/ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff\n[server]\nbasicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1,IP:::1${hosts.map(host => `,DNS:${host}`).join('')}\n`, { mode: 0o600 });
23
+ try {
24
+ if (!await exists(ca)) {
25
+ await run('openssl', ['ecparam', '-genkey', '-name', 'prime256v1', '-noout', '-out', caKey]);
26
+ await chmod(caKey, 0o600);
27
+ await run('openssl', ['req', '-new', '-x509', '-sha256', '-key', caKey, '-out', ca, '-days', '3650', '-config', config]);
28
+ }
29
+ const authority = new X509Certificate(await readFile(ca));
30
+ const publicKey = createPublicKey(createPrivateKey(await readFile(caKey))).export({ type: 'spki', format: 'der' });
31
+ if (!authority.publicKey.export({ type: 'spki', format: 'der' }).equals(publicKey)) throw new Error('The local CA certificate and key do not match. Restore the matching pair before enabling HTTPS.');
32
+ await run('openssl', ['x509', '-checkend', '86400', '-noout', '-in', ca]).catch(() => { throw new Error('The local CA is expired. Replace it and repeat agent-env trust.'); });
33
+ const oldNames = await readFile(join(directory, 'names.json'), 'utf8').catch(() => '');
34
+ const valid = oldNames === JSON.stringify(hosts) && await exists(key) && await exists(cert) && await run('openssl', ['x509', '-checkend', '86400', '-noout', '-in', cert]).then(() => true, () => false);
35
+ if (!valid) {
36
+ await run('openssl', ['ecparam', '-genkey', '-name', 'prime256v1', '-noout', '-out', key]);
37
+ await chmod(key, 0o600);
38
+ const csr = join(directory, 'server.csr');
39
+ try {
40
+ await run('openssl', ['req', '-new', '-key', key, '-out', csr, '-subj', '/CN=localhost']);
41
+ await run('openssl', ['x509', '-req', '-sha256', '-in', csr, '-CA', ca, '-CAkey', caKey, '-CAcreateserial', '-out', cert, '-days', '365', '-extfile', config, '-extensions', 'server']);
42
+ } finally { await rm(csr, { force: true }); }
43
+ }
44
+ await writeFile(join(directory, 'names.json'), JSON.stringify(hosts), { mode: 0o600 });
45
+ return { key: await readFile(key), cert: await readFile(cert), caPath: ca };
46
+ } finally { await rm(config, { force: true }); }
47
+ }
48
+ export async function trustTls() {
49
+ const { caPath } = await ensureTls();
50
+ if (platform() === 'darwin') {
51
+ await run('security', ['add-trusted-cert', '-r', 'trustRoot', '-k', join(homedir(), 'Library/Keychains/login.keychain-db'), caPath]);
52
+ } else if (platform() === 'linux') {
53
+ await run('sudo', ['-n', 'install', '-m', '0644', caPath, '/usr/local/share/ca-certificates/agent-env.crt']);
54
+ await run('sudo', ['-n', 'update-ca-certificates']);
55
+ } else throw new Error(`Install ${caPath} in your user trust store. Automatic trust supports macOS and Debian-based Linux.`);
56
+ return { message: `Trusted the agent-env local CA: ${caPath}` };
57
+ }
@@ -0,0 +1,51 @@
1
+ const $ = id => document.getElementById(id);
2
+ const element = (tag, text, className) => { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (className) node.className = className; return node; };
3
+ let loading = false;
4
+ async function refresh() {
5
+ if (loading || $('logs').open) return;
6
+ loading = true;
7
+ try {
8
+ const response = await fetch('/api/status');
9
+ if (!response.ok) throw new Error('Status is unavailable. Retry shortly.');
10
+ const data = await response.json();
11
+ $('notice').textContent = data.error ?? '';
12
+ $('empty').hidden = data.instances.length !== 0;
13
+ $('instances').replaceChildren();
14
+ for (const item of data.instances) {
15
+ const section = element('section');
16
+ const top = element('div', undefined, 'top');
17
+ const title = element('div'); title.append(element('h2', item.name), element('div', `${item.provider} · ${item.host}${item.environment ? ` · ${item.environment}` : ''}`, 'sub'));
18
+ const state = !item.running ? 'Stopped' : item.connected ? 'Connected' : 'Reconnecting';
19
+ top.append(title, element('span', state, `badge${item.connected ? '' : ' off'}`));
20
+ section.append(top);
21
+ const services = element('div', undefined, 'services');
22
+ for (const [name, service] of Object.entries(item.services)) {
23
+ const row = element('div', undefined, 'service'); row.append(element('strong', name));
24
+ const url = service.httpsUrl ?? service.url;
25
+ if (url?.startsWith('http')) { const link = element('a', url); link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer'; row.append(link); }
26
+ else row.append(element('span', url ?? 'Internal service', 'address sub'));
27
+ const actions = element('div', undefined, 'actions');
28
+ actions.append(element('span', item.status?.[name] ?? '', 'sub'));
29
+ const button = element('button', 'Logs'); button.setAttribute('aria-label', `Read ${item.name} ${name} logs`);
30
+ button.onclick = async () => {
31
+ $('log-title').textContent = `${item.name} / ${name}`; $('log-body').textContent = 'Loading logs...'; $('logs').showModal();
32
+ try { const response = await fetch(`/api/logs?name=${encodeURIComponent(item.name)}&service=${encodeURIComponent(name)}`); const data = await response.json(); $('log-body').textContent = data.output ?? data.error ?? 'No logs yet.'; }
33
+ catch { $('log-body').textContent = 'Could not read logs.'; }
34
+ };
35
+ actions.append(button); row.append(actions); services.append(row);
36
+ }
37
+ section.append(services);
38
+ if (item.error) section.append(element('p', item.error, 'hint error'));
39
+ else if (item.sync) section.append(element('p', `Sync: ${item.sync}`, 'hint'));
40
+ $('instances').append(section);
41
+ }
42
+ $('updated').textContent = `Local access only · Updated ${new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Madrid', hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date())} BCN`;
43
+ } catch (error) { $('notice').textContent = error.message; }
44
+ finally { loading = false; }
45
+ }
46
+ $('refresh').onclick = refresh;
47
+ $('close').onclick = () => $('logs').close();
48
+ $('auto').checked = localStorage.getItem('agent-env-auto-refresh') === 'true';
49
+ $('auto').onchange = () => localStorage.setItem('agent-env-auto-refresh', String($('auto').checked));
50
+ setInterval(() => { if ($('auto').checked && !document.hidden) refresh(); }, 5000);
51
+ refresh();
package/src/ui.html ADDED
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>agent-env</title>
4
+ <link rel="icon" href="/icon.svg" type="image/svg+xml">
5
+ <style>
6
+ :root{color-scheme:light;--ink:#20241f;--muted:#62695e;--line:#d9ded3;--paper:#f6f7f2;--green:#256941}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 ui-sans-serif,system-ui,sans-serif}main{max-width:1040px;margin:0 auto;padding:48px 24px}header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:40px}h1{font-size:26px;letter-spacing:-1px;margin:0}h2{font-size:18px;margin:0}.sub{color:var(--muted);font-size:13px}button,select,a{font:inherit}button,select{padding:7px 12px;border:1px solid var(--line);border-radius:7px;background:white;color:var(--ink)}button{cursor:pointer}button:hover{border-color:var(--green)}button:focus-visible,a:focus-visible{outline:3px solid #90b899;outline-offset:3px}section{border:1px solid var(--line);border-radius:12px;background:#fff;margin-bottom:18px;overflow:hidden}.top{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:20px 22px}.top>div{min-width:0;overflow-wrap:anywhere}.service>strong{min-width:0;overflow-wrap:anywhere}.badge{flex-shrink:0;font-size:12px;border-radius:99px;padding:3px 9px;background:#edf3e9;color:#286240}.badge.off{background:#f0f0ec;color:#5e6559}.services{border-top:1px solid var(--line)}.service{display:grid;grid-template-columns:140px 1fr auto;align-items:center;gap:12px;padding:12px 22px;border-bottom:1px solid #eef0e9}.service:last-child{border-bottom:0}.service a{color:var(--green);overflow-wrap:anywhere}.actions{display:flex;align-items:center;gap:8px}.hint{margin:0 22px 18px;color:var(--muted);font-size:12px}.error{color:#983c25;white-space:pre-wrap}#empty{padding:60px 20px;text-align:center;border:1px dashed var(--line);border-radius:12px}code{font:13px ui-monospace,monospace}dialog{width:min(850px,95vw);border:1px solid var(--line);border-radius:12px;padding:20px}dialog::backdrop{background:#20241f80}dialog header{margin-bottom:16px}pre{background:#181d18;color:#e5eae0;white-space:pre-wrap;overflow-wrap:anywhere;max-height:65vh;overflow:auto;border-radius:8px;padding:16px;font:12px/1.6 ui-monospace,monospace}footer{margin-top:30px;color:var(--muted);font-size:12px}@media(max-width:600px){main{padding:24px 14px}header{margin-bottom:24px}.top{padding:16px}.service{padding:12px 16px;grid-template-columns:1fr auto}.service a,.service .address{grid-row:2;grid-column:1/-1}.hint{margin-left:16px}.top .actions{flex-wrap:wrap;justify-content:flex-end}}
7
+ .brand{display:flex;align-items:center;gap:12px}.brand img{flex-shrink:0}@media(max-width:600px){main>header{flex-wrap:wrap}}
8
+ </style></head>
9
+ <body><main><header><div class="brand"><img src="/icon.svg" width="44" height="44" alt=""><div><h1>agent-env<span style="color:var(--green)">.</span></h1><div class="sub">Your development environments</div></div></div><div class="actions"><label class="sub"><input id="auto" type="checkbox"> Auto refresh</label><button id="refresh">Refresh</button></div></header>
10
+ <p id="notice" class="error" role="status"></p><div id="instances"></div><div id="empty" hidden><h2>Nothing running yet</h2><p class="sub">Start an environment from your terminal.</p><code>agent-env up</code></div><footer id="updated">Local access only</footer></main>
11
+ <dialog id="logs"><header><h2 id="log-title">Logs</h2><button id="close">Close</button></header><pre id="log-body"></pre></dialog>
12
+ <script src="/ui.js"></script></body></html>