@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,460 @@
1
+ import { chmod, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ import { DockerProvider } from './docker.mjs';
4
+ import { hash, validate } from '../config.mjs';
5
+ import { credentialValues, redactEnvironment, sensitiveValuesForSpec } from '../environments.mjs';
6
+ import { home } from '../paths.mjs';
7
+ import { run } from '../system.mjs';
8
+ import { shouldSync } from '../source-paths.mjs';
9
+
10
+ export async function detectComposeFiles(cwd) {
11
+ for (const name of ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml']) {
12
+ try {
13
+ if ((await stat(join(cwd, name))).isFile()) {
14
+ const override = name.startsWith('docker-') ? 'docker-compose.override' : 'compose.override';
15
+ for (const extension of ['.yaml', '.yml']) {
16
+ try { if ((await stat(join(cwd, `${override}${extension}`))).isFile()) return [name, `${override}${extension}`]; }
17
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
18
+ }
19
+ return [name];
20
+ }
21
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
22
+ }
23
+ return [];
24
+ }
25
+
26
+ function sourceArgs(spec) {
27
+ return ['compose', '--project-directory', spec.cwd, '--project-name', 'agent-env-parse',
28
+ ...spec.compose.files.flatMap(file => ['-f', file]),
29
+ ...(spec.compose.profiles ?? []).flatMap(profile => ['--profile', profile]),
30
+ ...(spec.envFiles?.length ? spec.envFiles : ['/dev/null']).flatMap(file => ['--env-file', file])];
31
+ }
32
+ function composeEnvironment(spec) {
33
+ // The caller selects files and profiles. Ambient Compose options must not change that selection.
34
+ return { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('COMPOSE_'))), ...spec.env };
35
+ }
36
+ async function parseCompose(spec, options = []) {
37
+ if (!spec.compose.files.length) throw new Error('No Compose file exists. Specify --compose-file FILE.');
38
+ try {
39
+ return JSON.parse(await run('docker', [...sourceArgs(spec), '--profile', '*', 'config', '--format', 'json', ...options], { env: composeEnvironment(spec) }));
40
+ } catch {
41
+ // Compose diagnostics can contain expanded secrets. Never copy them into daemon logs.
42
+ throw new Error('Cannot normalize Compose configuration. Check Docker Compose, source files, profiles, and required environment variables.');
43
+ }
44
+ }
45
+ async function interpolationEnvironment(spec) {
46
+ try {
47
+ const output = await run('docker', [...sourceArgs(spec), 'config', '--environment'], { env: composeEnvironment(spec) });
48
+ return Object.fromEntries(output.split(/\n(?=[A-Za-z_][A-Za-z0-9_]*=)/).map(line => {
49
+ const position = line.indexOf('=');
50
+ return [line.slice(0, position), line.slice(position + 1)];
51
+ }).filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)));
52
+ } catch { throw new Error('Cannot resolve the Compose environment. Check source files and required variable references.'); }
53
+ }
54
+ async function interpolationKeys(spec) {
55
+ try {
56
+ const output = await run('docker', [...sourceArgs(spec), 'config', '--variables'], { env: composeEnvironment(spec) });
57
+ if (output && !/^NAME\s/.test(output)) throw new Error();
58
+ return output.split('\n').slice(1).map(line => /^([A-Za-z_][A-Za-z0-9_]*)\s/.exec(line)?.[1]).filter(Boolean);
59
+ } catch { throw new Error('Cannot identify Compose variables. Use Docker Compose with config --variables support.'); }
60
+ }
61
+ function activeServices(model, profiles = []) {
62
+ return Object.entries(model.services ?? {}).filter(([, service]) => !service.profiles?.length || profiles.includes('*') || service.profiles.some(profile => profiles.includes(profile)));
63
+ }
64
+ function checkUnsupported(model) {
65
+ if (model.models && Object.keys(model.models).length) throw new Error('Compose models are not supported by this provider.');
66
+ if (['configs', 'secrets'].some(kind => Object.values(model[kind] ?? {}).some(resource => resource.external || resource.driver))) throw new Error('External Compose configs and secrets require a Swarm provider. Use private file sources.');
67
+ for (const service of Object.values(model.services ?? {})) {
68
+ if (service.network_mode && service.network_mode !== 'none') throw new Error('Compose network_mode bypasses isolated networks. Use Compose networks.');
69
+ if (service.provider) throw new Error('External Compose service providers require a separate provider adapter.');
70
+ if (service.pid === 'host' || service.ipc === 'host' || service.uts === 'host' || service.use_api_socket) throw new Error('Host namespaces and Docker API sockets are not supported by this provider.');
71
+ if ((service.scale ?? 1) !== 1 || (service.deploy?.replicas ?? 1) !== 1) throw new Error('Compose replicas require routing support. Use one replica per service.');
72
+ if (Object.keys(service.deploy ?? {}).some(key => !['replicas', 'resources', 'restart_policy'].includes(key))) throw new Error('This Compose deployment feature requires a Swarm provider.');
73
+ if (service.build?.tags?.length) throw new Error('Additional build tags can collide across instances. Use the isolated build image.');
74
+ if (service.ports?.some(port => (port.protocol ?? 'tcp') !== 'tcp')) throw new Error('UDP and SCTP routes are not supported. Use TCP ports.');
75
+ if (service.volumes?.some(volume => volume.type === 'bind' && /(^|\/)docker\.sock$/.test(volume.source))) throw new Error('Docker socket mounts bypass instance isolation and are not supported.');
76
+ if (service.networks && Object.values(service.networks).some(network => network?.ipv4_address || network?.ipv6_address || network?.mac_address)) throw new Error('Static network addresses cannot be isolated automatically.');
77
+ if (service.mac_address) throw new Error('Static MAC addresses cannot be isolated automatically.');
78
+ if (service.volumes_from?.length || service.external_links?.length) throw new Error('External container links and volumes_from are not supported. Use Compose dependencies and volumes.');
79
+ if (service.develop) throw new Error('Compose develop configuration requires a watch provider. Use agent-env sync.');
80
+ if (service.ports?.some(port => port.mode && port.mode !== 'ingress')) throw new Error('Compose host port mode is not supported. Use the default port mode.');
81
+ }
82
+ for (const [kind, resources] of Object.entries({ networks: model.networks, volumes: model.volumes })) {
83
+ for (const resource of Object.values(resources ?? {})) {
84
+ if (!resource.external && kind === 'networks' && (Object.keys(resource.ipam ?? {}).length || Object.keys(resource.driver_opts ?? {}).length || resource.driver && resource.driver !== 'bridge')) throw new Error('Custom network drivers, options, and IPAM require explicit isolation support.');
85
+ if (!resource.external && kind === 'volumes' && resource.driver_opts && Object.keys(resource.driver_opts).length) throw new Error('Volume driver options can share host storage. Use ordinary named volumes or external volumes.');
86
+ }
87
+ }
88
+ }
89
+
90
+ export function composeServiceMetadata(model, compose = {}) {
91
+ const active = activeServices(model, compose.profiles);
92
+ checkUnsupported({ ...model, services: Object.fromEntries(active) });
93
+ const services = {};
94
+ const routes = compose.routes ?? {};
95
+ if (!routes || typeof routes !== 'object' || Array.isArray(routes)) throw new Error('Compose routes must map aliases to route definitions.');
96
+ const consumed = new Set();
97
+ for (const [name, service] of active) {
98
+ const ports = service.ports ?? [];
99
+ if (!ports.length) {
100
+ if (Object.hasOwn(services, name)) throw new Error('A route alias collides with a Compose service name.');
101
+ services[name] = { composeService: name, internal: true };
102
+ continue;
103
+ }
104
+ for (const published of ports) {
105
+ const target = Number(published.target);
106
+ if (!Number.isInteger(target) || target < 1 || target > 65535) throw new Error('Compose port ranges require separate route definitions and are not supported.');
107
+ const entries = Object.entries(routes).filter(([, route]) => route?.service === name && Number(route.target) === target);
108
+ if (entries.length > 1) throw new Error('Define one route alias for each published Compose port.');
109
+ if (!entries.length && ports.length > 1) throw new Error(`Service ${name} publishes multiple ports. Define compose.routes aliases with service, target, port, and protocol.`);
110
+ const [alias, route] = entries[0] ?? [name, {}];
111
+ if (Object.hasOwn(services, alias) || consumed.has(alias)) throw new Error('A route alias collides with another service or route.');
112
+ consumed.add(alias);
113
+ const port = Number(route.port ?? published.published);
114
+ if (!port) throw new Error(`Define a public port in compose.routes for ${name}:${target}.`);
115
+ const appProtocol = published.app_protocol;
116
+ const protocol = route.protocol ?? (appProtocol === 'http' || [80, 3000, 4173, 4200, 4321, 5000, 5173, 8000, 8080, 8081, 8888, 9000].includes(target) ? 'http' : 'tcp');
117
+ if (appProtocol && !['http', 'tcp'].includes(appProtocol) && !route.protocol) throw new Error(`Set an explicit route protocol for ${name}:${target}.`);
118
+ services[alias] = { composeService: name, containerPort: target, port, protocol, transport: 'tcp' };
119
+ }
120
+ }
121
+ const activeNames = new Set(active.map(([name]) => name));
122
+ for (const [alias, route] of Object.entries(routes)) {
123
+ if (consumed.has(alias)) continue;
124
+ const service = model.services?.[route?.service];
125
+ if (!activeNames.has(route?.service) && service?.ports?.some(port => Number(port.target) === Number(route.target))) continue;
126
+ throw new Error(`Route ${alias} does not match a published Compose port.`);
127
+ }
128
+ return services;
129
+ }
130
+ export async function resolveComposeSpec(spec) {
131
+ const model = await parseCompose(spec);
132
+ const environment = await interpolationEnvironment(spec);
133
+ const keys = new Set([...Object.keys(spec.env ?? {}), ...await interpolationKeys(spec)]);
134
+ const sensitiveValues = [...(spec.sensitiveValues ?? [])];
135
+ for (const file of spec.envFiles ?? []) {
136
+ try {
137
+ const source = await readFile(file, 'utf8');
138
+ const values = {};
139
+ for (const line of source.split('\n')) {
140
+ const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|:|$)/.exec(line)?.[1];
141
+ if (key) { keys.add(key); values[key] = environment[key]; }
142
+ }
143
+ sensitiveValues.push(...credentialValues(values));
144
+ }
145
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
146
+ }
147
+ for (const kind of ['configs', 'secrets']) {
148
+ for (const resource of Object.values(model[kind] ?? {})) if (resource.environment) keys.add(resource.environment);
149
+ }
150
+ const captured = Object.fromEntries([...keys].filter(key => Object.hasOwn(environment, key)).map(key => [key, environment[key]]));
151
+ sensitiveValues.push(...credentialValues(captured));
152
+ const unresolved = await parseCompose(spec, ['--no-env-resolution']);
153
+ for (const [name, service] of Object.entries(unresolved.services ?? {})) {
154
+ for (const file of service.env_file ?? []) {
155
+ try {
156
+ const source = await readFile(resolve(spec.cwd, (typeof file === 'string' ? file : file.path).replaceAll('$$', '$')), 'utf8');
157
+ for (const line of source.split('\n')) {
158
+ const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|$)/.exec(line)?.[1];
159
+ if (!key || Object.hasOwn(service.environment ?? {}, key)) continue;
160
+ const value = model.services[name]?.environment?.[key];
161
+ if (typeof value === 'string') sensitiveValues.push(...credentialValues({ [key]: value.replaceAll('$$', '$') }));
162
+ }
163
+ }
164
+ catch (error) { if (error.code !== 'ENOENT' || file.required !== false) throw new Error('Cannot read a Compose environment file. Check its path and permissions.'); }
165
+ }
166
+ }
167
+ for (const resource of Object.values(model.secrets ?? {})) {
168
+ if (resource.environment) sensitiveValues.push(captured[resource.environment]);
169
+ if (resource.file) sensitiveValues.push(await readFile(resolve(spec.cwd, resource.file.replaceAll('$$', '$')), 'utf8'));
170
+ }
171
+ // Capture the resolved model before the CLI sends it to a daemon with another shell environment.
172
+ return { ...spec, sensitiveValues: [...new Set(sensitiveValues.filter(value => typeof value === 'string' && value))].sort(), compose: { ...spec.compose, model, environment: captured }, services: composeServiceMetadata(model, spec.compose) };
173
+ }
174
+
175
+ async function checkoutPath(spec, source, workspace, transferredSeparately = false) {
176
+ const absolute = resolve(spec.cwd, source.replaceAll('$$', '$'));
177
+ const actual = await realpath(absolute).catch(error => {
178
+ if (error.code === 'ENOENT') throw new Error('A remote Compose path does not exist in the checkout.');
179
+ throw error;
180
+ });
181
+ const path = relative(spec.cwd, actual);
182
+ if (path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) throw new Error('Remote Compose bind, build, config, and secret paths must remain inside the checkout.');
183
+ if (!transferredSeparately && !shouldSync(path)) throw new Error('Remote Compose paths cannot use files excluded from checkout synchronization.');
184
+ return { actual, remote: join(workspace, path).replaceAll('$', '$$'), relative: path };
185
+ }
186
+ function remoteContext(source) {
187
+ return /^(?:[a-z][a-z0-9+.-]*:\/\/|git@|service:|docker-image:)/i.test(source);
188
+ }
189
+ // The normalized model stays private. The source files remain unchanged.
190
+ export class ComposeProvider extends DockerProvider {
191
+ snapshotVolumeNames(spec, manifest) {
192
+ return Object.fromEntries(manifest.volumes.map(volume => {
193
+ const names = volume.mounts.map(mount => {
194
+ const service = spec.compose.model.services[spec.services[mount.service]?.composeService];
195
+ const source = service?.volumes?.find(item => item.type === 'volume' && item.target === mount.target)?.source;
196
+ const definition = spec.compose.model.volumes?.[source];
197
+ if (!source || !definition || definition.external) throw new Error(`Snapshot mount ${mount.service}:${mount.target} requires a managed destination volume.`);
198
+ return `${this.prefix(spec)}-${source}`;
199
+ });
200
+ if (new Set(names).size !== 1) throw new Error('Preserve shared volume mounts when restoring a Compose snapshot.');
201
+ return [volume.id, names[0]];
202
+ }));
203
+ }
204
+ snapshotVolumeLabels(spec, name) {
205
+ const source = Object.keys(spec.compose.model.volumes ?? {}).find(key => `${this.prefix(spec)}-${key}` === name);
206
+ if (!source) throw new Error('The destination volume does not match this Compose project.');
207
+ return { 'com.docker.compose.project': this.prefix(spec), 'com.docker.compose.volume': source };
208
+ }
209
+ relocate(spec, cwd) {
210
+ const result = structuredClone(spec);
211
+ const remap = path => typeof path === 'string' && (path === spec.cwd || path.startsWith(`${spec.cwd}${sep}`)) ? cwd + path.slice(spec.cwd.length) : path;
212
+ result.cwd = cwd;
213
+ result.envFiles = result.envFiles?.map(remap);
214
+ result.compose.files = result.compose.files.map(remap);
215
+ for (const service of Object.values(result.compose.model.services)) {
216
+ for (const volume of service.volumes ?? []) if (volume.type === 'bind') volume.source = remap(volume.source);
217
+ if (service.build) {
218
+ service.build.context = remap(service.build.context);
219
+ service.build.dockerfile = remap(service.build.dockerfile);
220
+ for (const [key, path] of Object.entries(service.build.additional_contexts ?? {})) service.build.additional_contexts[key] = remap(path);
221
+ }
222
+ }
223
+ for (const kind of ['configs', 'secrets']) for (const resource of Object.values(result.compose.model[kind] ?? {})) resource.file = remap(resource.file);
224
+ return result;
225
+ }
226
+ async docker(spec, args, options) {
227
+ try { return await super.docker(spec, args, options); }
228
+ catch (error) { throw Object.assign(new Error(redactEnvironment(error.message, sensitiveValuesForSpec(spec))), { exitCode: error.exitCode ?? 1 }); }
229
+ }
230
+ async compose(handle, args) {
231
+ try {
232
+ return await this.docker(handle.spec, ['compose', '--project-name', handle.project, '--project-directory', handle.workspace,
233
+ '--env-file', '/dev/null', '-f', handle.runtimeFile,
234
+ ...(handle.spec.compose.profiles ?? []).flatMap(profile => ['--profile', profile]), ...args], { env: { ...composeEnvironment({}), COMPOSE_DISABLE_ENV_FILE: '1' } });
235
+ } catch (error) {
236
+ throw Object.assign(new Error(`Compose ${args[0]} failed. Check service status and redacted logs.`), { exitCode: error.exitCode ?? 1 });
237
+ }
238
+ }
239
+ async prepare(spec, workspace, directory) {
240
+ const model = structuredClone(spec.compose.model ?? await parseCompose(spec));
241
+ const environment = spec.compose.environment ?? await interpolationEnvironment(spec);
242
+ const redactions = sensitiveValuesForSpec(spec);
243
+ const services = composeServiceMetadata(model, spec.compose);
244
+ if (JSON.stringify(services) !== JSON.stringify(spec.services)) throw new Error('Compose routes changed after configuration. Load the specification again.');
245
+ const project = this.prefix(spec);
246
+ delete model.name;
247
+ model.services = Object.fromEntries(activeServices(model, spec.compose.profiles));
248
+ const used = { volumes: new Set(), networks: new Set(), configs: new Set(), secrets: new Set() };
249
+ for (const service of Object.values(model.services)) {
250
+ for (const volume of service.volumes ?? []) if (volume.type === 'volume' && volume.source) used.volumes.add(volume.source);
251
+ for (const network of Object.keys(service.networks ?? {})) used.networks.add(network);
252
+ for (const kind of ['configs', 'secrets']) for (const item of service[kind] ?? []) used[kind].add(typeof item === 'string' ? item : item.source);
253
+ for (const item of service.build?.secrets ?? []) used.secrets.add(typeof item === 'string' ? item : item.source);
254
+ }
255
+ for (const [kind, names] of Object.entries(used)) {
256
+ if (model[kind]) model[kind] = Object.fromEntries(Object.entries(model[kind]).filter(([name]) => names.has(name)));
257
+ }
258
+ const builtImages = new Map();
259
+ for (const [name, service] of Object.entries(model.services)) {
260
+ if (service.build && service.image) builtImages.set(service.image, `${project}-image-${hash(service.image)}:agent-env`);
261
+ }
262
+ for (const [name, service] of Object.entries(model.services)) {
263
+ const originalName = service.container_name;
264
+ for (const [network, settings] of Object.entries(service.networks ?? {})) {
265
+ if (!originalName || model.networks?.[network]?.external) continue;
266
+ service.networks[network] = { ...settings, aliases: [...new Set([...(settings?.aliases ?? []), originalName])] };
267
+ }
268
+ delete service.container_name;
269
+ if (builtImages.has(service.image)) {
270
+ service.image = builtImages.get(service.image);
271
+ service.pull_policy = service.build ? 'build' : 'never';
272
+ } else if (service.build) {
273
+ service.image = `${project}-${name}:agent-env`;
274
+ service.pull_policy = 'build';
275
+ }
276
+ if (service.build) service.build.labels = { ...service.build.labels, 'agent-env.owner': spec.owner, 'agent-env.instance': spec.name };
277
+ service.ports = (service.ports ?? []).map(port => ({ target: port.target, protocol: 'tcp', host_ip: '127.0.0.1', published: '0' }));
278
+ if (spec.host) {
279
+ for (const volume of service.volumes ?? []) {
280
+ if (volume.type !== 'bind') continue;
281
+ const mapped = await checkoutPath(spec, volume.source, workspace);
282
+ volume.source = mapped.remote;
283
+ }
284
+ if (service.build) {
285
+ const build = service.build;
286
+ const originalContext = build.context;
287
+ if (originalContext && !remoteContext(originalContext)) build.context = (await checkoutPath(spec, originalContext, workspace)).remote;
288
+ if (build.dockerfile && isAbsolute(build.dockerfile)) build.dockerfile = (await checkoutPath(spec, build.dockerfile, workspace)).remote;
289
+ else if (build.dockerfile && originalContext && !remoteContext(originalContext)) await checkoutPath(spec, resolve(originalContext, build.dockerfile), workspace);
290
+ for (const [key, context] of Object.entries(build.additional_contexts ?? {})) {
291
+ if (!remoteContext(context)) build.additional_contexts[key] = (await checkoutPath(spec, context, workspace)).remote;
292
+ }
293
+ if (build.ssh?.length) throw new Error('Remote Compose builds cannot forward local SSH agents.');
294
+ if ([...(build.cache_from ?? []), ...(build.cache_to ?? [])].some(cache => /(?:^|,)type=local(?:,|$)/.test(cache))) throw new Error('Remote Compose builds cannot use local cache paths.');
295
+ }
296
+ if (service.credential_spec?.file || service.label_file) throw new Error('Remote Compose credential and label files require explicit transfer support.');
297
+ if (service.devices?.length) throw new Error('Remote Compose devices require explicit host configuration.');
298
+ }
299
+ }
300
+ for (const kind of ['volumes', 'networks']) {
301
+ for (const [name, resource] of Object.entries(model[kind] ?? {})) {
302
+ if (!resource.external) {
303
+ resource.name = `${project}-${name}`;
304
+ resource.labels = { ...resource.labels, 'agent-env.owner': spec.owner };
305
+ }
306
+ }
307
+ }
308
+ const assets = [];
309
+ for (const kind of ['configs', 'secrets']) {
310
+ for (const [name, resource] of Object.entries(model[kind] ?? {})) {
311
+ if (resource.external) continue;
312
+ if (resource.environment) {
313
+ const value = environment[resource.environment];
314
+ if (value === undefined) throw new Error('A Compose secret references an undefined environment variable.');
315
+ const filename = join(directory, `${kind}-${hash(name)}`);
316
+ await writeFile(filename, value, { mode: 0o600 });
317
+ delete resource.environment;
318
+ resource.file = filename;
319
+ }
320
+ if (!resource.file || !spec.host) continue;
321
+ const local = resource.file.startsWith(`${directory}${sep}`) ? resource.file : (await checkoutPath(spec, resource.file, workspace, true)).actual;
322
+ const filename = `${kind}-${hash(name)}`;
323
+ assets.push({ local, remote: join(workspace, '.agent-env', filename) });
324
+ resource.file = join(workspace, '.agent-env', filename);
325
+ }
326
+ }
327
+ const localFile = join(directory, 'compose.json');
328
+ await writeFile(join(directory, 'redactions.json'), JSON.stringify(redactions), { mode: 0o600 });
329
+ // Compose config already escapes interpolation for another Compose invocation.
330
+ await writeFile(localFile, `${JSON.stringify(model, null, 2)}\n`, { mode: 0o600 });
331
+ return { project, services, localFile, assets,
332
+ ownedImages: [...new Set(Object.values(model.services).filter(service => service.build).map(service => service.image))],
333
+ ownedVolumes: Object.values(model.volumes ?? {}).filter(volume => !volume.external).map(volume => volume.name) };
334
+ }
335
+ async create(spec) {
336
+ validate(spec);
337
+ if (!/^[a-z0-9-]+$/.test(spec.owner ?? '')) throw new Error('Compose instances require a valid owner identifier.');
338
+ await this.docker(spec, ['info', '--format', '{{.ServerVersion}}']);
339
+ const existing = await this.docker(spec, ['ps', '-aq', '--filter', `label=com.docker.compose.project=${this.prefix(spec)}`]);
340
+ if (existing) throw new Error('This Compose project already has containers. Reconnect its existing handle or choose another instance name.');
341
+ const previousVolumes = new Set((await this.docker(spec, ['volume', 'ls', '-q'])).split(/\s+/));
342
+ const previousImages = new Set((await this.docker(spec, ['image', 'ls', '--format', '{{.Repository}}:{{.Tag}}'])).split(/\s+/));
343
+ await mkdir(home, { recursive: true, mode: 0o700 });
344
+ const directory = await mkdtemp(join(home, 'compose-'));
345
+ await chmod(directory, 0o700);
346
+ let handle;
347
+ let attempted = false;
348
+ try {
349
+ const workspace = spec.host ? `${await this.command(spec, ['pwd'])}/.local/share/agent-env/${spec.owner}/${spec.name}` : spec.cwd;
350
+ const prepared = await this.prepare(spec, workspace, directory);
351
+ handle = { spec, workspace, project: prepared.project, services: prepared.services, ownedVolumes: prepared.ownedVolumes, ownedImages: prepared.ownedImages, runtimeDirectory: directory,
352
+ runtimeFile: spec.host ? join(workspace, '.agent-env', 'compose.json') : prepared.localFile };
353
+ if (spec.host) {
354
+ await this.command(spec, ['mkdir', '-p', workspace]);
355
+ await this.command(spec, ['chmod', '700', workspace]);
356
+ }
357
+ await this.sync(handle);
358
+ if (spec.host) {
359
+ const remoteDirectory = join(workspace, '.agent-env');
360
+ await this.command(spec, ['mkdir', '-p', remoteDirectory]);
361
+ await this.command(spec, ['chmod', '700', remoteDirectory]);
362
+ for (const asset of [...prepared.assets, { local: prepared.localFile, remote: handle.runtimeFile }]) {
363
+ await this.transfer(spec, asset.local, asset.remote);
364
+ await this.command(spec, ['chmod', '600', asset.remote]);
365
+ }
366
+ }
367
+ attempted = true;
368
+ await this.launch(handle);
369
+ return handle;
370
+ } catch (error) {
371
+ if (attempted) await this.compose(handle, ['down', '--remove-orphans']).catch(() => {});
372
+ if (attempted) {
373
+ for (const volume of handle.ownedVolumes.filter(volume => !previousVolumes.has(volume))) await this.docker(spec, ['volume', 'rm', volume]).catch(() => {});
374
+ await this.removeImages(handle, handle.ownedImages.filter(image => !previousImages.has(image))).catch(cleanup => { error.message += ` Image cleanup failed: ${cleanup.message}`; });
375
+ }
376
+ if (handle?.spec.host && handle.workspace.endsWith(`/.local/share/agent-env/${spec.owner}/${spec.name}`)) await this.command(spec, ['rm', '-rf', '--', handle.workspace]).catch(() => {});
377
+ await rm(directory, { recursive: true, force: true });
378
+ throw error;
379
+ }
380
+ }
381
+ async wait(handle, deadline) {
382
+ const containers = new Map();
383
+ for (const service of Object.values(handle.services)) {
384
+ if (!containers.has(service.composeService)) {
385
+ const ids = (await this.compose(handle, ['ps', '--all', '-q', service.composeService])).split(/\s+/).filter(Boolean);
386
+ if (ids.length !== 1) throw new Error(`Compose service ${service.composeService} requires exactly one container.`);
387
+ const info = JSON.parse(await this.docker(handle.spec, ['inspect', ids[0]]))[0];
388
+ if (!info.State.Running && !(service.internal && info.State.Status === 'exited' && info.State.ExitCode === 0)) throw new Error(`Compose service ${service.composeService} is not running.`);
389
+ if (info.State.Health && info.State.Health.Status !== 'healthy') throw new Error(`Compose service ${service.composeService} is not healthy.`);
390
+ containers.set(service.composeService, info);
391
+ }
392
+ const info = containers.get(service.composeService);
393
+ service.container = info.Id;
394
+ if (service.internal) continue;
395
+ const ports = info.NetworkSettings.Ports[`${service.containerPort}/tcp`] ?? [];
396
+ if (ports.length !== 1 || ports[0].HostIp !== '127.0.0.1' || !Number(ports[0].HostPort)) throw new Error('Compose did not publish an isolated loopback port.');
397
+ service.hostPort = Number(ports[0].HostPort);
398
+ }
399
+ const routed = Object.fromEntries(Object.entries(handle.services).filter(([, service]) => !service.internal));
400
+ try { await super.wait({ ...handle, services: routed }, deadline); }
401
+ catch (error) { throw Object.assign(new Error(await this.redact(handle, error.message)), { exitCode: error.exitCode ?? 1 }); }
402
+ }
403
+ connect(handle) {
404
+ return super.connect({ ...handle, services: Object.fromEntries(Object.entries(handle.services).filter(([, service]) => !service.internal)) });
405
+ }
406
+ async start(handle) {
407
+ await this.sync(handle);
408
+ await this.launch(handle);
409
+ }
410
+ async launch(handle) {
411
+ await this.compose(handle, ['create', '--build']);
412
+ const deadline = Date.now() + (handle.spec.startupTimeoutSeconds ?? 60) * 1000;
413
+ await this.compose(handle, ['up', '-d', '--no-build', '--pull', 'never', '--wait', '--wait-timeout', String(handle.spec.startupTimeoutSeconds ?? 60)]);
414
+ await this.wait(handle, deadline);
415
+ }
416
+ async removeImages(handle, images = handle.ownedImages ?? []) {
417
+ for (const name of images) {
418
+ if (!name.startsWith(`${this.prefix(handle.spec)}-`) || !name.endsWith(':agent-env')) throw new Error('Refusing to remove an image outside this instance.');
419
+ const found = await this.docker(handle.spec, ['image', 'ls', '--filter', `reference=${name}`, '--format', '{{.Repository}}:{{.Tag}}']);
420
+ if (!found.split(/\s+/).includes(name)) continue;
421
+ const [image] = JSON.parse(await this.docker(handle.spec, ['image', 'inspect', name]));
422
+ if (image.Config?.Labels?.['agent-env.owner'] !== handle.spec.owner || image.Config?.Labels?.['agent-env.instance'] !== handle.spec.name) throw new Error('Refusing to remove an image with different ownership labels.');
423
+ await this.docker(handle.spec, ['image', 'rm', name]);
424
+ }
425
+ }
426
+ stop(handle) { return this.compose(handle, ['stop', '-t', '5']); }
427
+ async remove(handle, purge = false) {
428
+ if (handle.removed) {
429
+ if (!purge) return;
430
+ const volumes = [];
431
+ for (const name of handle.ownedVolumes ?? []) {
432
+ if (!name.startsWith(`${this.prefix(handle.spec)}-`)) throw new Error('Refusing to remove a volume outside this instance.');
433
+ const found = (await this.docker(handle.spec, ['volume', 'ls', '-q', '--filter', `name=${name}`])).split(/\s+/);
434
+ if (!found.includes(name)) continue;
435
+ const [volume] = JSON.parse(await this.docker(handle.spec, ['volume', 'inspect', name]));
436
+ if (volume.Name !== name || volume.Labels?.['agent-env.owner'] !== handle.spec.owner || volume.Labels?.['com.docker.compose.project'] !== this.prefix(handle.spec)) throw new Error('Refusing to remove a volume with different ownership labels.');
437
+ volumes.push(name);
438
+ }
439
+ if (volumes.length) await this.docker(handle.spec, ['volume', 'rm', ...volumes]);
440
+ await this.removeImages(handle);
441
+ return;
442
+ }
443
+ await this.compose(handle, ['down', '--remove-orphans', ...(purge ? ['--volumes'] : [])]);
444
+ if (purge) await this.removeImages(handle);
445
+ await rm(handle.runtimeDirectory, { recursive: true, force: true });
446
+ if (handle.spec.host && handle.workspace.endsWith(`/.local/share/agent-env/${handle.spec.owner}/${handle.spec.name}`)) await this.command(handle.spec, ['rm', '-rf', '--', handle.workspace]);
447
+ }
448
+ async redact(handle, output) {
449
+ return redactEnvironment(output, JSON.parse(await readFile(join(handle.runtimeDirectory, 'redactions.json'), 'utf8')));
450
+ }
451
+ async logs(handle, name) {
452
+ try { return await this.redact(handle, await super.logs(handle, name)); }
453
+ catch { throw new Error('Cannot read Compose logs. Check service status and private runtime files.'); }
454
+ }
455
+ async exec(handle, name, command) {
456
+ try { return await this.redact(handle, await super.exec(handle, name, command)); }
457
+ catch (error) { throw Object.assign(new Error(await this.redact(handle, error.message)), { exitCode: error.exitCode ?? 1 }); }
458
+ }
459
+ fork() { throw new Error('Compose volume forks require snapshot support. Use a new instance for an empty isolated stack.'); }
460
+ }