@desplega.ai/agent-env 0.0.0-stage → 0.3.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +343 -2
  3. package/assets/icon.svg +5 -0
  4. package/bin/agent-env.mjs +210 -0
  5. package/docs/agent-swarm.md +146 -0
  6. package/docs/releases.md +207 -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 +49 -4
  22. package/src/cloud.mjs +314 -0
  23. package/src/config.mjs +133 -0
  24. package/src/daemon.mjs +484 -0
  25. package/src/environments.mjs +207 -0
  26. package/src/goodies.mjs +71 -0
  27. package/src/init.mjs +68 -0
  28. package/src/locks.mjs +56 -0
  29. package/src/maintenance.mjs +74 -0
  30. package/src/paths.mjs +5 -0
  31. package/src/providers/CLOUD_SANDBOX.md +240 -0
  32. package/src/providers/README.md +62 -0
  33. package/src/providers/compose.mjs +460 -0
  34. package/src/providers/docker.mjs +207 -0
  35. package/src/providers/index.mjs +13 -0
  36. package/src/providers/sandbox.mjs +170 -0
  37. package/src/proxy.mjs +149 -0
  38. package/src/runtime.mjs +83 -0
  39. package/src/snapshots.mjs +394 -0
  40. package/src/source-paths.mjs +15 -0
  41. package/src/system.mjs +39 -0
  42. package/src/tls.mjs +57 -0
  43. package/src/ui-client.js +51 -0
  44. package/src/ui.html +12 -0
  45. package/src/ui.mjs +32 -0
  46. package/src/watch.mjs +20 -0
  47. package/src/worktrees.mjs +154 -0
@@ -0,0 +1,207 @@
1
+ import http from 'node:http';
2
+ import { spawn } from 'node:child_process';
3
+ import { run, quote, freePort, reachable, delay } from '../system.mjs';
4
+ import { home } from '../paths.mjs';
5
+ import { hash } from '../config.mjs';
6
+ import { join } from 'node:path';
7
+ import { mkdtempSync } from 'node:fs';
8
+ import { rm } from 'node:fs/promises';
9
+ import { sourceExclusions } from '../source-paths.mjs';
10
+
11
+ // A provider owns compute and storage. The daemon owns names and local routing.
12
+ export class DockerProvider {
13
+ sessions = new Map();
14
+ sshArgs(spec, multiplex = true) {
15
+ const filename = `ssh-${hash(`${spec.host}:${spec.sshConfig ?? ''}`)}`;
16
+ let control = join(home, filename);
17
+ if (multiplex) {
18
+ // Reserve space for OpenSSH's temporary suffix within the macOS socket path limit.
19
+ if (Buffer.byteLength(control) > 80) {
20
+ this.controlDirectory ??= mkdtempSync('/tmp/agent-env-ssh-');
21
+ control = join(this.controlDirectory, filename);
22
+ }
23
+ this.sessions.set(control, spec);
24
+ }
25
+ const connection = multiplex
26
+ ? ['-o', 'ControlMaster=auto', '-S', control, '-o', 'ControlPersist=60']
27
+ : ['-o', 'ControlMaster=no', '-S', 'none', '-o', 'ControlPersist=no'];
28
+ return [...connection, ...(spec.sshConfig ? ['-F', spec.sshConfig] : []), '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=2'];
29
+ }
30
+ command(spec, args, options) {
31
+ return spec.host
32
+ ? run('ssh', [...this.sshArgs(spec), '--', spec.host, args.map(quote).join(' ')], options)
33
+ : run(args[0], args.slice(1), options);
34
+ }
35
+ docker(spec, args, options = {}) {
36
+ const inspect = args[0] === 'inspect' || ['container', 'image', 'volume', 'network'].includes(args[0]) && args[1] === 'inspect';
37
+ return this.command(spec, ['docker', ...args], { ...options, ...(inspect ? { errorStdout: false } : {}) });
38
+ }
39
+ prefix(spec) { return `agent-env-${spec.owner}-${spec.name}`; }
40
+ volume(spec, service, name) { return `${this.prefix(spec)}-${service}-${name}`; }
41
+ transfer(spec, source, destination, options = []) {
42
+ // We quote remote paths for older rsync. Prevent modern rsync from quoting them again.
43
+ return run('rsync', ['-az', ...options, '-e', ['ssh', ...this.sshArgs(spec)].map(quote).join(' '), source, `${spec.host}:${quote(destination)}`], {
44
+ env: { ...process.env, RSYNC_OLD_ARGS: '1', RSYNC_PROTECT_ARGS: '0' },
45
+ });
46
+ }
47
+ async sync(handle) {
48
+ const { spec, workspace } = handle;
49
+ if (!spec.host) return;
50
+ await this.command(spec, ['mkdir', '-p', workspace]);
51
+ // Rapid edits can keep the same size and timestamp. Compare file contents.
52
+ await this.transfer(spec, `${spec.cwd}/`, `${workspace}/`, ['--checksum', '--delete', ...sourceExclusions.map(rule => `--exclude=${rule}`)]);
53
+ }
54
+ async create(spec) {
55
+ await this.docker(spec, ['info', '--format', '{{.ServerVersion}}']);
56
+ const workspace = spec.host ? `${await this.command(spec, ['pwd'])}/.local/share/agent-env/${spec.owner}/${spec.name}` : spec.cwd;
57
+ if (workspace.includes(',')) throw new Error('Workspace paths cannot contain commas.');
58
+ const handle = { spec, workspace, services: {} };
59
+ const newVolumes = [];
60
+ await this.sync(handle);
61
+ const network = this.prefix(spec);
62
+ await this.docker(spec, ['network', 'create', '--label', `agent-env.owner=${spec.owner}`, network]);
63
+ try {
64
+ for (const [name, service] of Object.entries(spec.services)) {
65
+ const container = `${network}-${name}`;
66
+ const args = ['run', '-d', '--init', '--name', container, '--label', `agent-env.owner=${spec.owner}`, '--network', network, '--network-alias', name, '-p', `127.0.0.1::${service.containerPort}`, '--mount', `type=bind,src=${workspace},dst=/workspace`, '-w', '/workspace'];
67
+ for (const [volume, target] of Object.entries(service.volumes ?? {})) {
68
+ const volumeName = this.volume(spec, name, volume);
69
+ const exists = await this.docker(spec, ['volume', 'ls', '-q', '--filter', `name=^${volumeName}$`]);
70
+ if (!exists) newVolumes.push(volumeName);
71
+ await this.docker(spec, ['volume', 'create', '--label', `agent-env.owner=${spec.owner}`, volumeName]);
72
+ args.push('--mount', `type=volume,src=${this.volume(spec, name, volume)},dst=${target}`);
73
+ }
74
+ for (const [key, value] of Object.entries({ HOST: '0.0.0.0', PORT: String(service.containerPort), ...service.env })) args.push('-e', `${key}=${value}`);
75
+ args.push(service.image);
76
+ if (service.command) args.push('sh', '-lc', service.command);
77
+ // Record the name before creation so partial failures can remove it.
78
+ handle.services[name] = { container, ...service };
79
+ await this.docker(spec, args);
80
+ }
81
+ await this.wait(handle);
82
+ return handle;
83
+ } catch (error) {
84
+ await this.remove(handle).catch(() => {});
85
+ for (const volume of newVolumes) await this.docker(spec, ['volume', 'rm', volume]).catch(() => {});
86
+ throw error;
87
+ }
88
+ }
89
+ async wait(handle, deadline = Date.now() + (handle.spec.startupTimeoutSeconds ?? 60) * 1000) {
90
+ const seconds = handle.spec.startupTimeoutSeconds ?? 60;
91
+ for (const [name, service] of Object.entries(handle.services)) {
92
+ while (true) {
93
+ const info = JSON.parse(await this.docker(handle.spec, ['inspect', service.container]))[0];
94
+ if (!info.State.Running) {
95
+ const logs = await this.docker(handle.spec, ['logs', '--tail', '20', service.container], { combined: true }).catch(() => '');
96
+ throw new Error(`${name} exited. ${logs}`);
97
+ }
98
+ const published = info.NetworkSettings.Ports[`${service.containerPort}/tcp`]?.[0];
99
+ if (published) {
100
+ service.hostPort = Number(published.HostPort);
101
+ let ready = handle.spec.host
102
+ ? await this.command(handle.spec, ['bash', '-c', `timeout 1 bash -c 'exec 3<>/dev/tcp/127.0.0.1/${service.hostPort}'`]).then(() => true, () => false)
103
+ : await reachable(service.hostPort);
104
+ if (ready && service.protocol === 'http') {
105
+ ready = handle.spec.host
106
+ ? await this.command(handle.spec, ['bash', '-c', `timeout 2 bash -c 'exec 3<>/dev/tcp/127.0.0.1/${service.hostPort}; printf \"GET / HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n\" >&3; IFS= read -r line <&3; [[ $line == HTTP/* ]]'`]).then(() => true, () => false)
107
+ : await new Promise(resolve => {
108
+ const req = http.get({ host: '127.0.0.1', port: service.hostPort, path: '/' }, res => { res.resume(); resolve(true); });
109
+ req.setTimeout(1000, () => req.destroy());
110
+ req.on('error', () => resolve(false));
111
+ });
112
+ }
113
+ if (ready && service.health) ready = await this.docker(handle.spec, ['exec', service.container, 'sh', '-lc', service.health]).then(() => true, () => false);
114
+ if (ready) break;
115
+ }
116
+ if (Date.now() > deadline) throw new Error(`${name} did not open its port within ${seconds} seconds. Bind the service to 0.0.0.0.`);
117
+ await delay(300);
118
+ }
119
+ }
120
+ }
121
+ async connect(handle) {
122
+ const connections = [];
123
+ try {
124
+ for (const [name, service] of Object.entries(handle.services)) {
125
+ if (!handle.spec.host) { connections.push({ name, backendPort: service.hostPort, close() {} }); continue; }
126
+ const port = await freePort();
127
+ const child = spawn('ssh', [...this.sshArgs(handle.spec, false), '-o', 'ExitOnForwardFailure=yes', '-N', '-L', `127.0.0.1:${port}:127.0.0.1:${service.hostPort}`, '--', handle.spec.host], { stdio: ['ignore', 'ignore', 'pipe'] });
128
+ let error = '';
129
+ child.stderr.on('data', data => { error += data; });
130
+ child.on('error', value => { error += value.message; });
131
+ connections.push({ name, backendPort: port, child, close() { child.kill('SIGTERM'); } });
132
+ const deadline = Date.now() + 12000;
133
+ while (!await reachable(port)) {
134
+ if (child.exitCode !== null || child.signalCode !== null || Date.now() > deadline) throw new Error(`SSH forwarding failed: ${error}`);
135
+ await delay(100);
136
+ }
137
+ }
138
+ return connections;
139
+ } catch (error) { for (const connection of connections) connection.close(); throw error; }
140
+ }
141
+ async start(handle) {
142
+ await this.sync(handle);
143
+ for (const service of Object.values(handle.services)) await this.docker(handle.spec, ['start', service.container]);
144
+ await this.wait(handle);
145
+ }
146
+ async stop(handle) {
147
+ await Promise.all(Object.values(handle.services).map(service => this.docker(handle.spec, ['stop', '-t', '5', service.container])));
148
+ }
149
+ async remove(handle, purge = false) {
150
+ for (const service of Object.values(handle.services)) {
151
+ const exists = await this.docker(handle.spec, ['container', 'ls', '-aq', '--filter', `name=^/${service.container}$`]);
152
+ if (exists) await this.docker(handle.spec, ['rm', '-f', service.container]);
153
+ }
154
+ const network = this.prefix(handle.spec);
155
+ const networkExists = await this.docker(handle.spec, ['network', 'ls', '-q', '--filter', `name=^${network}$`]);
156
+ if (networkExists) await this.docker(handle.spec, ['network', 'rm', network]);
157
+ if (handle.spec.host && handle.workspace.endsWith(`/.local/share/agent-env/${handle.spec.owner}/${handle.spec.name}`)) {
158
+ await this.command(handle.spec, ['rm', '-rf', '--', handle.workspace]);
159
+ }
160
+ if (purge) {
161
+ for (const [name, service] of Object.entries(handle.services)) {
162
+ for (const volume of Object.keys(service.volumes ?? {})) {
163
+ const volumeName = this.volume(handle.spec, name, volume);
164
+ const exists = await this.docker(handle.spec, ['volume', 'ls', '-q', '--filter', `name=^${volumeName}$`]);
165
+ if (exists) await this.docker(handle.spec, ['volume', 'rm', volumeName]);
166
+ }
167
+ }
168
+ }
169
+ }
170
+ async dispose() {
171
+ await Promise.all([...this.sessions.values()].map(spec =>
172
+ run('ssh', [...this.sshArgs(spec), '-O', 'exit', '--', spec.host]).catch(() => {})));
173
+ this.sessions.clear();
174
+ if (this.controlDirectory) {
175
+ await rm(this.controlDirectory, { recursive: true, force: true });
176
+ this.controlDirectory = undefined;
177
+ }
178
+ }
179
+ async status(handle) {
180
+ return Object.fromEntries(await Promise.all(Object.entries(handle.services).map(async ([name, service]) => {
181
+ const result = await this.docker(handle.spec, ['inspect', '--format', '{{.State.Status}}', service.container]).catch(() => 'unreachable');
182
+ return [name, result];
183
+ })));
184
+ }
185
+ logs(handle, name) { return this.docker(handle.spec, ['logs', '--tail', '100', handle.services[name].container], { combined: true }); }
186
+ exec(handle, name, command) { return this.docker(handle.spec, ['exec', handle.services[name].container, ...command], { combined: true }); }
187
+ async fork(handle, spec) {
188
+ const volumes = Object.entries(handle.services).flatMap(([name, service]) =>
189
+ Object.keys(service.volumes ?? {}).map(volume => ({ source: this.volume(handle.spec, name, volume), destination: this.volume(spec, name, volume) })));
190
+ for (const { destination } of volumes) {
191
+ const existing = await this.docker(spec, ['volume', 'ls', '-q', '--filter', `name=^${destination}$`]);
192
+ if (existing) throw new Error(`Volume ${destination} already exists. Choose a different fork name.`);
193
+ }
194
+ const created = [];
195
+ try {
196
+ for (const { source, destination } of volumes) {
197
+ await this.docker(spec, ['volume', 'create', '--label', `agent-env.owner=${spec.owner}`, destination]);
198
+ created.push(destination);
199
+ await this.docker(spec, ['run', '--rm', '--mount', `type=volume,src=${source},dst=/source,readonly`, '--mount', `type=volume,src=${destination},dst=/destination`, 'alpine:3', 'sh', '-c', 'cp -a /source/. /destination/']);
200
+ }
201
+ return await this.create(spec);
202
+ } catch (error) {
203
+ for (const volume of created) await this.docker(spec, ['volume', 'rm', volume]).catch(() => {});
204
+ throw error;
205
+ }
206
+ }
207
+ }
@@ -0,0 +1,13 @@
1
+ import { DockerProvider } from './docker.mjs';
2
+ import { SandboxProvider } from './sandbox.mjs';
3
+ import { ComposeProvider } from './compose.mjs';
4
+ const providers = new Map([['docker', new DockerProvider()], ['compose', new ComposeProvider()], ['sandbox', new SandboxProvider()]]);
5
+ export function providerFor(spec) {
6
+ const provider = providers.get(spec.provider);
7
+ if (!provider) throw new Error(`Provider ${spec.provider} is not installed. Available providers: ${[...providers.keys()].join(', ')}.`);
8
+ return provider;
9
+ }
10
+
11
+ export async function disposeProviders() {
12
+ await Promise.all([...providers.values()].map(provider => provider.dispose?.()));
13
+ }
@@ -0,0 +1,170 @@
1
+ import { isAbsolute, resolve } from 'node:path';
2
+ import { fileURLToPath, pathToFileURL } from 'node:url';
3
+ import { DockerProvider } from './docker.mjs';
4
+
5
+ export const SANDBOX_OPERATIONS = Object.freeze(['create', 'start', 'wait', 'connect', 'stop', 'remove', 'status', 'sync', 'logs', 'exec', 'fork']);
6
+ export const SANDBOX_CAPABILITIES = Object.freeze([...SANDBOX_OPERATIONS, 'compose']);
7
+ const required = ['create', 'wait', 'connect', 'status'];
8
+
9
+ export function validateSandboxAdapter(adapter) {
10
+ if (!adapter || typeof adapter !== 'object' || !adapter.capabilities || typeof adapter.capabilities !== 'object') {
11
+ throw new Error('Sandbox adapter must declare explicit capabilities.');
12
+ }
13
+ for (const capability of SANDBOX_CAPABILITIES) {
14
+ if (typeof adapter.capabilities[capability] !== 'boolean') throw new Error(`Sandbox adapter must declare capability ${capability} as a boolean.`);
15
+ if (SANDBOX_OPERATIONS.includes(capability) && adapter.capabilities[capability] && typeof adapter[capability] !== 'function') {
16
+ throw new Error(`Sandbox adapter declares ${capability} but has no ${capability} method.`);
17
+ }
18
+ }
19
+ for (const capability of required) {
20
+ if (!adapter.capabilities[capability]) throw new Error(`Sandbox adapter requires capability ${capability}.`);
21
+ }
22
+ return adapter;
23
+ }
24
+
25
+ export function requireSandboxCapabilities(adapter, spec) {
26
+ const requested = spec.sandbox?.requireCapabilities ?? [];
27
+ if (!Array.isArray(requested) || requested.some(value => typeof value !== 'string')) throw new Error('Sandbox requireCapabilities must be an array of capability names.');
28
+ for (const capability of [...requested, ...(spec.compose ? ['compose'] : [])]) {
29
+ if (!SANDBOX_CAPABILITIES.includes(capability)) throw new Error(`Unknown sandbox capability ${capability}.`);
30
+ if (!adapter.capabilities[capability]) throw new Error(`Sandbox adapter does not support ${capability}.`);
31
+ }
32
+ }
33
+
34
+ // This bridge uses the existing container lifecycle and transport implementation.
35
+ export function createDockerSandboxAdapter(provider = new DockerProvider()) {
36
+ const adapter = {
37
+ capabilities: Object.freeze(Object.fromEntries(SANDBOX_CAPABILITIES.map(name => [name, SANDBOX_OPERATIONS.includes(name)]))),
38
+ dispose: () => provider.dispose?.(),
39
+ };
40
+ for (const method of SANDBOX_OPERATIONS) adapter[method] = (...args) => provider[method](...args);
41
+ return adapter;
42
+ }
43
+
44
+ function adapterLocation(spec) {
45
+ const value = spec.sandbox?.adapter;
46
+ if (value === 'docker') return 'docker';
47
+ if (typeof value !== 'string' || !value) throw new Error('Set sandbox.adapter to docker or a local ESM module path.');
48
+ if (value.startsWith('file:')) {
49
+ const url = new URL(value);
50
+ if (url.search || url.hash || url.host) throw new Error('Sandbox adapter must use a local file URL without query or fragment.');
51
+ return pathToFileURL(fileURLToPath(url)).href;
52
+ }
53
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) throw new Error('Sandbox adapter must use a local ESM module.');
54
+ if (!isAbsolute(value) && !isAbsolute(spec.cwd ?? '')) throw new Error('Relative sandbox adapters require an absolute spec.cwd.');
55
+ return pathToFileURL(resolve(spec.cwd ?? '.', value)).href;
56
+ }
57
+
58
+ function serializableHandle(handle) {
59
+ if (!handle || typeof handle !== 'object' || !handle.spec || !handle.services || typeof handle.services !== 'object' || Array.isArray(handle.services)) {
60
+ throw new Error('Sandbox adapter must return a handle with spec and services.');
61
+ }
62
+ const seen = new Set();
63
+ const visit = value => {
64
+ if (value === null || ['string', 'boolean'].includes(typeof value)) return;
65
+ if (typeof value === 'number' && Number.isFinite(value)) return;
66
+ if (typeof value !== 'object' || seen.has(value) || (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype)) {
67
+ throw new Error('Sandbox adapter handle must contain only JSON values.');
68
+ }
69
+ seen.add(value);
70
+ for (const item of Object.values(value)) visit(item);
71
+ seen.delete(value);
72
+ };
73
+ visit(handle);
74
+ for (const service of Object.values(handle.services)) {
75
+ if (!Number.isInteger(service?.port) || service.port < 1 || service.port > 65535 || !['http', 'tcp'].includes(service.protocol)) {
76
+ throw new Error('Sandbox services must declare a valid public port and protocol.');
77
+ }
78
+ }
79
+ return handle;
80
+ }
81
+
82
+ export class SandboxProvider {
83
+ constructor({ dockerProvider, load = specifier => import(specifier) } = {}) {
84
+ this.dockerProvider = dockerProvider;
85
+ this.load = load;
86
+ this.adapters = new Map();
87
+ }
88
+ async adapterFor(spec) {
89
+ const location = adapterLocation(spec);
90
+ const options = spec.sandbox?.options ?? {};
91
+ if (!options || typeof options !== 'object' || Array.isArray(options)) throw new Error('Sandbox options must be an object.');
92
+ const key = JSON.stringify([location, options]);
93
+ if (!this.adapters.has(key)) {
94
+ const pending = (async () => {
95
+ if (location === 'docker') return validateSandboxAdapter(createDockerSandboxAdapter(this.dockerProvider));
96
+ const module = await this.load(location);
97
+ if (typeof module.createAdapter !== 'function') throw new Error('Sandbox ESM module must export createAdapter(options).');
98
+ return validateSandboxAdapter(await module.createAdapter(options));
99
+ })();
100
+ this.adapters.set(key, pending);
101
+ pending.catch(() => { if (this.adapters.get(key) === pending) this.adapters.delete(key); });
102
+ }
103
+ const adapter = await this.adapters.get(key);
104
+ requireSandboxCapabilities(adapter, spec);
105
+ return { adapter, location };
106
+ }
107
+ async capabilities(spec) { return { ...(await this.adapterFor(spec)).adapter.capabilities }; }
108
+ async invoke(operation, spec, args) {
109
+ const { adapter } = await this.adapterFor(spec);
110
+ if (!adapter.capabilities[operation]) throw new Error(`Sandbox adapter does not support ${operation}.`);
111
+ return adapter[operation](...args);
112
+ }
113
+ async wrap(spec, runtime, location) {
114
+ serializableHandle(runtime);
115
+ return { spec, services: runtime.services, sandbox: { adapter: location }, runtime };
116
+ }
117
+ async create(spec) {
118
+ const { adapter, location } = await this.adapterFor(spec);
119
+ const runtime = await adapter.create(spec);
120
+ try { return await this.wrap(spec, runtime, location); }
121
+ catch (error) {
122
+ // An adapter must also clean partial allocation when create itself rejects.
123
+ if (adapter.capabilities.remove) await Promise.resolve().then(() => adapter.remove(runtime, true)).catch(() => {});
124
+ throw error;
125
+ }
126
+ }
127
+ async validateHandle(handle) {
128
+ serializableHandle(handle.runtime);
129
+ const { adapter, location } = await this.adapterFor(handle.spec);
130
+ if (handle.sandbox?.adapter !== location) throw new Error('Sandbox handle adapter does not match its specification.');
131
+ if (handle.runtime.spec.owner !== handle.spec.owner || handle.runtime.spec.name !== handle.spec.name) throw new Error('Sandbox runtime belongs to a different instance.');
132
+ return { adapter, location };
133
+ }
134
+ async operation(operation, handle, ...args) {
135
+ const { location } = await this.validateHandle(handle);
136
+ if (operation === 'start' && location === 'docker') handle.runtime.spec = structuredClone(handle.spec);
137
+ const result = await this.invoke(operation, handle.spec, [handle.runtime, ...args]);
138
+ serializableHandle(handle.runtime);
139
+ handle.services = handle.runtime.services;
140
+ return result;
141
+ }
142
+ start(handle) { return this.operation('start', handle); }
143
+ wait(handle) { return this.operation('wait', handle); }
144
+ connect(handle) { return this.operation('connect', handle); }
145
+ stop(handle) { return this.operation('stop', handle); }
146
+ remove(handle, purge = false) { return this.operation('remove', handle, purge); }
147
+ status(handle) { return this.operation('status', handle); }
148
+ sync(handle) { return this.operation('sync', handle); }
149
+ logs(handle, service) { return this.operation('logs', handle, service); }
150
+ exec(handle, service, argv) { return this.operation('exec', handle, service, argv); }
151
+ async fork(handle, destinationSpec) {
152
+ const source = await this.validateHandle(handle);
153
+ const destination = await this.adapterFor(destinationSpec);
154
+ if (source.adapter !== destination.adapter) throw new Error('Sandbox fork requires the same adapter and options.');
155
+ if (!source.adapter.capabilities.fork) throw new Error('Sandbox adapter does not support fork.');
156
+ const runtime = await this.invoke('fork', handle.spec, [handle.runtime, destinationSpec]);
157
+ try { return await this.wrap(destinationSpec, runtime, destination.location); }
158
+ catch (error) {
159
+ if (source.adapter.capabilities.remove) await Promise.resolve().then(() => source.adapter.remove(runtime, true)).catch(() => {});
160
+ throw error;
161
+ }
162
+ }
163
+ async dispose() {
164
+ const adapters = await Promise.allSettled(this.adapters.values());
165
+ this.adapters.clear();
166
+ const results = await Promise.allSettled(adapters.filter(item => item.status === 'fulfilled').map(item => Promise.resolve().then(() => item.value.dispose?.())));
167
+ const errors = results.filter(item => item.status === 'rejected').map(item => item.reason);
168
+ if (errors.length) throw new AggregateError(errors, 'Sandbox adapter disposal failed.');
169
+ }
170
+ }
package/src/proxy.mjs ADDED
@@ -0,0 +1,149 @@
1
+ import http from 'node:http';
2
+ import http2 from 'node:http2';
3
+ import net from 'node:net';
4
+
5
+ function hostname(authority) {
6
+ try { return new URL(`http://${authority}`).hostname.toLowerCase(); }
7
+ catch { return ''; }
8
+ }
9
+
10
+ export function selectTarget(request, targets, selected) {
11
+ const host = hostname(request.headers.host ?? '');
12
+ const bare = ['localhost', '127.0.0.1', '[::1]'].includes(host);
13
+ if (!bare && !host.endsWith('.localhost')) return { status: 403, error: 'Use a localhost address.' };
14
+ let focused = bare;
15
+ let id = bare ? selected : host.slice(0, -'.localhost'.length);
16
+ if (bare) {
17
+ for (const value of [request.headers.origin, request.headers.referer]) {
18
+ try {
19
+ const source = new URL(value).hostname;
20
+ if (source.endsWith('.localhost')) {
21
+ const candidate = source.slice(0, -'.localhost'.length);
22
+ if (targets.has(candidate)) { id = candidate; focused = false; break; }
23
+ }
24
+ } catch { /* The header does not contain a URL. */ }
25
+ }
26
+ }
27
+ const target = targets.get(id);
28
+ return target ? { target, focused, id } : { status: 404, error: 'No running service matches this address. Run agent-env list.' };
29
+ }
30
+
31
+ function headersFor(request, port, id, secure = false) {
32
+ const headers = { ...request.headers, host: `localhost:${port}` };
33
+ for (const key of Object.keys(headers)) if (key.startsWith(':')) delete headers[key];
34
+ if (headers.origin) {
35
+ try {
36
+ const origin = new URL(headers.origin);
37
+ if (origin.hostname === hostname(request.headers.host) || origin.hostname === `${id}.localhost`) {
38
+ if (secure && origin.host === request.headers.host) origin.port = String(port);
39
+ origin.hostname = 'localhost';
40
+ headers.origin = origin.origin;
41
+ }
42
+ } catch { /* Preserve an origin that is not a URL. */ }
43
+ }
44
+ headers['x-forwarded-host'] = request.headers.host;
45
+ headers['x-forwarded-proto'] = secure ? 'https' : 'http';
46
+ headers['x-forwarded-for'] = request.socket.remoteAddress;
47
+ return headers;
48
+ }
49
+
50
+ export function createProxy(port, getRoutes, options = {}) {
51
+ const sockets = new Set();
52
+ const focusedSockets = new Set();
53
+ const handler = (request, response) => {
54
+ request.headers.host ??= request.headers[':authority'];
55
+ const { targets, selected } = getRoutes(request);
56
+ const result = selectTarget(request, targets, selected);
57
+ if (!result.target) {
58
+ response.writeHead(result.status, { 'content-type': 'text/plain' });
59
+ response.end(`${result.error}\n`);
60
+ return;
61
+ }
62
+ options.onRequest?.(result.id);
63
+ if (result.focused) focusedSockets.add(request.socket);
64
+ else focusedSockets.delete(request.socket);
65
+ const upstream = http.request({
66
+ hostname: '127.0.0.1', port: result.target.backendPort,
67
+ method: request.method, path: request.url, headers: headersFor(request, result.target.publicPort ?? port, result.id, !!options.tls),
68
+ }, (incoming) => {
69
+ const headers = { ...incoming.headers };
70
+ if (request.httpVersionMajor === 2) for (const key of ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade']) delete headers[key];
71
+ response.writeHead(incoming.statusCode, headers);
72
+ incoming.pipe(response);
73
+ incoming.on('error', () => response.destroy());
74
+ });
75
+ upstream.on('error', () => {
76
+ if (!response.headersSent) response.writeHead(502, { 'content-type': 'text/plain' });
77
+ response.end('The service did not answer. Run agent-env logs <name>.\n');
78
+ });
79
+ request.on('aborted', () => upstream.destroy());
80
+ response.on('close', () => upstream.destroy());
81
+ request.pipe(upstream);
82
+ };
83
+ const server = options.tls ? http2.createSecureServer({ ...options.tls, allowHTTP1: true }, handler) : http.createServer(handler);
84
+ server.on('upgrade', (request, socket, head) => {
85
+ const { targets, selected } = getRoutes(request);
86
+ const result = selectTarget(request, targets, selected);
87
+ if (!result.target) {
88
+ socket.end(`HTTP/1.1 ${result.status} Not Found\r\nConnection: close\r\n\r\n`);
89
+ return;
90
+ }
91
+ options.onRequest?.(result.id);
92
+ socket.on('data', () => options.onRequest?.(result.id));
93
+ if (result.focused) focusedSockets.add(socket);
94
+ const upstream = net.connect(result.target.backendPort, '127.0.0.1', () => {
95
+ const headers = headersFor(request, result.target.publicPort ?? port, result.id, !!options.tls);
96
+ upstream.write(`${request.method} ${request.url} HTTP/${request.httpVersion}\r\n`);
97
+ for (const [key, value] of Object.entries(headers)) {
98
+ for (const item of Array.isArray(value) ? value : [value]) {
99
+ if (item !== undefined) upstream.write(`${key}: ${item}\r\n`);
100
+ }
101
+ }
102
+ upstream.write('\r\n');
103
+ if (head.length) upstream.write(head);
104
+ socket.pipe(upstream).pipe(socket);
105
+ });
106
+ upstream.on('error', () => socket.destroy());
107
+ socket.on('error', () => upstream.destroy());
108
+ socket.on('close', () => upstream.destroy());
109
+ upstream.on('close', () => socket.destroy());
110
+ });
111
+ server.on('connection', socket => {
112
+ sockets.add(socket);
113
+ socket.on('close', () => { sockets.delete(socket); focusedSockets.delete(socket); });
114
+ });
115
+ return {
116
+ server,
117
+ resetFocus() { for (const socket of focusedSockets) socket.destroy(); },
118
+ async close() {
119
+ for (const socket of sockets) socket.destroy();
120
+ await new Promise(resolve => server.close(resolve));
121
+ },
122
+ };
123
+ }
124
+
125
+ export function createTcpProxy(getRoutes, options = {}) {
126
+ const sockets = new Set();
127
+ const server = net.createServer(socket => {
128
+ const { targets, selected } = getRoutes();
129
+ const target = targets.get(selected);
130
+ if (!target) { socket.destroy(); return; }
131
+ options.onRequest?.(selected);
132
+ socket.on('data', () => options.onRequest?.(selected));
133
+ const upstream = net.connect(target.backendPort, '127.0.0.1');
134
+ sockets.add(socket);
135
+ socket.on('close', () => { sockets.delete(socket); upstream.destroy(); });
136
+ upstream.on('close', () => socket.destroy());
137
+ socket.on('error', () => upstream.destroy());
138
+ upstream.on('error', () => socket.destroy());
139
+ socket.pipe(upstream).pipe(socket);
140
+ });
141
+ return {
142
+ server,
143
+ resetFocus() { for (const socket of sockets) socket.destroy(); },
144
+ async close() {
145
+ for (const socket of sockets) socket.destroy();
146
+ await new Promise(resolve => server.close(resolve));
147
+ },
148
+ };
149
+ }
@@ -0,0 +1,83 @@
1
+ import { once } from 'node:events';
2
+ import { join } from 'node:path';
3
+ import { createProxy } from './proxy.mjs';
4
+ import { ensureTls } from './tls.mjs';
5
+ import { startUi } from './ui.mjs';
6
+ import { watchSource } from './watch.mjs';
7
+ import { hash } from './config.mjs';
8
+ import { home } from './paths.mjs';
9
+ import { redactEnvironment, sensitiveValuesForSpec } from './environments.mjs';
10
+
11
+ export function serviceAlias(name, service, primary) {
12
+ if (service === primary) return name;
13
+ const alias = `${name}--${service}`;
14
+ return alias.length <= 63 ? alias : `${alias.slice(0, 54)}-${hash(alias)}`;
15
+ }
16
+ export function redact(handle, text) {
17
+ const spec = handle?.spec ?? {};
18
+ return redactEnvironment(String(text ?? ''), sensitiveValuesForSpec(spec));
19
+ }
20
+ export class RuntimeExtras {
21
+ constructor({ instances, connections, changed, status, logs, activity }) {
22
+ Object.assign(this, { instances, connections, changed, status, logs, activity });
23
+ this.watchers = new Map(); this.tls = null; this.ui = null;
24
+ }
25
+ routes() {
26
+ const targets = new Map();
27
+ for (const [name, opened] of this.connections()) {
28
+ const handle = this.instances()[name];
29
+ if (!handle?.running) continue;
30
+ const http = Object.keys(handle.services).filter(key => handle.services[key].protocol === 'http' && handle.services[key].port);
31
+ for (const connection of opened) {
32
+ if (!http.includes(connection.name)) continue;
33
+ const service = handle.services[connection.name];
34
+ const alias = serviceAlias(name, connection.name, http[0]);
35
+ if (targets.has(alias)) throw new Error(`HTTPS name ${alias}.localhost is ambiguous. Rename one instance.`);
36
+ targets.set(alias, { backendPort: connection.backendPort, publicPort: service.port, instance: name });
37
+ }
38
+ }
39
+ return { targets, selected: undefined };
40
+ }
41
+ async enableHttps(port = 8443) {
42
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Specify a valid HTTPS port.');
43
+ if (this.tls) return { port: this.tls.port, caPath: this.tls.caPath };
44
+ const credentials = await ensureTls(join(home, 'tls'), [...this.routes().targets.keys()].map(name => `${name}.localhost`));
45
+ const proxy = createProxy(port, () => this.routes(), { tls: credentials, onRequest: alias => this.activity(this.routes().targets.get(alias)?.instance) });
46
+ try { proxy.server.listen(port, '127.0.0.1'); await once(proxy.server, 'listening'); }
47
+ catch (error) { throw new Error(`Cannot bind HTTPS port ${port}: ${error.code}. Choose --https-port with an available port.`); }
48
+ this.tls = { ...proxy, port: proxy.server.address().port, caPath: credentials.caPath };
49
+ return { port: this.tls.port, caPath: this.tls.caPath };
50
+ }
51
+ async refreshCertificates() {
52
+ if (!this.tls) return;
53
+ const credentials = await ensureTls(join(home, 'tls'), [...this.routes().targets.keys()].map(name => `${name}.localhost`));
54
+ this.tls.server.setSecureContext(credentials);
55
+ }
56
+ httpsUrl(name, service, primary) {
57
+ return this.tls ? `https://${serviceAlias(name, service, primary)}.localhost:${this.tls.port}` : undefined;
58
+ }
59
+ async enableUi(port = 4780) {
60
+ if (!this.ui) this.ui = await startUi({ port, status: this.status, logs: this.logs });
61
+ return { url: this.ui.url };
62
+ }
63
+ watch(handle) {
64
+ this.unwatch(handle.spec.name);
65
+ if (!handle.spec.host || handle.spec.watch === false || !handle.running) return;
66
+ const name = handle.spec.name;
67
+ try {
68
+ this.watchers.set(name, watchSource(handle.spec.cwd, {
69
+ stateDirectory: home,
70
+ changed: () => this.changed(name),
71
+ error: error => { handle.syncError = error.message; },
72
+ }));
73
+ handle.syncError = null;
74
+ } catch (error) { handle.syncError = error.message; }
75
+ }
76
+ unwatch(name) { this.watchers.get(name)?.(); this.watchers.delete(name); }
77
+ async close() {
78
+ for (const stop of this.watchers.values()) stop();
79
+ this.watchers.clear();
80
+ await this.ui?.close();
81
+ await this.tls?.close();
82
+ }
83
+ }