@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/ui.mjs ADDED
@@ -0,0 +1,32 @@
1
+ import http from 'node:http';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { once } from 'node:events';
4
+
5
+ export async function startUi({ port = 4780, status, logs }) {
6
+ const [html, script, icon] = await Promise.all(['ui.html', 'ui-client.js', '../assets/icon.svg'].map(file => readFile(new URL(file, import.meta.url))));
7
+ const server = http.createServer(async (request, response) => {
8
+ const send = (code, data, type = 'application/json') => {
9
+ response.writeHead(code, { 'content-type': `${type}; charset=utf-8`, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'" });
10
+ response.end(type === 'application/json' ? JSON.stringify(data) : data);
11
+ };
12
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.socket.remoteAddress)) return send(403, { error: 'Local access only.' });
13
+ const address = server.address();
14
+ const allowed = new Set([`127.0.0.1:${address.port}`, `localhost:${address.port}`, `[::1]:${address.port}`]);
15
+ if (!allowed.has(request.headers.host)) return send(403, { error: 'Use the local dashboard address.' });
16
+ if (request.headers.origin && ![`http://${request.headers.host}`].includes(request.headers.origin)) return send(403, { error: 'Cross-origin requests are not allowed.' });
17
+ if (request.headers['sec-fetch-site'] === 'cross-site') return send(403, { error: 'Cross-site requests are not allowed.' });
18
+ if (request.method !== 'GET') return send(405, { error: 'This dashboard is read-only.' });
19
+ const url = new URL(request.url, 'http://localhost');
20
+ try {
21
+ if (url.pathname === '/') return send(200, html, 'text/html');
22
+ if (url.pathname === '/ui.js') return send(200, script, 'text/javascript');
23
+ if (url.pathname === '/icon.svg') return send(200, icon, 'image/svg+xml');
24
+ if (url.pathname === '/api/status') return send(200, { instances: await status() });
25
+ if (url.pathname === '/api/logs') return send(200, { output: await logs(url.searchParams.get('name'), url.searchParams.get('service')) });
26
+ send(404, { error: 'Not found.' });
27
+ } catch (error) { send(400, { error: error.message }); }
28
+ });
29
+ server.listen(port, '127.0.0.1');
30
+ await once(server, 'listening');
31
+ return { url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); }) };
32
+ }
package/src/watch.mjs ADDED
@@ -0,0 +1,20 @@
1
+ import { watch, existsSync } from 'node:fs';
2
+ import { resolve, sep, basename } from 'node:path';
3
+ import { shouldSync } from './source-paths.mjs';
4
+ export { shouldSync } from './source-paths.mjs';
5
+ // The caller serializes synchronization with lifecycle changes.
6
+ export function watchSource(cwd, { changed, error = () => {}, debounceMs = 350, exclusions = [], stateDirectory } = {}) {
7
+ let timer;
8
+ const state = stateDirectory ? resolve(stateDirectory) : null;
9
+ const watcher = watch(cwd, { recursive: true, persistent: false }, (_, filename) => {
10
+ const path = resolve(cwd, filename ?? '');
11
+ if (filename === basename(cwd) && !existsSync(path)) return;
12
+ if (state && (path === state || path.startsWith(`${state}${sep}`))) return;
13
+ if (!shouldSync(filename, exclusions)) return;
14
+ clearTimeout(timer);
15
+ timer = setTimeout(changed, debounceMs);
16
+ timer.unref();
17
+ });
18
+ watcher.on('error', error);
19
+ return () => { clearTimeout(timer); watcher.close(); };
20
+ }
@@ -0,0 +1,154 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { constants, createWriteStream } from 'node:fs';
3
+ import { mkdir, realpath, lstat, open, readFile, writeFile, rm, rmdir } from 'node:fs/promises';
4
+ import { pipeline } from 'node:stream/promises';
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { shouldSync, worktreeExclusions } from './source-paths.mjs';
8
+
9
+ async function git(cwd, args, input) {
10
+ return new Promise((resolveResult, reject) => {
11
+ const child = spawn('git', ['-C', cwd, ...args], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
12
+ const output = [], errors = [];
13
+ child.stdout.on('data', chunk => output.push(chunk));
14
+ child.stderr.on('data', chunk => errors.push(chunk));
15
+ child.stdin.on('error', () => {});
16
+ child.on('error', reject);
17
+ child.on('close', code => code === 0
18
+ ? resolveResult(Buffer.concat(output))
19
+ : reject(new Error(`Git ${args[0]} failed: ${Buffer.concat(errors).toString().trim()}`)));
20
+ child.stdin.end(input);
21
+ });
22
+ }
23
+
24
+ const text = async (cwd, args) => (await git(cwd, args)).toString().trim();
25
+ const names = buffer => buffer.toString().split('\0').filter(Boolean);
26
+ const inside = (root, path) => { const part = relative(root, path); return part === '' || (!part.startsWith(`..${sep}`) && part !== '..' && !isAbsolute(part)); };
27
+
28
+ function credential(path) {
29
+ return path.split('/').some(part =>
30
+ /^\.env(?:\..*)?$/.test(part) && !/\.(?:example|sample|template)$/.test(part)
31
+ || /^(?:\.ssh|\.aws|\.gnupg|\.kube|\.docker|\.netrc|\.npmrc|\.pypirc|\.git-credentials|\.envrc|(?:application_default_)?credentials(?:\.json)?|service[-_]account(?:[-_].*)?\.json|secrets?(?:\..*)?|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?)$/i.test(part)
32
+ || /\.(?:pem|key|p12|pfx|keystore)$/i.test(part));
33
+ }
34
+
35
+ function excluded(path) {
36
+ return credential(path) || !shouldSync(path, worktreeExclusions);
37
+ }
38
+
39
+ async function absent(path) {
40
+ try { await lstat(path); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
41
+ throw new Error(`Destination ${path} already exists. Choose a new path.`);
42
+ }
43
+
44
+ async function copySourceFile(sourceRoot, destination, path) {
45
+ const source = join(sourceRoot, path), target = join(destination, path);
46
+ if (!inside(sourceRoot, source) || !inside(destination, target)) throw new Error('The source path leaves the repository.');
47
+ const info = await lstat(source);
48
+ if (!info.isFile()) return false;
49
+ if (!inside(sourceRoot, await realpath(source))) throw new Error('The source file resolves outside the repository.');
50
+ // Check each destination ancestor before copying through it.
51
+ let current = destination;
52
+ for (const part of relative(destination, dirname(target)).split(sep).filter(Boolean)) {
53
+ current = join(current, part);
54
+ try { await mkdir(current); } catch (error) { if (error.code !== 'EEXIST') throw error; }
55
+ if (!(await lstat(current)).isDirectory()) throw new Error(`Destination ancestor ${current} is not a directory.`);
56
+ }
57
+ // Exclusive creation prevents a source file from replacing a file in the selected ref.
58
+ const sourceHandle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
59
+ try {
60
+ if (!(await sourceHandle.stat()).isFile()) throw new Error('Only regular untracked files can be copied.');
61
+ await pipeline(sourceHandle.createReadStream(), createWriteStream(target, { flags: 'wx', mode: info.mode & 0o777 }));
62
+ } finally { await sourceHandle.close(); }
63
+ return true;
64
+ }
65
+
66
+ async function recordPath(ownership) {
67
+ if (ownership?.version !== 1 || !/^[0-9a-f-]{36}$/.test(ownership.token ?? '')) throw new Error('Valid worktree ownership metadata is required.');
68
+ const commonDir = await realpath(ownership.commonDir);
69
+ return join(commonDir, 'agent-env-worktrees', `${ownership.token}.json`);
70
+ }
71
+
72
+ /** Create a Git worktree and copy the current source changes into it. */
73
+ export async function createWorktree(sourceSpec, destinationName, options = {}) {
74
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(destinationName)) throw new Error('Use a destination name without path separators.');
75
+ const sourceCwd = await realpath(typeof sourceSpec === 'string' ? sourceSpec : sourceSpec.cwd);
76
+ const sourceRoot = await realpath(await text(sourceCwd, ['rev-parse', '--show-toplevel']));
77
+ const commonDir = await realpath(await text(sourceRoot, ['rev-parse', '--path-format=absolute', '--git-common-dir']));
78
+ const branch = options.branch ?? destinationName;
79
+ if (branch.startsWith('-')) throw new Error('The branch cannot start with a hyphen.');
80
+ await git(sourceRoot, ['check-ref-format', '--branch', branch]);
81
+ const branches = names(await git(sourceRoot, ['for-each-ref', '--format=%(refname)%00', `refs/heads/${branch}`]));
82
+ if (branches.some(value => value.trim() === `refs/heads/${branch}`)) throw new Error(`Branch ${branch} already exists.`);
83
+ const head = await text(sourceRoot, ['rev-parse', '--verify', '--end-of-options', `${options.ref ?? 'HEAD'}^{commit}`]);
84
+ const tracked = names(await git(sourceRoot, ['ls-tree', '-rz', '--name-only', head]));
85
+ if (tracked.some(credential)) throw new Error('The selected ref tracks credential files. Remove those files from Git before creating a fork.');
86
+ const requested = resolve(options.path ?? join(dirname(sourceRoot), destinationName));
87
+ const parent = await realpath(dirname(requested));
88
+ const destination = join(parent, basename(requested));
89
+ if (inside(sourceRoot, destination)) throw new Error('Create the worktree outside the source repository.');
90
+ await absent(destination);
91
+ // Git resolves repository boundaries and refs. Never copy .git directories.
92
+ const changed = options.copyChanges === false ? [] : names(await git(sourceRoot, ['diff', 'HEAD', '--name-only', '--no-renames', '-z']));
93
+ const untracked = options.copyChanges === false ? [] : names(await git(sourceRoot, ['ls-files', '--others', '--exclude-standard', '-z']));
94
+ const omittedPaths = [...changed, ...untracked].filter(excluded);
95
+ const safeChanges = changed.filter(path => !excluded(path));
96
+ const patch = safeChanges.length ? await git(sourceRoot, ['diff', 'HEAD', '--binary', '--no-ext-diff', '--no-textconv', '--no-renames', '--', ...safeChanges.map(path => `:(literal)${path}`)]) : null;
97
+ const ownership = { version: 1, token: randomUUID(), commonDir, path: destination, branch, head, sourceRoot };
98
+ let added = false, reserved = false, saved = false, branchCreated = false;
99
+ try {
100
+ await mkdir(destination);
101
+ reserved = true;
102
+ await git(sourceRoot, ['branch', '--', branch, head]);
103
+ branchCreated = true;
104
+ await git(sourceRoot, ['worktree', 'add', '--', destination, branch]);
105
+ added = true;
106
+ await mkdir(join(commonDir, 'agent-env-worktrees'), { recursive: true, mode: 0o700 });
107
+ await writeFile(await recordPath(ownership), JSON.stringify(ownership), { flag: 'wx', mode: 0o600 });
108
+ saved = true;
109
+ if (patch?.length) await git(destination, ['apply', '--binary', '--whitespace=nowarn', '-'], patch);
110
+ for (const path of untracked.filter(path => !excluded(path))) {
111
+ if (!await copySourceFile(sourceRoot, destination, path)) omittedPaths.push(path);
112
+ }
113
+ const cwd = join(destination, relative(sourceRoot, sourceCwd));
114
+ if (!(await lstat(cwd)).isDirectory() || !inside(destination, await realpath(cwd))) throw new Error('The selected ref does not provide the source working directory.');
115
+ return { cwd, ownership, omittedPaths };
116
+ } catch (error) {
117
+ try {
118
+ const registered = reserved && names(await git(sourceRoot, ['worktree', 'list', '--porcelain', '-z'])).includes(`worktree ${destination}`);
119
+ if (added || registered) {
120
+ await git(sourceRoot, ['worktree', 'remove', '--force', '--', destination]);
121
+ } else if (reserved) await rmdir(destination).catch(failure => { if (failure.code !== 'ENOENT') throw failure; });
122
+ // Delete only the new branch, and only while it still points at the original commit.
123
+ if (branchCreated) await git(sourceRoot, ['update-ref', '-d', `refs/heads/${branch}`, head]);
124
+ if (saved) await rm(await recordPath(ownership));
125
+ } catch (cleanupError) { error.cleanupError = cleanupError; }
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ /** Remove a recorded worktree. Set dirty: 'discard' to explicitly discard its files. */
131
+ export async function removeOwnedWorktree(result, options = {}) {
132
+ const ownership = result.ownership ?? result;
133
+ const file = await recordPath(ownership);
134
+ const saved = JSON.parse(await readFile(file, 'utf8'));
135
+ if (JSON.stringify(saved) !== JSON.stringify(ownership)) throw new Error('The worktree ownership metadata does not match the saved record.');
136
+ const path = await realpath(saved.path);
137
+ if (path !== saved.path || path === saved.sourceRoot) throw new Error('The owned worktree path changed.');
138
+ const commonDir = await realpath(await text(path, ['rev-parse', '--path-format=absolute', '--git-common-dir']));
139
+ const root = await realpath(await text(path, ['rev-parse', '--show-toplevel']));
140
+ const branch = await text(path, ['symbolic-ref', '--quiet', 'HEAD']);
141
+ if (root !== path || commonDir !== saved.commonDir || branch !== `refs/heads/${saved.branch}`) throw new Error('Git does not identify this checkout as the owned worktree.');
142
+ const status = await git(path, ['status', '--porcelain=v1', '--untracked-files=all', '--ignored=matching', '-z']);
143
+ if (status.length && options.dirty !== 'discard') throw new Error('The worktree is dirty. Preserve its files or explicitly select dirty: discard.');
144
+ if (options.checkOnly) return { checked: path };
145
+ const head = await text(path, ['rev-parse', 'HEAD']);
146
+ await git(commonDir, ['worktree', 'remove', ...(options.dirty === 'discard' ? ['--force'] : []), '--', path]);
147
+ let branchRetained = head !== saved.head;
148
+ if (!branchRetained) {
149
+ try { await git(commonDir, ['update-ref', '-d', `refs/heads/${saved.branch}`, saved.head]); }
150
+ catch { branchRetained = true; }
151
+ }
152
+ await rm(file);
153
+ return { removed: path, branchRetained };
154
+ }