@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,101 @@
1
+ x-workspace: &workspace
2
+ - .:/workspace
3
+ - dependencies:/workspace/node_modules
4
+ - ui-dependencies:/workspace/apps/ui/node_modules
5
+ - templates-dependencies:/workspace/apps/templates-ui/node_modules
6
+ - evals-dependencies:/workspace/apps/evals/node_modules
7
+ - catalog-dependencies:/workspace/packages/model-catalog/node_modules
8
+ - routing-dependencies:/workspace/packages/model-routing/node_modules
9
+ - e2e-dependencies:/workspace/packages/ui-e2e/node_modules
10
+ - bun-cache:/root/.bun/install/cache
11
+
12
+ services:
13
+ setup:
14
+ image: oven/bun:1.4.4
15
+ working_dir: /workspace
16
+ command: ["bun", "--no-env-file", "install", "--frozen-lockfile", "--ignore-scripts"]
17
+ volumes: *workspace
18
+ api:
19
+ image: oven/bun:1.4.4
20
+ working_dir: /workspace
21
+ command: ["bun", "--no-env-file", "--expose-gc", "src/http.ts"]
22
+ depends_on:
23
+ setup:
24
+ condition: service_completed_successfully
25
+ environment:
26
+ AGENT_SWARM_API_KEY: ${AGENT_SWARM_API_KEY:?Provide a test-only API key through a private env file}
27
+ DATABASE_PATH: /data/agent-swarm.sqlite
28
+ AGENT_FS_LOCAL_DIR: /data/files
29
+ NODE_ENV: development
30
+ PORT: "3013"
31
+ MCP_BASE_URL: http://api:3013
32
+ APP_URL: http://localhost:4574
33
+ CORS_ALLOWED_ORIGINS: http://localhost:4574,http://${AGENT_SWARM_INSTANCE:-swarm-example}.localhost:4574
34
+ DESPLEGA_TELEMETRY_ENV: test
35
+ ANONYMIZED_TELEMETRY: "false"
36
+ CAPABILITIES: core,task,kv,skills,repo
37
+ HEARTBEAT_DISABLE: "true"
38
+ HEARTBEAT_CHECKLIST_DISABLE: "true"
39
+ TASK_AUTO_TITLE_ENABLED: "false"
40
+ RUNTIME_POOLS_ENABLED: "false"
41
+ CHAT_SDK_ENABLED: "false"
42
+ SLACK_DISABLE: "true"
43
+ GITHUB_DISABLE: "true"
44
+ GITLAB_DISABLE: "true"
45
+ AZURE_DEVOPS_DISABLE: "true"
46
+ BITBUCKET_DISABLE: "true"
47
+ AGENTMAIL_DISABLE: "true"
48
+ LINEAR_DISABLE: "true"
49
+ JIRA_DISABLE: "true"
50
+ OAUTH_KEEPALIVE_DISABLE: "true"
51
+ OAUTH_REFRESH_SWEEP_DISABLE: "true"
52
+ volumes:
53
+ - .:/workspace:ro
54
+ - dependencies:/workspace/node_modules
55
+ - ui-dependencies:/workspace/apps/ui/node_modules
56
+ - templates-dependencies:/workspace/apps/templates-ui/node_modules
57
+ - evals-dependencies:/workspace/apps/evals/node_modules
58
+ - catalog-dependencies:/workspace/packages/model-catalog/node_modules
59
+ - routing-dependencies:/workspace/packages/model-routing/node_modules
60
+ - e2e-dependencies:/workspace/packages/ui-e2e/node_modules
61
+ - data:/data
62
+ ports: ["127.0.0.1:4313:3013"]
63
+ healthcheck:
64
+ test: ["CMD", "bun", "--no-env-file", "-e", "fetch('http://127.0.0.1:3013/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
65
+ interval: 2s
66
+ timeout: 3s
67
+ retries: 25
68
+ ui:
69
+ image: oven/bun:1.4.4
70
+ working_dir: /workspace/apps/ui
71
+ command:
72
+ - bun
73
+ - --no-env-file
74
+ - -e
75
+ - "import {createServer} from 'vite'; const server = await createServer({envDir: false, server: {host: '0.0.0.0', port: 5274, strictPort: true}}); await server.listen(); server.printUrls();"
76
+ depends_on:
77
+ setup:
78
+ condition: service_completed_successfully
79
+ api:
80
+ condition: service_healthy
81
+ environment:
82
+ VITE_PROXY_TARGET: http://api:3013
83
+ VITE_ALLOWED_HOSTS: .localhost
84
+ VITE_PLAUSIBLE_ANALYTICS: "false"
85
+ volumes: *workspace
86
+ ports: ["127.0.0.1:4574:5274"]
87
+ healthcheck:
88
+ test: ["CMD", "bun", "--no-env-file", "-e", "fetch('http://127.0.0.1:5274').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
89
+ interval: 2s
90
+ timeout: 3s
91
+ retries: 25
92
+ volumes:
93
+ data: {}
94
+ dependencies: {}
95
+ ui-dependencies: {}
96
+ templates-dependencies: {}
97
+ evals-dependencies: {}
98
+ catalog-dependencies: {}
99
+ routing-dependencies: {}
100
+ e2e-dependencies: {}
101
+ bun-cache: {}
@@ -0,0 +1,55 @@
1
+ import assert from 'node:assert/strict';
2
+ import http from 'node:http';
3
+
4
+ const instance = process.env.AGENT_SWARM_INSTANCE ?? 'swarm-example';
5
+ const apiKey = process.env.AGENT_SWARM_API_KEY;
6
+ assert.ok(apiKey, 'Provide a test-only AGENT_SWARM_API_KEY.');
7
+ const readOnly = process.argv.includes('--read-only');
8
+ const path = '/api/kv/_/agent-env-example/persistence';
9
+ const value = 'agent-env-local-persistence-check';
10
+
11
+ function request(port, path, { method = 'GET', authenticated = false, body, named = true } = {}) {
12
+ return new Promise((resolve, reject) => {
13
+ const req = http.request({
14
+ hostname: '127.0.0.1', port, path, method,
15
+ headers: {
16
+ host: `${named ? `${instance}.localhost` : 'localhost'}:${port}`,
17
+ ...(authenticated ? { authorization: `Bearer ${apiKey}` } : {}),
18
+ ...(body ? { 'content-type': 'application/json' } : {}),
19
+ },
20
+ }, response => {
21
+ let text = '';
22
+ response.setEncoding('utf8');
23
+ response.on('data', chunk => { text += chunk; });
24
+ response.on('end', () => resolve({ status: response.statusCode, text, headers: response.headers }));
25
+ });
26
+ req.setTimeout(15000, () => req.destroy(new Error('The local HTTP request timed out.')));
27
+ req.on('error', reject);
28
+ req.end(body ? JSON.stringify(body) : undefined);
29
+ });
30
+ }
31
+
32
+ for (const named of [true, false]) {
33
+ const health = await request(4313, '/health', { named });
34
+ assert.equal(health.status, 200, 'The API health request must succeed.');
35
+ assert.equal(JSON.parse(health.text).status, 'ok');
36
+ }
37
+ assert.equal((await request(4313, path)).status, 401, 'The API must require authentication.');
38
+ if (!readOnly) {
39
+ const write = await request(4313, path, { method: 'PUT', authenticated: true, body: { value } });
40
+ assert.equal(write.status, 200, 'The API write must succeed.');
41
+ assert.equal(JSON.parse(write.text).value, value);
42
+ }
43
+ for (const port of [4313, 4574]) {
44
+ const read = await request(port, path, { authenticated: true });
45
+ assert.equal(read.status, 200, 'The direct or proxied API read must succeed.');
46
+ assert.equal(JSON.parse(read.text).value, value);
47
+ }
48
+ const ui = await request(4574, '/');
49
+ assert.equal(ui.status, 200, 'The dashboard document must load.');
50
+ assert.match(ui.text, /\/src\/main\.tsx/);
51
+ const module = await request(4574, '/src/main.tsx');
52
+ assert.equal(module.status, 200, 'Vite must transform the real application entry.');
53
+ assert.match(module.headers['content-type'], /javascript/);
54
+ assert.equal((await request(4574, '/health')).status, 200, 'The dashboard proxy must reach the API.');
55
+ console.log(`PASS: ${readOnly ? 'retained KV value' : 'KV write/read'}, authentication, named and selected localhost API routes, dashboard HTML, transformed entry, and dashboard API proxy.`);
@@ -0,0 +1,3 @@
1
+ .env
2
+ .env.*
3
+ agent-env.json
@@ -0,0 +1,4 @@
1
+ FROM node:22-bookworm-slim
2
+ WORKDIR /app
3
+ COPY server.mjs .
4
+ CMD ["node", "server.mjs"]
@@ -0,0 +1,109 @@
1
+ # Compose example
2
+
3
+ This project builds a Node web service and starts Postgres after its health check succeeds.
4
+ The `tools` service requires the `tools` profile.
5
+
6
+ Use these commands from this directory:
7
+
8
+ ```sh
9
+ agent-env init
10
+ agent-env env set POSTGRES_PASSWORD < /path/to/private/password-file
11
+ agent-env up --name compose-one
12
+ agent-env up --name compose-two --profile tools
13
+ agent-env exec compose-one --service db -- psql -U postgres -d app -c 'select 1'
14
+ agent-env rm compose-one --purge
15
+ agent-env rm compose-two --purge
16
+ ```
17
+
18
+ The source Compose file stays unchanged. Each instance has its own containers, network, build image, and data volume.
19
+ The web service retains public port 3000 in its metadata. Docker assigns a private loopback port.
20
+ Postgres has no published port and receives no HTTP route.
21
+
22
+ `loadSpec` accepts one environment name through `env`, as a string or a one-element array.
23
+ The `profile`, `compose-file`, and `env-file` options accept a string or an array of strings.
24
+ Arrays preserve order. Later Compose files and environment files override earlier files.
25
+
26
+ Named environments use this public configuration:
27
+
28
+ ```json
29
+ {
30
+ "provider": "compose",
31
+ "compose": { "files": ["compose.yaml"] },
32
+ "environments": {
33
+ "review": {
34
+ "compose": { "profiles": ["tools"] },
35
+ "envFiles": [".env.review"],
36
+ "env": { "POSTGRES_PASSWORD": { "secret": "POSTGRES_PASSWORD" } }
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ An environment value can be a literal string, `{ "secret": "PRIVATE_KEY" }`, or `{ "env": "PROCESS_VARIABLE" }`.
43
+ Store secret values through stdin or a process variable reference. The private store uses mode 0600 outside the checkout.
44
+ Environment files support `export`, comments, quoted values, and multiline quoted values. Their values remain literal, without shell execution or variable expansion.
45
+ Global environment values apply to Docker services and Compose interpolation. Service `environment` entries control which variables enter Compose containers.
46
+
47
+ For multiple published ports, define one alias for each port:
48
+
49
+ ```json
50
+ {
51
+ "compose": {
52
+ "files": ["compose.yaml"],
53
+ "routes": {
54
+ "web": { "service": "web", "target": 3000, "port": 3000, "protocol": "http" },
55
+ "admin": { "service": "web", "target": 9090, "port": 9090, "protocol": "http" }
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ Services without published ports remain internal, including services with `expose` entries.
62
+ Use explicit protocols for ports that do not match common HTTP ports.
63
+ UDP, replicas, host namespaces, static addresses, custom network allocation, and volume driver options require separate support.
64
+ The provider rejects these features. External networks and volumes remain external and survive purge.
65
+ Remote bind and build paths must resolve inside the checkout. Remote binds cannot use files excluded from synchronization.
66
+ The daemon implements Compose volume forks through verified snapshots.
67
+
68
+ ## Integration contract
69
+
70
+ Import `ComposeProvider` from `src/providers/compose.mjs` and register it under `compose`.
71
+ Docker Compose must support `config --format json`, `config --variables`, and `up --wait`.
72
+ The class implements the Docker provider interface. It uses the Docker helpers for commands, synchronization, SSH, and tunnels.
73
+ `create`, `start`, and `wait` retain desired public ports and discover private backend ports.
74
+ The daemon coordinates snapshot-based forks. Direct provider `fork` calls remain unsupported.
75
+
76
+ Each `spec.services` and `handle.services` entry has `composeService`.
77
+ Internal entries have `internal: true` and no `port`, `protocol`, or HTTP URL.
78
+ The parent must exclude internal entries when it reserves listeners, changes route focus, and formats URLs.
79
+ Published entries have `port`, `containerPort`, `protocol`, and `transport`.
80
+ Handle entries also have `container` and `hostPort`. Several aliases can refer to one container.
81
+
82
+ The initialization exports are `createInitialConfig(options)` and `initialize(options)` from `src/init.mjs`.
83
+ Both accept `cwd`, `command`, `port`, and `image`.
84
+ The first returns public configuration. The second writes a new file and returns `{ filename, config }`.
85
+ `initialize` also accepts `config` for the output filename. It never replaces an existing file.
86
+
87
+ The environment command exports come from `src/environments.mjs`:
88
+
89
+ - `setEnvironmentSecret({ cwd, environment, key, stdin })` reads a readable stream.
90
+ - `setEnvironmentSecret({ cwd, environment, key, fromEnv })` reads a process variable reference.
91
+ - `listEnvironmentSecrets({ cwd, environment })` returns sorted variable names.
92
+ - `unsetEnvironmentSecret({ cwd, environment, key })` returns whether the variable existed.
93
+
94
+ These helpers also accept `home` to select the private state directory.
95
+ `setEnvironmentSecret` accepts `env` for an explicit source environment. It rejects a literal `value` argument.
96
+ `readEnvironmentSecrets` and `loadEnvironment` return private values for runtime integration only.
97
+ `parseEnvFile`, `resolveEnvironment`, and `stringList` provide parsing and configuration helpers.
98
+
99
+ `redactEnvironment(output, values)` accepts an array or an object with private values.
100
+ Compose logs and execution output already use this helper.
101
+ The daemon applies it to Docker logs, execution output, and errors.
102
+ Keep specification values in private daemon state. Never print the full specification or write it into generated public configuration.
103
+ `spec.compose.model` captures the resolved source configuration. It can contain private values.
104
+ This snapshot preserves shell interpolation when the daemon has a different environment.
105
+ It also lets the daemon detect changes to commands, builds, health checks, and other Compose settings.
106
+ `spec.compose.environment` contains private values needed for runtime secrets and redaction.
107
+
108
+ Run `node test/compose.e2e.mjs` for the provider E2E. It creates and removes only its own resources.
109
+ Run `npm run test:features` for the integrated Compose CLI, snapshot, and worktree flow.
@@ -0,0 +1,36 @@
1
+ services:
2
+ web:
3
+ build: .
4
+ container_name: existing-web-name
5
+ ports:
6
+ - "3000:3000"
7
+ environment:
8
+ PORT: "3000"
9
+ depends_on:
10
+ db:
11
+ condition: service_healthy
12
+ healthcheck:
13
+ test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"]
14
+ interval: 2s
15
+ timeout: 2s
16
+ retries: 30
17
+ db:
18
+ image: postgres:16-alpine
19
+ environment:
20
+ POSTGRES_USER: postgres
21
+ POSTGRES_DB: app
22
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD through agent-env env or an explicit env file}
23
+ volumes:
24
+ - data:/var/lib/postgresql/data
25
+ healthcheck:
26
+ test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres -d app"]
27
+ interval: 2s
28
+ timeout: 2s
29
+ retries: 30
30
+ tools:
31
+ image: busybox:1.37
32
+ profiles: [tools]
33
+ command: ["sh", "-c", "while :; do sleep 3600; done"]
34
+ volumes:
35
+ data:
36
+ name: existing-shared-data-name
@@ -0,0 +1,6 @@
1
+ import http from 'node:http';
2
+
3
+ http.createServer((request, response) => {
4
+ response.setHeader('content-type', 'text/plain');
5
+ response.end('Compose web is ready. Postgres is internal.\n');
6
+ }).listen(Number(process.env.PORT ?? 3000), '0.0.0.0');
@@ -0,0 +1,20 @@
1
+ {
2
+ "services": {
3
+ "web": {
4
+ "image": "busybox:1.37",
5
+ "port": 3300,
6
+ "command": "httpd -f -p 3000 -h /workspace",
7
+ "containerPort": 3000,
8
+ "volumes": { "data": "/data" }
9
+ },
10
+ "db": {
11
+ "image": "postgres:16-alpine",
12
+ "port": 55432,
13
+ "containerPort": 5432,
14
+ "protocol": "tcp",
15
+ "health": "pg_isready -h 127.0.0.1 -U postgres",
16
+ "env": { "POSTGRES_PASSWORD": "dev-only", "POSTGRES_DB": "app" },
17
+ "volumes": { "data": "/var/lib/postgresql/data" }
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <meta charset="utf-8">
4
+ <meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <title>agent-env</title>
6
+ <style>body{font:18px system-ui;background:#f7f7f2;color:#242923;max-width:640px;margin:15vh auto;padding:24px}code{background:#e8ebe2;padding:4px 8px;border-radius:5px}h1{font-size:48px;letter-spacing:-2px}</style>
7
+ <h1>Local address. Any machine.</h1>
8
+ <p>This page runs in an isolated Docker container.</p>
9
+ <p>Your address: <code id="address"></code></p>
10
+ <p>Edit this file, then reload. For SSH instances, run <code>agent-env sync NAME</code> first.</p>
11
+ <script>document.getElementById('address').textContent=location.host</script>
12
+ </html>
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-env",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Run development services behind stable localhost addresses.",
5
+ "type": "module",
6
+ "bin": {
7
+ "agent-env": "./bin/agent-env.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node test/run.mjs",
11
+ "check": "node scripts/check.mjs",
12
+ "test:e2e": "node test/run.mjs",
13
+ "test:core": "node test/run.mjs core",
14
+ "test:docker": "node test/run.mjs docker",
15
+ "test:runtime": "node test/runtime-e2e.mjs",
16
+ "test:features": "node test/run.mjs features",
17
+ "test:compose": "node test/run.mjs compose",
18
+ "test:ssh": "node test/run.mjs ssh",
19
+ "test:s3": "node test/run.mjs s3",
20
+ "test:cloud": "node test/run.mjs cloud"
21
+ },
22
+ "engines": {
23
+ "node": ">=22"
24
+ },
25
+ "os": [
26
+ "darwin",
27
+ "linux"
28
+ ],
29
+ "files": [
30
+ "bin",
31
+ "src",
32
+ "README.md",
33
+ "LICENSE",
34
+ "examples",
35
+ "docs",
36
+ "assets"
37
+ ],
38
+ "license": "MIT",
39
+ "author": "Desplega Labs",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/desplega-ai/agent-env.git"
43
+ },
44
+ "homepage": "https://github.com/desplega-ai/agent-env#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/desplega-ai/agent-env/issues"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }