@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 +139 -7
- package/dist/index.d.ts +24 -1
- package/dist/index.js +3 -0
- package/package.json +1 -1
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.
|
|
66
|
+
5. Any time a secret is needed, the caller invokes `exec-secrets`:
|
|
67
67
|
|
|
68
|
-
```bash
|
|
69
|
-
exec-secrets
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|