@openparachute/agent 0.2.2 → 0.2.3-rc.10
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/.parachute/module.json +3 -3
- package/package.json +4 -1
- package/src/agent-defs.ts +9 -0
- package/src/auth.ts +182 -14
- package/src/backends/registry.ts +65 -27
- package/src/daemon.ts +311 -12
- package/src/def-vault-triggers.ts +317 -0
- package/src/preflight.ts +139 -0
- package/src/spawn-agent.ts +16 -0
- package/src/step-up.ts +316 -0
- package/src/terminal-ui.ts +73 -0
- package/src/transports/http-ui.ts +10 -8
- package/src/transports/vault.ts +40 -22
- package/src/ui-kit.ts +6 -3
- package/src/ui-ticket.ts +121 -0
- package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
- package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
- package/web/ui/dist/index.html +2 -2
- package/src/_parked/interactive-spawn.test.ts +0 -324
- package/src/_parked/interactive-spawn.ts +0 -701
- package/src/agent-defs.test.ts +0 -1504
- package/src/agent-mcp-config.test.ts +0 -115
- package/src/agents.test.ts +0 -360
- package/src/auth.test.ts +0 -46
- package/src/backends/attached-queue.test.ts +0 -376
- package/src/backends/programmatic.test.ts +0 -1715
- package/src/backends/registry.test.ts +0 -1494
- package/src/backends/stream-json.test.ts +0 -570
- package/src/channel-backend-wiring.test.ts +0 -237
- package/src/credentials.test.ts +0 -274
- package/src/cron.test.ts +0 -342
- package/src/daemon-agent-def-api.test.ts +0 -166
- package/src/daemon-agent-defs-api.test.ts +0 -953
- package/src/daemon-agent-env-api.test.ts +0 -338
- package/src/daemon-attached-queue-store.test.ts +0 -65
- package/src/daemon-config-api.test.ts +0 -962
- package/src/daemon-jobs-api.test.ts +0 -271
- package/src/daemon-vault-chat.test.ts +0 -250
- package/src/daemon.test.ts +0 -746
- package/src/def-vaults.test.ts +0 -136
- package/src/delivery-state.test.ts +0 -110
- package/src/effective-env.test.ts +0 -114
- package/src/grants.test.ts +0 -638
- package/src/hub-jwt.test.ts +0 -161
- package/src/jobs.test.ts +0 -245
- package/src/mcp-http.test.ts +0 -265
- package/src/mint-token.test.ts +0 -152
- package/src/module-manifest.test.ts +0 -158
- package/src/programmatic-wiring.test.ts +0 -838
- package/src/registry.test.ts +0 -227
- package/src/resolve-port.test.ts +0 -64
- package/src/routing.test.ts +0 -184
- package/src/runner.test.ts +0 -506
- package/src/sandbox/config.test.ts +0 -150
- package/src/sandbox/egress.test.ts +0 -113
- package/src/sandbox/live-seatbelt.test.ts +0 -277
- package/src/sandbox/mounts.test.ts +0 -154
- package/src/sandbox/sandbox.test.ts +0 -168
- package/src/services-manifest.test.ts +0 -106
- package/src/spa-serve.test.ts +0 -116
- package/src/spawn-agent-cli.test.ts +0 -172
- package/src/spawn-agent.test.ts +0 -1218
- package/src/spawn-deps.test.ts +0 -54
- package/src/terminal-assets.test.ts +0 -50
- package/src/terminal.test.ts +0 -530
- package/src/transports/http-ui.test.ts +0 -455
- package/src/transports/telegram.test.ts +0 -174
- package/src/transports/vault.test.ts +0 -2011
- package/src/ui-kit.test.ts +0 -178
- package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
- package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
- package/web/ui/tsconfig.json +0 -21
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-register the per-def-vault runtime triggers the daemon needs so that
|
|
3
|
+
* "just define an agent in the vault and it works" — no manual trigger setup, no
|
|
4
|
+
* `parachute restart agent` to pick up a new/edited def (agent#157).
|
|
5
|
+
*
|
|
6
|
+
* Two wiring gaps this closes:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Def-watch triggers were stale `#`-keyed.** A def-vault carried
|
|
9
|
+
* `conn_agentdefs-create-<vault>` / `conn_agentdefs-edit-<vault>` triggers
|
|
10
|
+
* keyed on the PRE-canonicalization tag `#agent/definition`. Since defs are
|
|
11
|
+
* now bare `agent/definition`, those triggers never fired → creating/editing
|
|
12
|
+
* a def did NOT auto-rescan; only the 60s `loadAll` poll converged it. We
|
|
13
|
+
* re-register them BARE-keyed on (re)start, upsert-by-name, so the stale
|
|
14
|
+
* `#`-keyed rows are REPLACED in place (the runtime triggers API is an upsert
|
|
15
|
+
* by `name`, and we reuse the hub's `conn_agentdefs-{create,edit}-<vault>`
|
|
16
|
+
* names so our POST overwrites the hub-provisioned ones).
|
|
17
|
+
*
|
|
18
|
+
* 2. **The inbound trigger was never auto-registered.** Defining a def + the
|
|
19
|
+
* daemon discovering it (the channel appears) is NOT enough — nothing wakes
|
|
20
|
+
* the agent until an `agent_inbound` trigger fires the inbound webhook. The
|
|
21
|
+
* hub provisioned it only via the operator's explicit "Connect" click (or the
|
|
22
|
+
* hub Connections builder); a fresh box needed it registered BY HAND. We now
|
|
23
|
+
* register ONE inbound trigger per def-vault on (re)start (one trigger routes
|
|
24
|
+
* ALL agents in the vault by `metadata.agent`).
|
|
25
|
+
*
|
|
26
|
+
* ## How this mirrors the hub's provisioning path
|
|
27
|
+
*
|
|
28
|
+
* The hub's Connections engine (`parachute-hub/src/admin-connections.ts`) already
|
|
29
|
+
* registers these triggers by:
|
|
30
|
+
* - minting a `vault:<v>:admin` token (the triggers API is ADMIN-scoped — a
|
|
31
|
+
* webhook trigger exfiltrates note data, so even listing is admin, not write),
|
|
32
|
+
* - minting an `agent:send` webhook bearer for `action.auth.bearer`,
|
|
33
|
+
* - POSTing `{ name, events, when, action }` to
|
|
34
|
+
* `<vaultUrl>/vault/<v>/api/triggers` (upsert by name),
|
|
35
|
+
* - naming the def-watch triggers `conn_agentdefs-{create,edit}-<vault>`.
|
|
36
|
+
*
|
|
37
|
+
* We do the SAME thing, daemon-side, on boot — reusing the daemon's own
|
|
38
|
+
* `mintScopedToken` (attenuated to the operator bearer) for BOTH mints. This is
|
|
39
|
+
* the credential the hub uses too; the daemon already holds the operator bearer
|
|
40
|
+
* (it mints the def-vault `vault:<v>:write` token the same way), so the admin mint
|
|
41
|
+
* succeeds exactly when the operator's authority covers it.
|
|
42
|
+
*
|
|
43
|
+
* ## Scope caveat (admin mint)
|
|
44
|
+
*
|
|
45
|
+
* The triggers API requires `vault:<v>:admin`. The def-vault token the daemon
|
|
46
|
+
* persists in `agent-vaults.json` is only `vault:<v>:write`, so we CANNOT register
|
|
47
|
+
* triggers with that token — we MINT a short-lived `vault:<v>:admin` token against
|
|
48
|
+
* the operator bearer instead. If the operator bearer's own authority doesn't cover
|
|
49
|
+
* `vault:<v>:admin`, the hub returns `invalid_scope` on the mint (the same bound the
|
|
50
|
+
* hub's own provisioning hits) — we log + skip, never crash boot. The 60s `loadAll`
|
|
51
|
+
* poll remains the correctness floor either way.
|
|
52
|
+
*
|
|
53
|
+
* ## Webhook URL
|
|
54
|
+
*
|
|
55
|
+
* The webhook points at `<hub-origin>/agent/api/vault/{inbound,agent-def}` — the
|
|
56
|
+
* hub origin + the agent module's `/agent` proxy mount + the action endpoint
|
|
57
|
+
* (mirrors the hub's `buildWebhook` and the `AGENT_*_VAULT_TRIGGER_TEMPLATE`
|
|
58
|
+
* placeholders). The hub reverse-proxies `/agent/*` to the loopback daemon, so
|
|
59
|
+
* this works co-located AND exposed; and the daemon validates the webhook bearer's
|
|
60
|
+
* `iss` against the hub origin anyway, so the hub origin is the right base.
|
|
61
|
+
*
|
|
62
|
+
* Best-effort throughout: every failure is caught + logged, never thrown — a
|
|
63
|
+
* def-vault with no admin authority (or an unreachable vault) must never block the
|
|
64
|
+
* daemon from serving, and the poll fallback covers reactivity.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import type { DefVaultBinding } from "./agent-defs.ts";
|
|
68
|
+
import { mintScopedToken, vaultScope, MintError } from "./mint-token.ts";
|
|
69
|
+
import { DEFAULT_DEF_VAULT_URL } from "./def-vaults.ts";
|
|
70
|
+
import { AGENT_VAULT_TRIGGER_TEMPLATE } from "./transports/vault.ts";
|
|
71
|
+
|
|
72
|
+
/** The bare def-discriminator tag the def-watch triggers filter on (post-canonicalization). */
|
|
73
|
+
export const DEFINITION_TAG = "agent/definition";
|
|
74
|
+
/**
|
|
75
|
+
* The inbound trigger's `when` predicate. SOURCED from the existing
|
|
76
|
+
* {@link AGENT_VAULT_TRIGGER_TEMPLATE} (`src/transports/vault.ts`) — the
|
|
77
|
+
* module-owned shape the hub already substitutes — so the daemon's
|
|
78
|
+
* auto-registration produces a SEMANTICALLY IDENTICAL trigger to the
|
|
79
|
+
* hub-Connections path (bare `agent/message/inbound` tag, `has_metadata:[agent]`,
|
|
80
|
+
* `missing_metadata:[channel_inbound_rendered_at]`). Reusing the one source of
|
|
81
|
+
* truth keeps the two registration paths from drifting on the field names.
|
|
82
|
+
*
|
|
83
|
+
* (The real loop guard is the vault engine's own `<triggerName>_rendered_at`
|
|
84
|
+
* marker, checked unconditionally regardless of `missing_metadata`. The
|
|
85
|
+
* `missing_metadata` clause is the module-owned belt — kept identical so both
|
|
86
|
+
* paths upsert cleanly over the same trigger.)
|
|
87
|
+
*/
|
|
88
|
+
const INBOUND_WHEN = AGENT_VAULT_TRIGGER_TEMPLATE.when;
|
|
89
|
+
/** The bare inbound-message child tag the inbound trigger fires on (from the template). */
|
|
90
|
+
export const INBOUND_TAG = INBOUND_WHEN.tags[0];
|
|
91
|
+
|
|
92
|
+
/** The `agent:send` scope minted for every webhook `action.auth.bearer`. */
|
|
93
|
+
const WEBHOOK_SCOPE = "agent:send";
|
|
94
|
+
/** Short TTL for the throwaway `vault:<v>:admin` registration token. */
|
|
95
|
+
const ADMIN_MINT_TTL_SECONDS = 60;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The shape POSTed to `<vaultUrl>/vault/<v>/api/triggers`. The vault validates
|
|
99
|
+
* `events` ⊆ {created, updated} (NO `deleted` — so the def-watch is two triggers,
|
|
100
|
+
* create + edit, not one create/updated/deleted trigger).
|
|
101
|
+
*/
|
|
102
|
+
export interface VaultTriggerInput {
|
|
103
|
+
name: string;
|
|
104
|
+
events: Array<"created" | "updated">;
|
|
105
|
+
when: {
|
|
106
|
+
tags: string[];
|
|
107
|
+
has_metadata?: string[];
|
|
108
|
+
missing_metadata?: string[];
|
|
109
|
+
};
|
|
110
|
+
action: {
|
|
111
|
+
webhook: string;
|
|
112
|
+
send: "json";
|
|
113
|
+
auth?: { bearer: string };
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The stable def-watch trigger name for a (vault, kind). MUST match the hub's
|
|
119
|
+
* `conn_${defReloadId(vault, kind)}` (web/ui/src/lib/hub.ts → `agentdefs-<kind>-<vault>`,
|
|
120
|
+
* hub admin-connections → `conn_<id>`) so our upsert REPLACES any stale `#`-keyed
|
|
121
|
+
* trigger the hub provisioned, rather than orphaning it alongside a new one.
|
|
122
|
+
*/
|
|
123
|
+
export function defWatchTriggerName(vault: string, kind: "create" | "edit"): string {
|
|
124
|
+
return `conn_agentdefs-${kind}-${vault}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The inbound trigger name for a vault. ONE per def-vault — it routes ALL agents
|
|
129
|
+
* by `metadata.agent`, so it isn't per-agent. Stable so the POST upserts in place.
|
|
130
|
+
*/
|
|
131
|
+
export function inboundTriggerName(vault: string): string {
|
|
132
|
+
return `conn_agentinbound-${vault}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Build the webhook URL `<hub-origin>/agent/api/vault/<endpoint>`. */
|
|
136
|
+
function buildWebhook(hubOrigin: string, endpoint: string): string {
|
|
137
|
+
const origin = hubOrigin.replace(/\/+$/, "");
|
|
138
|
+
const ep = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
|
139
|
+
return `${origin}/agent${ep}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The three triggers a def-vault needs, bare-keyed, with the webhook bearer filled.
|
|
144
|
+
* Exported for tests (asserts the bare tag + the trigger shapes without the live mints).
|
|
145
|
+
*/
|
|
146
|
+
export function buildDefVaultTriggers(
|
|
147
|
+
vault: string,
|
|
148
|
+
hubOrigin: string,
|
|
149
|
+
webhookBearer: string,
|
|
150
|
+
): VaultTriggerInput[] {
|
|
151
|
+
const defWebhook = buildWebhook(hubOrigin, "/api/vault/agent-def");
|
|
152
|
+
const inboundWebhook = buildWebhook(hubOrigin, "/api/vault/inbound");
|
|
153
|
+
const auth = { bearer: webhookBearer };
|
|
154
|
+
return [
|
|
155
|
+
// Def-watch CREATE — a new bare `agent/definition` note instantiates its agent live.
|
|
156
|
+
{
|
|
157
|
+
name: defWatchTriggerName(vault, "create"),
|
|
158
|
+
events: ["created"],
|
|
159
|
+
when: { tags: [DEFINITION_TAG] },
|
|
160
|
+
action: { webhook: defWebhook, send: "json", auth },
|
|
161
|
+
},
|
|
162
|
+
// Def-watch EDIT — an edited bare `agent/definition` note re-instantiates its agent.
|
|
163
|
+
{
|
|
164
|
+
name: defWatchTriggerName(vault, "edit"),
|
|
165
|
+
events: ["updated"],
|
|
166
|
+
when: { tags: [DEFINITION_TAG] },
|
|
167
|
+
action: { webhook: defWebhook, send: "json", auth },
|
|
168
|
+
},
|
|
169
|
+
// INBOUND — a new bare `agent/message/inbound` note (routed by metadata.agent,
|
|
170
|
+
// not yet rendered) wakes the agent. One trigger routes every agent in the vault.
|
|
171
|
+
// `when` is the module-owned shape from AGENT_VAULT_TRIGGER_TEMPLATE (copied so
|
|
172
|
+
// the predicate matches the hub-Connections path exactly).
|
|
173
|
+
{
|
|
174
|
+
name: inboundTriggerName(vault),
|
|
175
|
+
events: ["created"],
|
|
176
|
+
when: {
|
|
177
|
+
tags: [...INBOUND_WHEN.tags],
|
|
178
|
+
has_metadata: [...INBOUND_WHEN.has_metadata],
|
|
179
|
+
missing_metadata: [...INBOUND_WHEN.missing_metadata],
|
|
180
|
+
},
|
|
181
|
+
action: { webhook: inboundWebhook, send: "json", auth },
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Dependencies for {@link registerDefVaultTriggers} (injected for tests). */
|
|
187
|
+
export interface RegisterTriggersDeps {
|
|
188
|
+
/** Hub public origin (the webhook base + the mint endpoint + the JWT `iss`). */
|
|
189
|
+
hubOrigin: string;
|
|
190
|
+
/** The operator bearer the per-resource mints attenuate against. */
|
|
191
|
+
managerBearer: string;
|
|
192
|
+
/** Inject fetch for tests. Defaults to global fetch. */
|
|
193
|
+
fetchFn?: typeof fetch;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The outcome of registering one def-vault's triggers (for logging/tests). */
|
|
197
|
+
export interface RegisterTriggersResult {
|
|
198
|
+
vault: string;
|
|
199
|
+
/** Trigger names that registered (HTTP 200). */
|
|
200
|
+
registered: string[];
|
|
201
|
+
/** `name: reason` for each failure (mint refusal, vault non-2xx, fetch error). */
|
|
202
|
+
failures: string[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Register (idempotent upsert) the def-watch + inbound triggers for ONE def-vault.
|
|
207
|
+
* Mints a `vault:<v>:admin` token for the triggers API and an `agent:send` token
|
|
208
|
+
* for the webhook bearer, then POSTs each trigger. Best-effort: never throws — a
|
|
209
|
+
* mint refusal (insufficient operator authority) or an unreachable vault is logged
|
|
210
|
+
* and returned in `failures`. The poll fallback is the correctness floor.
|
|
211
|
+
*/
|
|
212
|
+
export async function registerDefVaultTriggers(
|
|
213
|
+
binding: DefVaultBinding,
|
|
214
|
+
deps: RegisterTriggersDeps,
|
|
215
|
+
): Promise<RegisterTriggersResult> {
|
|
216
|
+
const vault = binding.vault;
|
|
217
|
+
const vaultUrl = (binding.vaultUrl ?? DEFAULT_DEF_VAULT_URL).replace(/\/+$/, "");
|
|
218
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
219
|
+
const result: RegisterTriggersResult = { vault, registered: [], failures: [] };
|
|
220
|
+
|
|
221
|
+
const mintDeps = {
|
|
222
|
+
hubOrigin: deps.hubOrigin,
|
|
223
|
+
managerBearer: deps.managerBearer,
|
|
224
|
+
...(deps.fetchFn ? { fetchFn: deps.fetchFn } : {}),
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// 1. Mint the ADMIN token for the triggers API FIRST (the def-vault write token
|
|
228
|
+
// can't register triggers — admin-scoped endpoint). This is the mint most
|
|
229
|
+
// likely to be refused (a `vault:<v>:write`-only operator can't mint admin), so
|
|
230
|
+
// minting it first short-circuits a restricted install before the second mint.
|
|
231
|
+
// On `invalid_scope` the hub refuses here — log + skip, never crash boot.
|
|
232
|
+
let adminToken: string;
|
|
233
|
+
try {
|
|
234
|
+
const minted = await mintScopedToken(
|
|
235
|
+
{ scope: vaultScope(vault, "admin"), expiresIn: ADMIN_MINT_TTL_SECONDS },
|
|
236
|
+
mintDeps,
|
|
237
|
+
);
|
|
238
|
+
adminToken = minted.token;
|
|
239
|
+
} catch (err) {
|
|
240
|
+
const detail = err instanceof MintError ? err.message : (err as Error).message;
|
|
241
|
+
result.failures.push(`mint ${vaultScope(vault, "admin")}: ${detail}`);
|
|
242
|
+
return result; // No admin token → can't POST triggers.
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 2. Mint the webhook bearer (agent:send) — the trigger fires this at the daemon.
|
|
246
|
+
// Long-lived (the hub's default ~90d, matching the hub's own provisioning) since
|
|
247
|
+
// it lives in the trigger's persistent `action.auth.bearer`; re-minted on each
|
|
248
|
+
// restart's re-registration. A refusal here also skips (no bearer → no trigger).
|
|
249
|
+
let webhookBearer: string;
|
|
250
|
+
try {
|
|
251
|
+
const minted = await mintScopedToken({ scope: WEBHOOK_SCOPE }, mintDeps);
|
|
252
|
+
webhookBearer = minted.token;
|
|
253
|
+
} catch (err) {
|
|
254
|
+
const detail = err instanceof MintError ? err.message : (err as Error).message;
|
|
255
|
+
result.failures.push(`mint ${WEBHOOK_SCOPE}: ${detail}`);
|
|
256
|
+
return result; // No bearer → no trigger can be registered.
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 3. POST each trigger (upsert by name).
|
|
260
|
+
const triggers = buildDefVaultTriggers(vault, deps.hubOrigin, webhookBearer);
|
|
261
|
+
const url = `${vaultUrl}/vault/${vault}/api/triggers`;
|
|
262
|
+
for (const trigger of triggers) {
|
|
263
|
+
try {
|
|
264
|
+
const res = await fetchFn(url, {
|
|
265
|
+
method: "POST",
|
|
266
|
+
headers: {
|
|
267
|
+
"content-type": "application/json",
|
|
268
|
+
authorization: `Bearer ${adminToken}`,
|
|
269
|
+
},
|
|
270
|
+
body: JSON.stringify(trigger),
|
|
271
|
+
});
|
|
272
|
+
if (res.ok) {
|
|
273
|
+
result.registered.push(trigger.name);
|
|
274
|
+
} else {
|
|
275
|
+
const detail = await res.text().catch(() => "");
|
|
276
|
+
result.failures.push(`${trigger.name}: HTTP ${res.status} ${detail}`.trim());
|
|
277
|
+
}
|
|
278
|
+
} catch (err) {
|
|
279
|
+
result.failures.push(`${trigger.name}: ${(err as Error).message}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Register triggers for EVERY def-vault binding. Best-effort + sequential (a
|
|
287
|
+
* handful of vaults at most); a per-vault failure never blocks the others. Logs a
|
|
288
|
+
* one-line summary per vault. Returns the per-vault results (for tests).
|
|
289
|
+
*/
|
|
290
|
+
export async function registerAllDefVaultTriggers(
|
|
291
|
+
bindings: DefVaultBinding[],
|
|
292
|
+
deps: RegisterTriggersDeps,
|
|
293
|
+
): Promise<RegisterTriggersResult[]> {
|
|
294
|
+
const results: RegisterTriggersResult[] = [];
|
|
295
|
+
for (const binding of bindings) {
|
|
296
|
+
const r = await registerDefVaultTriggers(binding, deps).catch(
|
|
297
|
+
(err): RegisterTriggersResult => ({
|
|
298
|
+
vault: binding.vault,
|
|
299
|
+
registered: [],
|
|
300
|
+
failures: [`unexpected: ${(err as Error).message}`],
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
results.push(r);
|
|
304
|
+
if (r.failures.length === 0) {
|
|
305
|
+
console.log(
|
|
306
|
+
`parachute-agent: def-vault "${r.vault}" — auto-registered ${r.registered.length} trigger(s) ` +
|
|
307
|
+
`(def-watch create/edit + inbound, bare-keyed).`,
|
|
308
|
+
);
|
|
309
|
+
} else {
|
|
310
|
+
console.warn(
|
|
311
|
+
`parachute-agent: def-vault "${r.vault}" — registered ${r.registered.length}, ` +
|
|
312
|
+
`${r.failures.length} failed (continuing; the 60s poll is the correctness floor): ${r.failures.join("; ")}`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return results;
|
|
317
|
+
}
|
package/src/preflight.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-time dependency PREFLIGHT (agent#156).
|
|
3
|
+
*
|
|
4
|
+
* A freshly-provisioned box can't run a programmatic `claude -p` turn until the
|
|
5
|
+
* sandbox deps (`bwrap`, `rg`, `socat`) AND the `claude` CLI are installed — but
|
|
6
|
+
* pre-#156 each missing piece surfaced ONLY as a failed *turn*, one at a time, so
|
|
7
|
+
* an operator discovered them serially (install bwrap → next turn fails on rg →
|
|
8
|
+
* install rg → next turn fails on claude → …).
|
|
9
|
+
*
|
|
10
|
+
* This lifts the check to DAEMON BOOT: resolve each required binary on PATH ONCE
|
|
11
|
+
* and log a single clear warning naming exactly what's missing + the one-liner to
|
|
12
|
+
* fix it. It is a WARNING, never a crash — the daemon may run only `attached`-backend
|
|
13
|
+
* agents (which don't spawn `claude -p` and need no sandbox/claude), so a missing
|
|
14
|
+
* dep means "programmatic turns will fail until …", not "the daemon can't start."
|
|
15
|
+
*
|
|
16
|
+
* Deliberately NOT a full doctor framework — a focused boot preflight + clear log is
|
|
17
|
+
* the whole of #156. (`spawn-deps.ts`'s turn-time check still stands as the last line
|
|
18
|
+
* of defence for a dep removed AFTER boot.)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One required external binary the programmatic backend needs on PATH, with the
|
|
23
|
+
* one-liner that installs it on a fresh Debian/Ubuntu box (the #156 reproduction).
|
|
24
|
+
*/
|
|
25
|
+
interface RequiredDep {
|
|
26
|
+
/** The binary name resolved on PATH (`Bun.which`). */
|
|
27
|
+
bin: string;
|
|
28
|
+
/** Human label for the warning. */
|
|
29
|
+
label: string;
|
|
30
|
+
/** The install hint shown when it's missing. */
|
|
31
|
+
hint: string;
|
|
32
|
+
/**
|
|
33
|
+
* True when this dep is only required on LINUX. On macOS the sandbox uses Seatbelt
|
|
34
|
+
* (built in, no helper binaries), so the bubblewrap egress-proxy deps (`bwrap`,
|
|
35
|
+
* `socat`) aren't needed — flagging them on a Mac deploy (the documented preferred
|
|
36
|
+
* self-host path) would be a false-positive that trains operators to ignore the
|
|
37
|
+
* preflight. So they're checked on Linux only. (`rg` is NOT linux-only: the runtime's
|
|
38
|
+
* deny-path scan needs a real ripgrep on macOS too. `claude` is needed everywhere.)
|
|
39
|
+
*/
|
|
40
|
+
linuxOnly?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The deps a programmatic `claude -p` turn needs. `bwrap`/`socat` are the LINUX
|
|
45
|
+
* bubblewrap sandbox deps the runtime shells out to (bubblewrap is the containment,
|
|
46
|
+
* socat bridges the egress proxy) — not needed under macOS Seatbelt, so `linuxOnly`.
|
|
47
|
+
* `rg` (ripgrep) does the deny-path scan on EVERY platform (the macOS sandbox needs a
|
|
48
|
+
* real `rg` too). `claude` is the CLI the turn runs, required everywhere. The platform
|
|
49
|
+
* filter is applied in {@link checkProgrammaticDeps}.
|
|
50
|
+
*/
|
|
51
|
+
export const REQUIRED_DEPS: readonly RequiredDep[] = [
|
|
52
|
+
{ bin: "bwrap", label: "bubblewrap (bwrap)", hint: "apt install bubblewrap", linuxOnly: true },
|
|
53
|
+
{ bin: "rg", label: "ripgrep (rg)", hint: "apt install ripgrep" },
|
|
54
|
+
{ bin: "socat", label: "socat", hint: "apt install socat", linuxOnly: true },
|
|
55
|
+
{
|
|
56
|
+
bin: "claude",
|
|
57
|
+
label: "Claude Code CLI (claude)",
|
|
58
|
+
hint: "curl -fsSL https://claude.ai/install.sh | bash (native build — no node/npm needed)",
|
|
59
|
+
},
|
|
60
|
+
] as const;
|
|
61
|
+
|
|
62
|
+
/** A resolver from binary name → absolute path (or null when not on PATH). Injectable for tests. */
|
|
63
|
+
export type WhichFn = (bin: string) => string | null;
|
|
64
|
+
|
|
65
|
+
/** The default resolver — Bun.which against the daemon's PATH. */
|
|
66
|
+
export const realWhich: WhichFn = (bin) => Bun.which(bin);
|
|
67
|
+
|
|
68
|
+
/** Which {@link REQUIRED_DEPS} apply on the given platform (drops `linuxOnly` deps off Linux). */
|
|
69
|
+
export function depsForPlatform(platform: NodeJS.Platform = process.platform): RequiredDep[] {
|
|
70
|
+
return REQUIRED_DEPS.filter((d) => !d.linuxOnly || platform === "linux");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The outcome of {@link checkProgrammaticDeps}: which required deps are missing + a ready-to-log warning. */
|
|
74
|
+
export interface PreflightResult {
|
|
75
|
+
/** The deps NOT resolvable on PATH (empty = all present). */
|
|
76
|
+
missing: RequiredDep[];
|
|
77
|
+
/** True when every required dep resolved (nothing to warn about). */
|
|
78
|
+
ok: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* The formatted multi-line warning to log, or null when nothing is missing. Lists
|
|
81
|
+
* each missing dep + its install one-liner, framed as "programmatic turns will fail
|
|
82
|
+
* until …" (attached-backend agents are unaffected).
|
|
83
|
+
*/
|
|
84
|
+
warning: string | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* PURE check: resolve each platform-applicable {@link REQUIRED_DEPS} binary via `which`
|
|
89
|
+
* and build the missing-deps result + warning text. No I/O beyond the injected `which`;
|
|
90
|
+
* no logging (the caller logs). Cheap + idempotent — safe to call at boot. `platform` is
|
|
91
|
+
* injectable so a test can assert the macOS filter without running on a Mac.
|
|
92
|
+
*/
|
|
93
|
+
export function checkProgrammaticDeps(
|
|
94
|
+
which: WhichFn = realWhich,
|
|
95
|
+
platform: NodeJS.Platform = process.platform,
|
|
96
|
+
): PreflightResult {
|
|
97
|
+
const missing = depsForPlatform(platform).filter((d) => {
|
|
98
|
+
try {
|
|
99
|
+
return !which(d.bin);
|
|
100
|
+
} catch {
|
|
101
|
+
// A which() fault is treated as "can't confirm it's present" → report it missing
|
|
102
|
+
// (better a spurious advisory than silently swallowing a real gap).
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
if (missing.length === 0) return { missing: [], ok: true, warning: null };
|
|
107
|
+
const lines = missing.map((d) => ` - ${d.label}: ${d.hint}`);
|
|
108
|
+
const warning =
|
|
109
|
+
`parachute-agent: PREFLIGHT — ${missing.length} dependency/dependencies for programmatic ` +
|
|
110
|
+
`(claude -p) turns is/are NOT on PATH. Programmatic-backend turns will FAIL until installed ` +
|
|
111
|
+
`(attached-backend agents are unaffected):\n${lines.join("\n")}`;
|
|
112
|
+
return { missing, ok: false, warning };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Run the boot preflight: check the deps and LOG the warning once (via `console.warn`)
|
|
117
|
+
* when anything is missing. Returns the {@link PreflightResult} so the caller can also
|
|
118
|
+
* surface the missing-deps state elsewhere (e.g. `/health`). Never throws — the daemon
|
|
119
|
+
* keeps booting regardless.
|
|
120
|
+
*/
|
|
121
|
+
export function runBootPreflight(
|
|
122
|
+
which: WhichFn = realWhich,
|
|
123
|
+
platform: NodeJS.Platform = process.platform,
|
|
124
|
+
): PreflightResult {
|
|
125
|
+
let result: PreflightResult;
|
|
126
|
+
try {
|
|
127
|
+
result = checkProgrammaticDeps(which, platform);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
// Defensive: the preflight must never break boot. An unexpected fault is reported
|
|
130
|
+
// HONESTLY (ok:false + the error in the warning) rather than a false "all clear" —
|
|
131
|
+
// but it's still non-fatal; the daemon boots and the turn-time check in
|
|
132
|
+
// spawn-deps.ts remains the real guard.
|
|
133
|
+
const msg = `parachute-agent: boot preflight errored (continuing, dependency state UNKNOWN): ${(err as Error).message}`;
|
|
134
|
+
console.error(msg);
|
|
135
|
+
return { missing: [], ok: false, warning: msg };
|
|
136
|
+
}
|
|
137
|
+
if (result.warning) console.warn(result.warning);
|
|
138
|
+
return result;
|
|
139
|
+
}
|
package/src/spawn-agent.ts
CHANGED
|
@@ -428,6 +428,22 @@ export function buildAgentChildEnv(
|
|
|
428
428
|
}
|
|
429
429
|
if (!out.PATH) out.PATH = "/usr/local/bin:/usr/bin:/bin";
|
|
430
430
|
|
|
431
|
+
// IS_SANDBOX=1 — signal to claude that it is running INSIDE a sandbox (agent#155).
|
|
432
|
+
// The programmatic turn always launches inside a bwrap/Seatbelt sandbox (that IS the
|
|
433
|
+
// containment), so `--dangerously-skip-permissions` is safe — but Claude Code REFUSES
|
|
434
|
+
// that flag under root/sudo ("cannot be used with root/sudo privileges for security
|
|
435
|
+
// reasons") UNLESS `IS_SANDBOX` is set, which makes EVERY turn error on a daemon that
|
|
436
|
+
// runs as root (e.g. the friends/team box). Setting it here makes the fix permanent +
|
|
437
|
+
// automatic (it was being worked around per-deploy via the env store, which is lost on
|
|
438
|
+
// reset). It defaults to "1" for every sandboxed turn but honors an explicit operator
|
|
439
|
+
// value from `channelEnv` (already laid down above) — so an operator who deliberately
|
|
440
|
+
// sets it can still override. NB: IS_SANDBOX is NOT in SANDBOX_ENV_ALLOWLIST and is not
|
|
441
|
+
// set by `seedAgentHome`, so it survives `mergeSandboxLaunchEnv` un-clobbered — it can
|
|
442
|
+
// never be reset to empty by the env-merge layering.
|
|
443
|
+
if (typeof out.IS_SANDBOX !== "string" || out.IS_SANDBOX.length === 0) {
|
|
444
|
+
out.IS_SANDBOX = "1";
|
|
445
|
+
}
|
|
446
|
+
|
|
431
447
|
// The interactive subscription credential (design §6). Explicitly the ONLY
|
|
432
448
|
// Claude auth var set; ANTHROPIC_API_KEY is intentionally absent. Set LAST so no
|
|
433
449
|
// channel-injected var can ever override the session's managed auth.
|