@openparachute/agent 0.2.3-rc.7 → 0.2.3-rc.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/agent",
3
- "version": "0.2.3-rc.7",
3
+ "version": "0.2.3-rc.8",
4
4
  "description": "Vault-native agents for Claude Code — a #agent/definition note + an inbound message becomes a sandboxed claude turn; the reply is written back as a note. Messaging gateway on :1941.",
5
5
  "license": "AGPL-3.0",
6
6
  "type": "module",
package/src/daemon.ts CHANGED
@@ -66,6 +66,7 @@ import {
66
66
  DEFAULT_HUB_ORIGIN,
67
67
  } from "./def-vaults.ts";
68
68
  import { mintScopedToken, vaultScope } from "./mint-token.ts";
69
+ import { registerAllDefVaultTriggers } from "./def-vault-triggers.ts";
69
70
  import { GrantsClient } from "./grants.ts";
70
71
  import { resolveEffectiveEnv } from "./effective-env.ts";
71
72
  import { VaultJobStore, validateJob, vaultTransportFor, type Job } from "./jobs.ts";
@@ -3531,6 +3532,29 @@ function main(): void {
3531
3532
  const bindings = await resolveDefVaults({ hubOrigin: getHubOrigin(), managerBearer });
3532
3533
  for (const b of bindings) agentDefs.addVault(b);
3533
3534
  if (bindings.length === 0) return; // nothing bound — vault-native path idle.
3535
+
3536
+ // AUTO-REGISTER the per-def-vault runtime triggers (agent#157) so "define an
3537
+ // agent → it runs" needs NO manual trigger setup + NO restart-to-pick-up:
3538
+ // - the def-watch create/edit triggers, BARE-keyed (`agent/definition`) so a
3539
+ // created/edited def auto-fires the rescan — upsert-by-name REPLACES any
3540
+ // stale `#agent/definition`-keyed `conn_agentdefs-*` row the hub provisioned;
3541
+ // - the inbound trigger (`agent/message/inbound` + has_metadata:[agent]) so a
3542
+ // new inbound note wakes the agent without a hand-registered trigger.
3543
+ // Mints the admin (triggers API) + agent:send (webhook bearer) tokens the same
3544
+ // way the hub's Connections engine does (attenuated to the operator bearer).
3545
+ // Best-effort: a mint refusal / unreachable vault is logged, never fatal — the
3546
+ // 60s loadAll poll below stays the correctness floor. Skipped with no operator
3547
+ // bearer (can't mint) — the vault-native path still runs own-vault.
3548
+ if (managerBearer) {
3549
+ await registerAllDefVaultTriggers(bindings, { hubOrigin: getHubOrigin(), managerBearer }).catch(
3550
+ (err) => {
3551
+ console.warn(
3552
+ `parachute-agent: def-vault trigger auto-registration failed (continuing): ${(err as Error).message}`,
3553
+ );
3554
+ },
3555
+ );
3556
+ }
3557
+
3534
3558
  const n = await agentDefs.loadAll();
3535
3559
  console.log(
3536
3560
  `parachute-agent: vault-native agent defs — ${n} instantiated from ${bindings.length} def-vault(s).`,
@@ -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
+ }