@replayio/app-building 1.32.0 → 1.34.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.
package/README.md CHANGED
@@ -63,16 +63,146 @@ Secrets are never passed directly to the container or agent. Instead:
63
63
  2. At startup, the container fetches global secrets from Infisical for internal use (clone token, agent API key).
64
64
  3. A **secrets server** (`127.0.0.1:9119`) runs inside the container, accessible only locally. It fetches secrets live from Infisical on every request — no caching.
65
65
  4. The agent process runs with a **restricted environment** — only `ANTHROPIC_API_KEY` (required for the Claude CLI) is present.
66
- 5. When the agent needs to run a command that requires secrets, it uses `exec-secrets`:
66
+ 5. Any time a secret is needed, the caller invokes `exec-secrets`:
67
67
 
68
- ```bash
69
- exec-secrets NEON_API_KEY -- curl -s -H "Authorization: Bearer $NEON_API_KEY" https://...
70
- exec-secrets NETLIFY_AUTH_TOKEN NETLIFY_ACCOUNT_SLUG -- netlify deploy --prod
68
+ ```bash
69
+ exec-secrets <SECRET1> [SECRET2 …] -- <target> [args …]
70
+ ```
71
+
72
+ The secrets server spawns the target with the named secrets in its
73
+ environment and **redacts those secret values from the output**.
74
+
75
+ `exec-secrets` is invoked recursively. The agent's own shell has no secrets,
76
+ so when it runs an app script — `npm run test`, `npm run deploy`, a seed
77
+ script, a migration — that script calls `exec-secrets` itself for each
78
+ operation that needs a secret. One agent task can produce dozens of
79
+ `exec-secrets` invocations from inside scripts it never directly typed.
80
+
81
+ ### Three commands you'll see
82
+
83
+ | Command | Purpose |
84
+ |---|---|
85
+ | `exec-secrets <SECRETS…> -- <target> [args…]` | Run `<target>` with the named secrets injected, output redacted. |
86
+ | `list-secrets` | Print the secret names the container can resolve. With an allowlist configured, also prints the allowed targets. |
87
+ | `set-branch-secret <NAME> <value>` | Store a new branch-scoped secret in Infisical (e.g. `DATABASE_URL` after provisioning Neon). Rejected if `<value>` has already appeared in logs. |
88
+
89
+ In **unrestricted mode** (no allowlist), `<target>` is any binary the
90
+ container has installed — `curl`, `psql`, `npx netlify`, etc. In
91
+ **restricted mode** (allowlist configured), `<target>` must be one of the
92
+ allowlist entry names; see Allowlist mode below.
93
+
94
+ ### Allowlist mode
95
+
96
+ Set `ContainerConfig.secretAllowlist` to restrict the set of secret-using
97
+ operations available in the container. With an allowlist configured, every
98
+ `exec-secrets` call — whether issued directly by the agent or by an app
99
+ script the agent runs (`npm run test`, deploy scripts, seed scripts,
100
+ migrations) — must name an entry; calls naming an arbitrary binary are
101
+ rejected.
102
+
103
+ Each entry is `{ name, helpString, shellCommand }`. The shellCommand body
104
+ runs under `sh -c`; positional args supplied after the target become `$1`,
105
+ `$2`, … and the named secrets are present in the environment.
106
+
107
+ #### Design principle: one entry per verb
108
+
109
+ Each entry should encode **one specific operation**. The `shellCommand`
110
+ hardcodes URL, method, file path, and any other fixed structure; positional
111
+ args carry only the data the operation needs. The caller supplies "what to
112
+ operate on", never "what to do".
113
+
114
+ #### Good entries
115
+
116
+ ```ts
117
+ secretAllowlist: [
118
+ {
119
+ name: "neon-create-branch",
120
+ helpString: "Create a Neon branch. Args: <project_id> <branch_name>",
121
+ shellCommand:
122
+ 'curl -fsS -X POST "https://console.neon.tech/api/v2/projects/$1/branches" ' +
123
+ '-H "Authorization: Bearer $NEON_API_KEY" ' +
124
+ '-H "Content-Type: application/json" ' +
125
+ '-d "{\\"branch\\":{\\"name\\":\\"$2\\"}}"',
126
+ },
127
+ {
128
+ name: "neon-delete-branch",
129
+ helpString: "Delete a Neon branch. Args: <project_id> <branch_id>",
130
+ shellCommand:
131
+ 'curl -fsS -X DELETE "https://console.neon.tech/api/v2/projects/$1/branches/$2" ' +
132
+ '-H "Authorization: Bearer $NEON_API_KEY"',
133
+ },
134
+ {
135
+ name: "netlify-deploy-prod",
136
+ helpString: "Deploy the current build to production. No args.",
137
+ shellCommand:
138
+ 'npx netlify deploy --prod --auth "$NETLIFY_AUTH_TOKEN" --site "$NETLIFY_SITE_ID"',
139
+ },
140
+ {
141
+ name: "replay-upload-all",
142
+ helpString: "Upload all pending Replay recordings. No args.",
143
+ shellCommand: 'npx replayio upload --all --api-key "$RECORD_REPLAY_API_KEY"',
144
+ },
145
+ ]
71
146
  ```
72
147
 
73
- The secrets server spawns the command with the requested secrets in its environment and **redacts requested secret values** from the output.
148
+ Each of these:
149
+
150
+ - Pins the URL, method, and headers — the caller can't redirect a Neon API
151
+ token at a different host or use it for an operation that wasn't allowed.
152
+ - Pins the binary and its flags — Netlify / Replay invocations always carry
153
+ the right auth and the intended verb.
154
+ - Takes data only — branch names, project IDs, etc.
155
+
156
+ #### Anti-patterns
157
+
158
+ Don't pass `"$@"` through to a primitive tool. These look like allowlist
159
+ entries but they're not constraining anything:
160
+
161
+ ```ts
162
+ // BAD — caller can curl any URL with the Neon token attached.
163
+ { name: "curl", shellCommand: 'curl "$@"' }
164
+ // BAD — caller can run arbitrary SQL, including `\!` shell escapes.
165
+ { name: "psql", shellCommand: 'psql "$@"' }
166
+ // BAD — caller can run arbitrary JS with every secret in env.
167
+ { name: "node", shellCommand: 'node "$@"' }
168
+ // BAD — sh/bash with -c is identical to "run anything".
169
+ { name: "shell", shellCommand: 'sh -c "$1"' }
170
+ ```
74
171
 
75
- The agent can also run `list-secrets` to see which secrets are available, and `set-branch-secret` to store new branch-level secrets (e.g., `DATABASE_URL` created at deploy time). The server rejects credential values that have already appeared in logs.
172
+ If the same upstream API has ten operations the app needs, write ten
173
+ entries — each hardcoding URL + method + headers, each taking only the
174
+ data fields the operation requires.
175
+
176
+ #### In-repo scripts aren't a security boundary
177
+
178
+ The allowlist only constrains operations whose semantics live **outside the
179
+ repo** — upstream HTTP APIs, third-party CLIs invoked with fixed flags. For
180
+ anything that runs code the agent wrote (`npx tsx scripts/seed-db.ts`,
181
+ `node scripts/migrate.js`, `npm run <anything>`), the agent controls the
182
+ file contents and can do whatever it likes with the secrets in env.
183
+ Pinning a path doesn't help — the agent can edit the file.
184
+
185
+ So: do **not** add allowlist entries that exist to run an in-repo script.
186
+ Instead, write narrow API-level entries (`neon-create-branch`,
187
+ `netlify-env-set`, …) and have the in-repo script call `exec-secrets` for
188
+ each of those verbs as needed. The allowlist then describes the set of
189
+ operations the system supports, and the agent's code composes them.
190
+
191
+ #### Behavior when configured
192
+
193
+ - `list-secrets` returns `{ secrets, allowlist }` — `secrets` are the names
194
+ available before `--`; `allowlist` is `name — helpString` per entry,
195
+ available as targets after `--`.
196
+ - `exec-secrets <SECRET1> [SECRET2 …] -- <name> [args…]`: named secrets are
197
+ injected into env (and only those values are redacted from output);
198
+ `<name>` must match an entry; `[args…]` become `$1`, `$2`, … inside the
199
+ entry's `shellCommand`. `$0` is `"exec-secrets"`.
200
+ - Targets not in the allowlist are rejected with `Unknown allowlist entry`.
201
+
202
+ `secretAllowlist` is serialized into the container as `SECRET_ALLOWLIST_JSON`;
203
+ the secrets server parses it on startup. To swap or clear the allowlist on
204
+ a running container without restarting, POST to `/reconfigure` (see
205
+ Container HTTP API).
76
206
 
77
207
  ## Exported API
78
208
 
@@ -80,8 +210,9 @@ The agent can also run `list-secrets` to see which secrets are available, and `s
80
210
 
81
211
  | Export | Description |
82
212
  |---|---|
83
- | `ContainerConfig` | `infisical` (required `InfisicalConfig`), optional `projectRoot` (local Docker only), `registry`, `flyToken`/`flyApp` (set both for remote Fly.io), `flyGuest` (override Fly Machine guest sizing; default: 16 performance CPUs / 32 GiB), `flyVolumeSizeGb` (override Fly Volume size in GiB; default: 50), `imageRef`, `webhookUrl`/`webhookSecret`, `taskWebhookUrl` (GET endpoint for external task queue), `addTaskWebhookUrl` (POST endpoint for tasks added by `add-task` script), `detached`, `initialPrompt`, `localPort`, `absorbTasks`, `namePrefix` (default: `"app-building"`), `env` (extra env vars to inject into the container; cannot clobber package-reserved vars). |
213
+ | `ContainerConfig` | `infisical` (required `InfisicalConfig`), optional `projectRoot` (local Docker only), `registry`, `flyToken`/`flyApp` (set both for remote Fly.io), `flyGuest` (override Fly Machine guest sizing; default: 16 performance CPUs / 32 GiB), `flyVolumeSizeGb` (override Fly Volume size in GiB; default: 50), `imageRef`, `webhookUrl`/`webhookSecret`, `taskWebhookUrl` (GET endpoint for external task queue), `addTaskWebhookUrl` (POST endpoint for tasks added by `add-task` script), `detached`, `initialPrompt`, `localPort`, `absorbTasks`, `namePrefix` (default: `"app-building"`), `env` (extra env vars to inject into the container; cannot clobber package-reserved vars), `secretAllowlist` (curated `exec-secrets` commands — see Secrets architecture > Allowlist mode). |
84
214
  | `FlyGuest` | Fly Machine guest spec: `cpu_kind` (`"shared"` \| `"performance"`), `cpus`, `memory_mb`. |
215
+ | `SecretAllowlistEntry` | `name` (verb used with `exec-secrets`), `helpString` (one-line description shown by `list-secrets`), `shellCommand` (sh script body; args become `$1`, `$2`, …). |
85
216
  | `RepoOptions` | Per-invocation git settings: `repoUrl`, `cloneBranch`, `pushBranch`. |
86
217
  | `AgentState` | Returned by `startContainer`. Contains `type`, `containerName`, `port`, `baseUrl`, and Fly-specific fields for remote containers. |
87
218
  | `ContainerRegistry` | Interface for container registry storage. Methods: `log`, `markStopped`, `clearStopped`, `getRecent`, `find`, `findAlive`. |
@@ -162,6 +293,7 @@ Each container runs an HTTP server that accepts the following requests:
162
293
  | `POST /detach` | | Signal the container to exit once all tasks are done. |
163
294
  | `POST /stop` | | Force-stop the container immediately. Interrupts any running work, commits remaining changes, then exits. |
164
295
  | `POST /interrupt` | | Kill the currently running Claude process without stopping the container. |
296
+ | `POST /reconfigure` | `{ secretAllowlist?: SecretAllowlistEntry[] \| null }` | Live-update container config. Omit to leave unchanged; `null` for unrestricted mode (`exec-secrets <SECRETS…> -- <cmd>` runs any binary); `[]` for restricted mode with zero entries (rejects every target); an array of entries to replace. Returns `{ ok: true }`. |
165
297
  | `GET /status` | | Container state, queue depth, iteration count, cost, revision, etc. |
166
298
  | `GET /events?offset=N` | | Stream of Claude events (JSON lines) since offset. |
167
299
  | `GET /logs?offset=N` | | Stream of log lines since offset. |
package/dist/index.d.ts CHANGED
@@ -125,6 +125,29 @@ interface ContainerConfig {
125
125
  * always take precedence — values here cannot clobber them.
126
126
  */
127
127
  env?: Record<string, string>;
128
+ /**
129
+ * Allowlist of named shell commands the agent may invoke via `exec-secrets`.
130
+ * When set, `list-secrets` returns the allowlist (instead of raw secret
131
+ * names) and `exec-secrets <name> [args…]` runs the named entry's
132
+ * `shellCommand` with `args` mapped to positional params (`$1`, `$2`, …).
133
+ * All secrets are available in the command's environment; their values are
134
+ * redacted from output. When no allowlist is set, the container runs in
135
+ * unrestricted mode and `exec-secrets <SECRET…> -- <cmd>` may invoke any
136
+ * binary; with an allowlist set, the target after `--` must name an entry.
137
+ */
138
+ secretAllowlist?: SecretAllowlistEntry[];
139
+ }
140
+ interface SecretAllowlistEntry {
141
+ /** Name the agent uses with `exec-secrets <name>` (e.g. "neon-query"). */
142
+ name: string;
143
+ /** One-line description shown by `list-secrets`. */
144
+ helpString: string;
145
+ /**
146
+ * Shell script body. Invoked as `sh -c <shellCommand> exec-secrets <args…>`,
147
+ * so caller-supplied args become `$1`, `$2`, … and `$0` is `exec-secrets`.
148
+ * All secrets are present in the environment.
149
+ */
150
+ shellCommand: string;
128
151
  }
129
152
  interface RepoOptions {
130
153
  repoUrl: string;
@@ -202,4 +225,4 @@ interface Task {
202
225
  */
203
226
  declare function findReadyTask(pendingTasks: Task[], completedTasks: Pick<Task, "id" | "parentTaskId">[]): Task | null;
204
227
 
205
- export { type AgentState, type ContainerConfig, type ContainerRegistry, FileContainerRegistry, type FlyGuest, type HttpOptions, type InfisicalConfig, type RegistryEntry, type RepoOptions, type Task, buildImage, createBranchSecret, fetchBranchSecrets, fetchGlobalSecrets, fetchInfisicalSecrets, findReadyTask, getImageRef, getInfisicalConfig, httpGet, httpOptsFor, httpPost, infisicalLogin, loadDotEnv, probeAlive, spawnTestContainer, startContainer, stopContainer };
228
+ export { type AgentState, type ContainerConfig, type ContainerRegistry, FileContainerRegistry, type FlyGuest, type HttpOptions, type InfisicalConfig, type RegistryEntry, type RepoOptions, type SecretAllowlistEntry, type Task, buildImage, createBranchSecret, fetchBranchSecrets, fetchGlobalSecrets, fetchInfisicalSecrets, findReadyTask, getImageRef, getInfisicalConfig, httpGet, httpOptsFor, httpPost, infisicalLogin, loadDotEnv, probeAlive, spawnTestContainer, startContainer, stopContainer };
package/dist/index.js CHANGED
@@ -256,6 +256,9 @@ function buildExtraEnv(config, containerName) {
256
256
  if (config.env && Object.keys(config.env).length > 0) {
257
257
  extra.AGENT_ENV_PASSTHROUGH = Object.keys(config.env).join(",");
258
258
  }
259
+ if (config.secretAllowlist !== void 0) {
260
+ extra.SECRET_ALLOWLIST_JSON = JSON.stringify(config.secretAllowlist);
261
+ }
259
262
  return extra;
260
263
  }
261
264
  function isRemote(config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio/app-building",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "description": "Library for managing agentic app-building containers",
5
5
  "type": "module",
6
6
  "exports": {