@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
@@ -0,0 +1,140 @@
1
+ # Remote services and data operations
2
+
3
+ Read only the section needed for the requested operation.
4
+ Keep existing authorization for the selected host and data operation.
5
+ This skill does not authorize paid infrastructure, trust changes, or data deletion by itself.
6
+
7
+ ## Existing SSH hosts
8
+
9
+ The local machine needs SSH and rsync.
10
+ The remote host needs Docker, rsync, Bash, and `timeout`. Compose projects also need Docker Compose.
11
+ Use an SSH alias or `--ssh-config FILE` for credentials and ports.
12
+ Establish and verify the host key through the normal SSH workflow. Do not disable host verification.
13
+
14
+ ```sh
15
+ agent-env up --name remote --ssh dev-host
16
+ agent-env --json list
17
+ agent-env sync remote
18
+ agent-env logs remote --service web
19
+ agent-env down remote
20
+ agent-env up remote
21
+ ```
22
+
23
+ Replace `dev-host` with the authorized SSH alias or `user@host`.
24
+ Remote source synchronization follows local edits unless startup includes `--no-watch`.
25
+ The daemon reconnects failed SSH tunnels automatically.
26
+ Verify the returned localhost address after startup and after a representative source edit.
27
+
28
+ Sync excludes `.git`, `node_modules`, `.agent-env`, `.env*`, and common generated directories.
29
+ Root `build` and `dist` directories are excluded. Nested source paths such as `src/build` still transfer.
30
+ Keep other credentials outside the checkout.
31
+ Remote Compose bind mounts, build contexts, and Dockerfiles must remain inside the checkout and pass the sync exclusions.
32
+ Compose secret files use a separate private transfer.
33
+
34
+ ## Owned Hetzner machines
35
+
36
+ Use this workflow only when the user requests paid infrastructure.
37
+ Check the authenticated `hcloud` context before provisioning.
38
+
39
+ ```sh
40
+ agent-env up --name cloud --cloud hetzner --location hel1
41
+ agent-env down cloud
42
+ agent-env up cloud
43
+ ```
44
+
45
+ `down` powers off the owned machine when no other running instance needs it.
46
+ Powered-off machines still incur charges.
47
+ `agent-env rm cloud --purge` deletes the last instance's owned machine, IP, key, and data.
48
+ Forks share the source machine. Removing one instance preserves its peers.
49
+ Keep the original hcloud context and credential for lifecycle operations.
50
+ If either changes, the CLI refuses access and preserves recovery records.
51
+ Do not delete those records to bypass an error.
52
+
53
+ The default idle timeout is 30 minutes. `--idle-minutes 0` disables it.
54
+ `--ttl-hours N` sets a destructive expiry for the shared machine and all its instances, including sleeping forks.
55
+ Idle shutdown and expiry require the local daemon to remain running and connected.
56
+ These are not cloud-side schedules. Do not promise cost control after the daemon stops.
57
+
58
+ ## HTTPS and the dashboard
59
+
60
+ ```sh
61
+ agent-env up --name app --local --https --https-port 8443
62
+ agent-env ui --port 4780
63
+ ```
64
+
65
+ Read the actual HTTPS URLs from `agent-env list`.
66
+ The primary HTTP service uses `https://app.localhost:8443`.
67
+ Other HTTP services use `https://app--SERVICE.localhost:8443`.
68
+ The dashboard shows status and recent redacted logs. It accepts local read-only requests.
69
+
70
+ `agent-env trust` installs the local certificate authority into the OS trust store.
71
+ Run it only when trust installation is authorized. Linux installation requires sudo.
72
+ Certificate creation during `up --https` does not install OS trust.
73
+ For verification without a trust change, use the generated CA:
74
+
75
+ ```sh
76
+ curl --cacert ~/.local/state/agent-env/tls/ca.pem \
77
+ --resolve app.localhost:8443:127.0.0.1 https://app.localhost:8443/
78
+ ```
79
+
80
+ Use `$AGENT_ENV_HOME/tls/ca.pem` instead when the state directory was overridden.
81
+ Some browsers and runtimes use separate certificate stores.
82
+
83
+ ## Forks
84
+
85
+ Stop the source before copying database volumes:
86
+
87
+ ```sh
88
+ agent-env down app
89
+ agent-env fork app experiment --worktree ../experiment --branch experiment
90
+ agent-env --json list
91
+ ```
92
+
93
+ This creates a Git worktree, copies source changes and volumes, and starts an independent fork.
94
+ Credentials and generated files are excluded from copied source changes.
95
+ Without worktree options, `fork app experiment` copies data and reuses the source checkout.
96
+ Forks require a new destination name and use the source Docker host.
97
+ Verify that a database write in the fork does not change the source data.
98
+ Resume a saved fork with `agent-env up experiment`, even from another directory.
99
+
100
+ Instance removal retains worktrees by default.
101
+ `rm NAME --remove-worktree` removes an owned clean worktree.
102
+ `--discard-worktree` also permits removal of changed and ignored files. Use it only for an authorized discard.
103
+
104
+ ## Snapshots and restore
105
+
106
+ ```sh
107
+ agent-env down app
108
+ agent-env snapshot app /existing/backups/checkpoint
109
+ agent-env restore /existing/backups/checkpoint --name restored
110
+ ```
111
+
112
+ Run restore from the intended project checkout with the intended environment options.
113
+ Restore uses current project configuration and creates fresh volumes before startup.
114
+ It verifies archive checksums and refuses existing destination volumes.
115
+ Snapshots contain volume data, not source files or a complete deployable project.
116
+ Unsupported or shared mounts cause an error. Do not add `--omit-mounts` unless the user accepts the missing data.
117
+
118
+ S3 uses the installed AWS CLI and its configured credentials:
119
+
120
+ ```sh
121
+ agent-env snapshot app s3://my-bucket/checkpoint
122
+ agent-env restore s3://my-bucket/checkpoint --name restored --ssh dev-host
123
+ ```
124
+
125
+ Use a new snapshot prefix. `--endpoint URL` selects compatible object storage.
126
+ S3 stores backups. Running databases still use Docker volumes.
127
+ Snapshots need temporary local disk space. AWS S3 exports are limited to 5 GB per volume.
128
+ For format and ownership details, read the [snapshot documentation](https://github.com/desplega-ai/agent-env/blob/master/docs/snapshots-and-worktrees.md).
129
+
130
+ ## Provider and state boundaries
131
+
132
+ State defaults to `~/.local/state/agent-env`. `AGENT_ENV_HOME` selects another private directory.
133
+ State can contain environment values and cloud ownership credentials. Do not publish it or treat it as disposable cache.
134
+ For isolated tests, set `AGENT_ENV_HOME` before the first CLI command.
135
+ Remove only the test's resources, then stop its daemon before deleting its state.
136
+
137
+ The sandbox bridge is available through `--provider sandbox --adapter docker`.
138
+ Custom sandbox adapters are trusted local ESM modules.
139
+ Firecracker and hosted sandbox vendor integrations are not bundled.
140
+ See the [adapter contract](https://github.com/desplega-ai/agent-env/blob/master/src/providers/CLOUD_SANDBOX.md) before proposing a new provider.
package/src/cloud.mjs ADDED
@@ -0,0 +1,314 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { isIP } from 'node:net';
6
+ import { run as systemRun, delay as systemDelay } from './system.mjs';
7
+ import { withFileLock } from './locks.mjs';
8
+
9
+ const digest = value => createHash('sha256').update(value).digest('hex');
10
+ const methods = ['server', 'primary-ip', 'ssh-key'];
11
+ const fields = { server: 'serverId', 'primary-ip': 'primaryIpId', 'ssh-key': 'keyId' };
12
+ const safeName = value => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$/.test(value);
13
+ const sshQuote = value => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
14
+
15
+ export function cloudOptions(value) {
16
+ const options = typeof value === 'string' ? { provider: value } : value;
17
+ if (!options || options.provider !== 'hetzner') throw new Error('Select the cloud provider explicitly: hetzner.');
18
+ const result = { type: 'cx23', location: 'fsn1', image: 'ubuntu-24.04', idleTimeoutMs: 30 * 60 * 1000, ...options, provider: 'hetzner' };
19
+ for (const field of ['type', 'location', 'image']) {
20
+ if (typeof result[field] !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(result[field])) throw new Error(`Cloud ${field} must be a resource name or numeric ID.`);
21
+ }
22
+ if (!Number.isSafeInteger(result.idleTimeoutMs) || result.idleTimeoutMs < 0) throw new Error('Cloud idleTimeoutMs must be a nonnegative integer.');
23
+ if (result.expiresAt != null) {
24
+ if (typeof result.expiresAt !== 'string' || !Number.isFinite(Date.parse(result.expiresAt))) throw new Error('Cloud expiresAt must be an ISO date string.');
25
+ result.expiresAt = new Date(result.expiresAt).toISOString();
26
+ } else result.expiresAt = null;
27
+ return Object.fromEntries(['provider', 'type', 'location', 'image', 'idleTimeoutMs', 'expiresAt'].map(key => [key, result[key]]));
28
+ }
29
+
30
+ // Pin the host key before the first connection. Never accept an unauthenticated scan.
31
+ export function cloudInit(hostPrivateKey, hostPublicKey) {
32
+ const script = `set -eu
33
+ . /etc/os-release
34
+ case "$ID" in ubuntu|debian) ;; *) exit 1 ;; esac
35
+ apt-get update
36
+ apt-get install -y ca-certificates curl rsync
37
+ install -m 0755 -d /etc/apt/keyrings
38
+ curl -fsSL "https://download.docker.com/linux/$ID/gpg" -o /etc/apt/keyrings/docker.asc
39
+ chmod a+r /etc/apt/keyrings/docker.asc
40
+ printf 'Types: deb\\nURIs: https://download.docker.com/linux/%s\\nSuites: %s\\nComponents: stable\\nArchitectures: %s\\nSigned-By: /etc/apt/keyrings/docker.asc\\n' "$ID" "\${UBUNTU_CODENAME:-$VERSION_CODENAME}" "$(dpkg --print-architecture)" > /etc/apt/sources.list.d/docker.sources
41
+ apt-get update
42
+ apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
43
+ systemctl enable --now docker
44
+ `;
45
+ // JSON values are valid YAML values and retain all newlines in the private key.
46
+ return `#cloud-config\nssh_deletekeys: true\nssh_keys:\n ed25519_private: ${JSON.stringify(hostPrivateKey)}\n ed25519_public: ${JSON.stringify(hostPublicKey.trim())}\nssh_pwauth: false\ndisable_root: false\nwrite_files:\n - path: /root/agent-env-bootstrap.sh\n permissions: '0700'\n content: ${JSON.stringify(script)}\nruncmd:\n - [sh, /root/agent-env-bootstrap.sh]\n`;
47
+ }
48
+
49
+ export function createCloudManager({
50
+ home = resolve(process.env.AGENT_ENV_HOME ?? join(homedir(), '.local/state/agent-env')),
51
+ run = systemRun, delay = systemDelay, now = Date.now, readinessTimeoutMs = 300000,
52
+ } = {}) {
53
+ const root = join(resolve(home), 'cloud');
54
+ if (/[\r\n\0%]/.test(root)) throw new Error('Cloud home cannot contain newlines, null bytes, or SSH percent tokens.');
55
+ const recordKey = identity => {
56
+ if (!safeName(identity?.owner) || !safeName(identity?.name)) throw new Error('Cloud owner and name must be safe resource names.');
57
+ return digest(`${identity.owner}\0${identity.name}`);
58
+ };
59
+ const directory = identity => join(root, recordKey(identity));
60
+ const recordPath = identity => join(directory(identity), 'record.json');
61
+ const labels = record => ({ 'agent-env.owner': record.owner, 'agent-env.instance': record.name, 'agent-env.record': record.recordId });
62
+ const labelArgs = record => Object.entries(labels(record)).flatMap(([key, value]) => ['--label', `${key}=${value}`]);
63
+ const metadata = record => ({
64
+ ...record.options, owner: record.owner, name: record.name, recordId: record.recordId,
65
+ serverId: record.serverId ?? null, keyId: record.keyId ?? null,
66
+ primaryIpIds: record.primaryIpId ? [record.primaryIpId] : [],
67
+ createdAt: record.createdAt, createdForStart: !record.committed,
68
+ });
69
+ const extract = handle => handle?.spec?.cloud ?? handle?.cloud ?? handle;
70
+ async function privateWrite(path, contents) {
71
+ const temporary = `${path}.${randomUUID()}.tmp`;
72
+ const file = await open(temporary, 'wx', 0o600);
73
+ try { await file.writeFile(contents); await file.sync(); } finally { await file.close(); }
74
+ try { await rename(temporary, path); } finally { await rm(temporary, { force: true }); }
75
+ }
76
+ const save = record => privateWrite(recordPath(record), `${JSON.stringify(record, null, 2)}\n`);
77
+ async function read(identity) {
78
+ try {
79
+ const record = JSON.parse(await readFile(recordPath(identity), 'utf8'));
80
+ if (record.owner !== identity.owner || record.name !== identity.name || record.version !== 1 || !/^[a-f0-9]{32}$/.test(record.recordId)) {
81
+ throw new Error('Cloud ownership record is invalid.');
82
+ }
83
+ return record;
84
+ } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
85
+ }
86
+ async function locked(identity, action) {
87
+ recordKey(identity);
88
+ await mkdir(root, { recursive: true, mode: 0o700 });
89
+ await chmod(root, 0o700);
90
+ const path = join(root, `${recordKey(identity)}.lock`);
91
+ return withFileLock(path, action);
92
+ }
93
+
94
+ async function command(binary, args, env) {
95
+ try { return await run(binary, args, { timeout: 120000, killSignal: 'SIGKILL', ...(env ? { env } : {}) }); }
96
+ catch { throw new Error(`${binary} ${binary === 'hcloud' ? args.slice(0, 2).join(' ') : 'command'} failed. Check authentication, connectivity, and resource availability.`); }
97
+ }
98
+ async function authentication(record) {
99
+ if (record && !record.authentication?.fingerprint) throw new Error('This older cloud record has no credential binding. Preserve its local keys and recover its resources in Hetzner.');
100
+ const quiet = { ...process.env, HCLOUD_DEBUG: 'false', HCLOUD_DEBUG_FILE: '' };
101
+ const get = field => command('hcloud', ['config', 'get', field, '--allow-sensitive'], quiet);
102
+ const context = (await get('context')).trim();
103
+ const fixed = { ...quiet, HCLOUD_CONTEXT: context };
104
+ const endpoint = (await command('hcloud', ['config', 'get', 'endpoint'], fixed)).trim();
105
+ const token = (await command('hcloud', ['config', 'get', 'token', '--allow-sensitive'], fixed)).trim();
106
+ if (!token || !endpoint) throw new Error('Configure an authenticated hcloud context before using cloud instances.');
107
+ const binding = { context, endpoint, fingerprint: digest(JSON.stringify([context, endpoint, token])) };
108
+ if (record && record.authentication?.fingerprint !== binding.fingerprint) throw new Error('The hcloud context, endpoint, or credential differs from this cloud record. Restore the original settings. Local keys remain intact.');
109
+ return { binding, env: { ...fixed, HCLOUD_ENDPOINT: endpoint, HCLOUD_TOKEN: token } };
110
+ }
111
+ async function hcloud(record, kind, action, args = [], json = false) {
112
+ const { env } = await authentication(record);
113
+ const output = await command('hcloud', [kind, action, ...args, ...(json ? ['--output', 'json'] : [])], env);
114
+ if (!json) return output;
115
+ try { return JSON.parse(output); } catch { throw new Error(`hcloud ${kind} ${action} returned invalid JSON.`); }
116
+ }
117
+ function verify(record, kind, resource) {
118
+ if (!Number.isSafeInteger(resource.id) || resource.id <= 0 || resource.name !== `${record.resourceName}-${kind}` ||
119
+ Object.entries(labels(record)).some(([key, value]) => resource.labels?.[key] !== value)) {
120
+ throw new Error(`Cloud ownership check failed for ${kind}. No resource was changed.`);
121
+ }
122
+ }
123
+ async function reconcile(record, kind) {
124
+ // Listing also distinguishes absence from API errors and changed ownership labels.
125
+ const resources = await hcloud(record, kind, 'list', [], true);
126
+ if (!Array.isArray(resources)) throw new Error(`hcloud ${kind} list returned invalid resources.`);
127
+ const field = fields[kind];
128
+ const owned = resources.filter(resource => resource.labels?.['agent-env.record'] === record.recordId);
129
+ if (owned.length > 1) throw new Error(`Cloud has multiple ${kind} resources for this record. Refuse automatic changes.`);
130
+ const resource = record[field] ? resources.find(item => item.id === record[field]) : owned[0];
131
+ if (record[field] && owned.some(item => item.id !== record[field])) throw new Error(`Cloud ${kind} ID conflicts with its ownership record.`);
132
+ if (resource) {
133
+ verify(record, kind, resource);
134
+ if (record[field] && record[field] !== resource.id) throw new Error(`Cloud ${kind} ID does not match its ownership record.`);
135
+ if (!record[field]) { record[field] = resource.id; await save(record); }
136
+ }
137
+ return resource ?? null;
138
+ }
139
+ async function loadHandle(handle) {
140
+ const identity = extract(handle);
141
+ const record = await read(identity);
142
+ if (!record || identity.recordId !== record.recordId) throw new Error('No matching cloud ownership record exists.');
143
+ for (const field of ['serverId', 'keyId']) {
144
+ if (identity[field] != null && record[field] != null && identity[field] !== record[field]) throw new Error(`Cloud handle ${field} does not match its ownership record.`);
145
+ }
146
+ if (identity.primaryIpIds?.length && record.primaryIpId && !identity.primaryIpIds.includes(record.primaryIpId)) throw new Error('Cloud handle IP does not match its ownership record.');
147
+ await authentication(record);
148
+ return record;
149
+ }
150
+ async function createResource(record, kind, args) {
151
+ const existing = await reconcile(record, kind);
152
+ if (existing) return existing;
153
+ if (record.attempted[kind]) throw new Error(`Cloud ${kind} creation has an uncertain result or a missing resource. Run agent-env rm ${record.name} --purge before replacement.`);
154
+ // Store intent before the external side effect, including requests with lost responses.
155
+ record.attempted[kind] = true;
156
+ await save(record);
157
+ const result = await hcloud(record, kind, 'create', ['--name', `${record.resourceName}-${kind}`, ...labelArgs(record), ...args], true);
158
+ const resource = result[kind.replaceAll('-', '_')] ?? result;
159
+ verify(record, kind, resource);
160
+ record[fields[kind]] = resource.id;
161
+ await save(record);
162
+ return resource;
163
+ }
164
+ async function keyPair(path) {
165
+ const exists = async name => readFile(name).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; });
166
+ const privateExists = await exists(path);
167
+ const publicExists = await exists(`${path}.pub`);
168
+ if (privateExists !== publicExists) throw new Error('Cloud key pair is incomplete. Dispose the failed start before retry.');
169
+ if (!privateExists) {
170
+ await command('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'agent-env', '-f', path]);
171
+ }
172
+ await chmod(path, 0o600);
173
+ return { privateKey: await readFile(path, 'utf8'), publicKey: await readFile(`${path}.pub`, 'utf8') };
174
+ }
175
+ async function connection(record, server) {
176
+ const address = server.public_net?.ipv4?.ip;
177
+ if (isIP(address ?? '') !== 4) throw new Error('Cloud server has no public IPv4 address.');
178
+ const dir = directory(record);
179
+ const alias = `agent-env-${record.recordId}`;
180
+ const publicKey = (await readFile(join(dir, 'host-key.pub'), 'utf8')).trim().split(/\s+/).slice(0, 2).join(' ');
181
+ await privateWrite(join(dir, 'known_hosts'), `${alias} ${publicKey}\n`);
182
+ const config = join(dir, 'ssh_config');
183
+ await privateWrite(config, `Host ${alias}\n HostName ${address}\n User root\n Port 22\n IdentityFile ${sshQuote(join(dir, 'identity'))}\n IdentitiesOnly yes\n IdentityAgent none\n PasswordAuthentication no\n KbdInteractiveAuthentication no\n BatchMode yes\n ConnectTimeout 10\n StrictHostKeyChecking yes\n HostKeyAlias ${alias}\n HostKeyAlgorithms ssh-ed25519\n UserKnownHostsFile ${sshQuote(join(dir, 'known_hosts'))}\n GlobalKnownHostsFile /dev/null\n UpdateHostKeys no\n`);
184
+ const deadline = now() + readinessTimeoutMs;
185
+ do {
186
+ try {
187
+ await command('ssh', ['-F', config, '-o', 'ControlMaster=no', '-S', 'none', '--', alias,
188
+ 'cloud-init status --wait >/dev/null 2>&1 && docker info >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 && command -v rsync >/dev/null']);
189
+ return { host: alias, sshConfig: config };
190
+ } catch {
191
+ if (now() >= deadline) throw new Error('Cloud SSH readiness failed. Check the pinned host identity and cloud-init installation.');
192
+ await delay(1000);
193
+ }
194
+ } while (true);
195
+ }
196
+ async function prepareCloud(spec, existingHandle) {
197
+ recordKey(spec);
198
+ const supplied = extract(existingHandle ?? (spec.cloud?.recordId ? spec : null));
199
+ return locked(spec, async () => {
200
+ let record = await read(spec);
201
+ if (supplied) {
202
+ const matched = await loadHandle(supplied);
203
+ if (matched.owner !== spec.owner || matched.name !== spec.name) throw new Error('Cloud handle belongs to a different instance.');
204
+ record = matched;
205
+ }
206
+ try {
207
+ const explicit = typeof spec.cloud === 'object' ? spec.cloud : {};
208
+ const options = cloudOptions({ ...record?.options, ...(typeof spec.cloud === 'string' ? { provider: spec.cloud } : explicit) });
209
+ if (record && ['type', 'image', 'location'].some(key => options[key] !== record.options[key])) throw new Error('Cloud machine options differ from the ownership record. Purge before replacement.');
210
+ if (options.expiresAt && Date.parse(options.expiresAt) <= now()) throw new Error('Cloud expiry has elapsed. Destroy or extend this instance before start.');
211
+ const { binding } = await authentication(record);
212
+ if (!record) {
213
+ const recordId = randomUUID().replaceAll('-', '');
214
+ record = { version: 1, owner: spec.owner, name: spec.name, recordId, resourceName: `dn-${recordId}`, createdAt: new Date(now()).toISOString(), options, authentication: binding, attempted: {}, committed: false };
215
+ await mkdir(directory(record), { recursive: true, mode: 0o700 });
216
+ await chmod(directory(record), 0o700);
217
+ await save(record);
218
+ }
219
+ if (record.destroying) throw new Error('Cloud cleanup is incomplete. Retry destroyCloud before start.');
220
+ record.options = options;
221
+ await save(record);
222
+ const dir = directory(record);
223
+ const identity = await keyPair(join(dir, 'identity'));
224
+ const host = await keyPair(join(dir, 'host-key'));
225
+ await privateWrite(join(dir, 'cloud-init.yaml'), cloudInit(host.privateKey, host.publicKey));
226
+ const key = await createResource(record, 'ssh-key', ['--public-key-from-file', join(dir, 'identity.pub')]);
227
+ if (key.public_key?.trim() !== identity.publicKey.trim()) throw new Error('Cloud SSH key does not match the dedicated local key.');
228
+ const ip = await createResource(record, 'primary-ip', ['--type', 'ipv4', '--location', options.location, '--auto-delete=false']);
229
+ let server = await createResource(record, 'server', ['--type', options.type, '--location', options.location, '--image', options.image,
230
+ '--ssh-key', String(key.id), '--primary-ipv4', String(ip.id), '--without-ipv6', '--user-data-from-file', join(dir, 'cloud-init.yaml')]);
231
+ if (server.public_net?.ipv4?.id !== ip.id) throw new Error('Cloud server IP does not match the ownership record.');
232
+ for (let attempt = 0; server?.status === 'off' && attempt < 3; attempt++) {
233
+ try { await hcloud(record, 'server', 'poweron', [String(server.id)]); }
234
+ catch (error) {
235
+ server = await reconcile(record, 'server');
236
+ if (!server || !['off', 'running', 'starting'].includes(server.status) || (server.status === 'off' && attempt === 2)) throw error;
237
+ if (server.status === 'off') await delay(1000 * (attempt + 1));
238
+ }
239
+ server = await reconcile(record, 'server');
240
+ }
241
+ if (!server || !['running', 'starting'].includes(server.status)) throw new Error('Cloud server cannot start from its current state.');
242
+ const ssh = await connection(record, server);
243
+ // Remove the cloud-init copy that contains the private host key after readiness.
244
+ await rm(join(dir, 'cloud-init.yaml'), { force: true });
245
+ return { ...spec, ...ssh, cloud: metadata(record) };
246
+ } catch (error) {
247
+ if (record) error.cloud = metadata(record);
248
+ throw error;
249
+ }
250
+ });
251
+ }
252
+ async function cloudStatus(handle) {
253
+ return locked(extract(handle), async () => {
254
+ const record = await loadHandle(handle);
255
+ const server = await reconcile(record, 'server');
256
+ return { ...metadata(record), status: server?.status ?? 'missing', committed: record.committed, destroying: !!record.destroying };
257
+ });
258
+ }
259
+ async function sleepCloud(handle) {
260
+ return locked(extract(handle), async () => {
261
+ const record = await loadHandle(handle);
262
+ const server = await reconcile(record, 'server');
263
+ if (!server) throw new Error('Cloud server is missing. No resource was changed.');
264
+ if (server.status !== 'off') await hcloud(record, 'server', 'shutdown', ['--wait', '--wait-timeout', '120s', String(server.id)]);
265
+ return { ...metadata(record), status: 'off' };
266
+ });
267
+ }
268
+ async function destroyLocked(record) {
269
+ // Check every stored resource before the first destructive operation.
270
+ const resources = {};
271
+ for (const kind of methods) resources[kind] = await reconcile(record, kind);
272
+ const server = resources.server;
273
+ const ip = resources['primary-ip'];
274
+ if (server && server.public_net?.ipv4?.id !== record.primaryIpId) throw new Error('Cloud server IP differs from the ownership record. Refuse deletion.');
275
+ if (server?.public_net?.ipv6?.id) throw new Error('Cloud server has an untracked IPv6 address. Refuse deletion.');
276
+ if (ip?.assignee_id && ip.assignee_id !== record.serverId) throw new Error('Cloud IP belongs to a different server. Refuse deletion.');
277
+ record.destroying = true;
278
+ await save(record);
279
+ for (const kind of methods) {
280
+ if (resources[kind]) {
281
+ // Recheck ownership immediately before each destructive operation.
282
+ const resource = await reconcile(record, kind);
283
+ if (resource) await hcloud(record, kind, 'delete', [String(resource.id)]);
284
+ }
285
+ delete record[fields[kind]];
286
+ await save(record);
287
+ }
288
+ await rm(directory(record), { recursive: true, force: true });
289
+ return { destroyed: true };
290
+ }
291
+ async function destroyCloud(handle) {
292
+ return locked(extract(handle), async () => destroyLocked(await loadHandle(handle)));
293
+ }
294
+ async function commitCloud(handle) {
295
+ return locked(extract(handle), async () => {
296
+ const record = await loadHandle(handle);
297
+ if (record.destroying || !await reconcile(record, 'server')) throw new Error('Cannot commit missing cloud infrastructure.');
298
+ record.committed = true;
299
+ await save(record);
300
+ return metadata(record);
301
+ });
302
+ }
303
+ async function disposeFailedCloud(handle) {
304
+ return locked(extract(handle), async () => {
305
+ const record = await loadHandle(handle);
306
+ if (record.committed) return { destroyed: false, reason: 'committed' };
307
+ return destroyLocked(record);
308
+ });
309
+ }
310
+ return { prepareCloud, sleepCloud, destroyCloud, cloudStatus, commitCloud, disposeFailedCloud };
311
+ }
312
+
313
+ const manager = createCloudManager();
314
+ export const { prepareCloud, sleepCloud, destroyCloud, cloudStatus, commitCloud, disposeFailedCloud } = manager;
package/src/config.mjs ADDED
@@ -0,0 +1,133 @@
1
+ import { readFile, realpath } from 'node:fs/promises';
2
+ import { basename, resolve } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { discoverEnvironmentFiles, loadEnvironment, resolveEnvironment, stringList } from './environments.mjs';
5
+ import { cloudOptions } from './cloud.mjs';
6
+
7
+ export const hash = value => createHash('sha256').update(value).digest('hex').slice(0, 8);
8
+ export function validName(value) {
9
+ if (typeof value !== 'string' || !/^[a-z][a-z0-9-]{0,47}$/.test(value)) throw new Error('Use a name with 1 to 48 lowercase letters, digits, or hyphens. Start with a letter.');
10
+ return value;
11
+ }
12
+ export function validate(spec) {
13
+ validName(spec.name);
14
+ if (spec.startupTimeoutSeconds !== undefined && (!Number.isInteger(spec.startupTimeoutSeconds) || spec.startupTimeoutSeconds < 1 || spec.startupTimeoutSeconds > 3600)) {
15
+ throw new Error('Set the startup timeout to an integer from 1 to 3600 seconds.');
16
+ }
17
+ if (typeof spec.provider !== 'string' || !spec.provider) throw new Error('Specify a provider name.');
18
+ if (spec.host && !/^[a-zA-Z0-9_][a-zA-Z0-9_.@-]*$/.test(spec.host)) throw new Error('Use an SSH alias or user@host. Configure ports and keys in SSH config.');
19
+ if (spec.provider === 'compose') {
20
+ if (!spec.compose || !stringList(spec.compose.files, 'compose-file').length) throw new Error('Specify at least one Compose file.');
21
+ if (!spec.services || !Object.keys(spec.services).length) throw new Error('Enable at least one Compose service.');
22
+ const ports = new Set();
23
+ for (const [name, service] of Object.entries(spec.services)) {
24
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name) || !service.composeService) throw new Error('Compose services require valid names and source service metadata.');
25
+ if (service.internal) continue;
26
+ validName(name);
27
+ if (!Number.isInteger(service.port) || service.port < 1 || service.port > 65535) throw new Error('Compose public ports must be integers between 1 and 65535.');
28
+ if (!Number.isInteger(service.containerPort) || service.containerPort < 1 || service.containerPort > 65535) throw new Error('Container ports must be integers between 1 and 65535.');
29
+ if (!['http', 'tcp'].includes(service.protocol)) throw new Error('The protocol must be http or tcp.');
30
+ if (ports.has(service.port)) throw new Error(`Port ${service.port} appears more than once in this stack.`);
31
+ ports.add(service.port);
32
+ }
33
+ return spec;
34
+ }
35
+ if (!spec.services || !Object.keys(spec.services).length) throw new Error('Define at least one service.');
36
+ const ports = new Set();
37
+ for (const [name, service] of Object.entries(spec.services)) {
38
+ validName(name);
39
+ if (!service.image || typeof service.image !== 'string') throw new Error(`Set an image for ${name}.`);
40
+ service.containerPort ??= service.port;
41
+ service.protocol ??= 'http';
42
+ if (!Number.isInteger(service.port) || service.port < 1024 || service.port > 65535) throw new Error('Public ports must be integers between 1024 and 65535.');
43
+ if (!Number.isInteger(service.containerPort) || service.containerPort < 1 || service.containerPort > 65535) throw new Error('Container ports must be integers between 1 and 65535.');
44
+ if (ports.has(service.port)) throw new Error(`Port ${service.port} appears more than once in this stack.`);
45
+ ports.add(service.port);
46
+ if (!['http', 'tcp'].includes(service.protocol)) throw new Error('The protocol must be http or tcp.');
47
+ if (service.health !== undefined && typeof service.health !== 'string') throw new Error('The health command must be a string.');
48
+ if (service.command !== undefined && typeof service.command !== 'string') throw new Error('The service command must be a string.');
49
+ for (const [key, value] of Object.entries(service.env ?? {})) {
50
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value !== 'string') throw new Error('Environment variables must have valid names and string values.');
51
+ }
52
+ for (const [volume, target] of Object.entries(service.volumes ?? {})) {
53
+ validName(volume);
54
+ if (typeof target !== 'string' || !target.startsWith('/') || /[:,\n]/.test(target)) throw new Error('Volume destinations must be absolute paths without commas or colons.');
55
+ }
56
+ }
57
+ return spec;
58
+ }
59
+ // env accepts one name. profile, compose-file, and env-file accept strings or arrays.
60
+ export async function loadSpec(options = {}, command = []) {
61
+ const cwd = await realpath(options.cwd ?? process.cwd());
62
+ let config = {};
63
+ try { config = JSON.parse(await readFile(resolve(cwd, options.config ?? 'agent-env.json'), 'utf8')); }
64
+ catch (error) { if (error.code !== 'ENOENT' || options.config) throw error; }
65
+ if (options.provider) config.provider = options.provider;
66
+ const names = stringList(options.env, 'env');
67
+ if (names.length > 1) throw new Error('Select one named environment per instance.');
68
+ const environment = names[0] ?? 'default';
69
+ if (!/^[a-z][a-z0-9-]{0,47}$/.test(environment)) throw new Error('Use a valid environment name.');
70
+ if (options['no-env-file'] && options['env-file']) throw new Error('Select --env-file or --no-env-file, not both.');
71
+ const envConfig = config.environments?.[environment] ?? {};
72
+ const configuredFiles = options['env-file'] ?? envConfig.envFiles ?? envConfig.envFile ?? config.envFiles ?? config.envFile;
73
+ const selectedFiles = options['no-env-file'] ? [] : stringList(configuredFiles ?? await discoverEnvironmentFiles(cwd, environment), 'env-file').map(file => resolve(cwd, file));
74
+ if (environment !== 'default' && !Object.hasOwn(config.environments ?? {}, environment)
75
+ && selectedFiles.some(file => file === resolve(cwd, `.env.${environment}`) || file === resolve(cwd, `.env.${environment}.local`))) {
76
+ config.environments = { ...config.environments, [environment]: {} };
77
+ }
78
+ const environmentName = config.environments?.[environment]?.name;
79
+ config = resolveEnvironment(config, environment);
80
+ const base = basename(cwd).toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, environment === 'default' ? 30 : 24);
81
+ const suffix = environment === 'default' ? '' : `-${hash(environment)}`;
82
+ const configuredName = environmentName ?? (config.name ? environment === 'default' ? config.name : `${config.name.slice(0, 39)}${suffix}` : undefined);
83
+ const spec = { ...config, cwd, environment, name: options.name ?? configuredName ?? `dev-${base}-${hash(cwd)}${suffix}`, provider: config.provider ?? 'docker' };
84
+ if (options.timeout !== undefined) spec.startupTimeoutSeconds = Number(options.timeout);
85
+ if ([options.ssh, options.local, options.cloud].filter(Boolean).length > 1) throw new Error('Select only one target: --ssh, --local, or --cloud.');
86
+ if (options.ssh) { spec.host = options.ssh; delete spec.cloud; }
87
+ if (options.local) { delete spec.host; delete spec.cloud; delete spec.sshConfig; }
88
+ if (options.cloud) spec.cloud = { ...(typeof spec.cloud === 'object' ? spec.cloud : {}), provider: options.cloud };
89
+ const cloudFlags = ['cloud-type', 'location', 'idle-minutes', 'ttl-hours'];
90
+ if (!spec.cloud && cloudFlags.some(key => options[key] !== undefined)) throw new Error('Cloud options require --cloud hetzner or an explicit cloud provider in configuration.');
91
+ if (spec.cloud) {
92
+ const cloud = typeof spec.cloud === 'string' ? { provider: spec.cloud } : { ...spec.cloud };
93
+ if (options['cloud-type'] !== undefined) cloud.type = options['cloud-type'];
94
+ if (options.location !== undefined) cloud.location = options.location;
95
+ if (options['idle-minutes'] !== undefined) cloud.idleTimeoutMs = Number(options['idle-minutes']) * 60000;
96
+ if (options['ttl-hours'] !== undefined) {
97
+ const expires = Date.now() + Number(options['ttl-hours']) * 3600000;
98
+ if (!Number.isFinite(expires) || expires <= Date.now() || expires > 8640000000000000) throw new Error('TTL hours must be a positive number within the supported date range.');
99
+ cloud.expiresAt = new Date(expires).toISOString();
100
+ }
101
+ spec.cloud = cloudOptions(cloud);
102
+ }
103
+ if (options['ssh-config']) spec.sshConfig = resolve(cwd, options['ssh-config']);
104
+ spec.envFiles = selectedFiles;
105
+ // These values remain in memory and private daemon state. Initialization never writes them.
106
+ spec.sensitiveValues = [];
107
+ const { detectComposeFiles, resolveComposeSpec } = await import('./providers/compose.mjs');
108
+ const composeFiles = stringList(options['compose-file'] ?? config.compose?.files ?? config.composeFiles, 'compose-file');
109
+ const detected = !config.provider && !config.services && !command.length ? await detectComposeFiles(cwd) : [];
110
+ if (!command.length && (spec.provider === 'compose' || composeFiles.length || detected.length)) {
111
+ if (config.services) throw new Error('Use Compose override files for service configuration. Use env and compose.routes in agent-env.json.');
112
+ spec.provider = 'compose';
113
+ // Compose resolves dotenv interpolation. Only explicit values override its environment.
114
+ spec.env = await loadEnvironment({ cwd, environment, values: config.env, sensitiveValues: spec.sensitiveValues });
115
+ spec.compose = {
116
+ ...config.compose,
117
+ files: (composeFiles.length ? composeFiles : detected.length ? detected : await detectComposeFiles(cwd)).map(file => resolve(cwd, file)),
118
+ profiles: stringList(options.profile ?? config.compose?.profiles ?? config.profiles, 'profile'),
119
+ };
120
+ return validate(await resolveComposeSpec(spec));
121
+ }
122
+ if (options.profile !== undefined || composeFiles.length) throw new Error('Compose profiles and files require the Compose provider without a command override.');
123
+ spec.env = await loadEnvironment({ cwd, environment, files: spec.envFiles, values: config.env, sensitiveValues: spec.sensitiveValues });
124
+ if (command.length) {
125
+ spec.provider = 'docker';
126
+ spec.services = { web: { image: options.image ?? 'node:22-bookworm-slim', port: Number(options.port ?? 3000), command: command.map(value => `'${value.replaceAll("'", "'\\''")}'`).join(' '), volumes: { dependencies: '/workspace/node_modules', cache: '/root/.npm' } } };
127
+ }
128
+ for (const service of Object.values(spec.services ?? {})) {
129
+ service.env = { ...spec.env, ...await loadEnvironment({ cwd, environment, values: service.env, includeStored: false, sensitiveValues: spec.sensitiveValues }) };
130
+ }
131
+ spec.sensitiveValues = [...new Set(spec.sensitiveValues)].sort();
132
+ return validate(spec);
133
+ }