@openparachute/agent 0.2.2 → 0.2.3-rc.11

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 (74) hide show
  1. package/.parachute/module.json +3 -3
  2. package/package.json +4 -1
  3. package/src/agent-defs.ts +9 -0
  4. package/src/auth.ts +182 -14
  5. package/src/backends/programmatic.ts +35 -2
  6. package/src/backends/registry.ts +159 -40
  7. package/src/backends/types.ts +44 -0
  8. package/src/daemon.ts +317 -12
  9. package/src/def-vault-triggers.ts +317 -0
  10. package/src/preflight.ts +139 -0
  11. package/src/spawn-agent.ts +16 -0
  12. package/src/step-up.ts +316 -0
  13. package/src/terminal-ui.ts +73 -0
  14. package/src/transports/http-ui.ts +10 -8
  15. package/src/transports/vault.ts +48 -27
  16. package/src/ui-kit.ts +6 -3
  17. package/src/ui-ticket.ts +121 -0
  18. package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
  19. package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
  20. package/web/ui/dist/index.html +2 -2
  21. package/src/_parked/interactive-spawn.test.ts +0 -324
  22. package/src/_parked/interactive-spawn.ts +0 -701
  23. package/src/agent-defs.test.ts +0 -1504
  24. package/src/agent-mcp-config.test.ts +0 -115
  25. package/src/agents.test.ts +0 -360
  26. package/src/auth.test.ts +0 -46
  27. package/src/backends/attached-queue.test.ts +0 -376
  28. package/src/backends/programmatic.test.ts +0 -1715
  29. package/src/backends/registry.test.ts +0 -1494
  30. package/src/backends/stream-json.test.ts +0 -570
  31. package/src/channel-backend-wiring.test.ts +0 -237
  32. package/src/credentials.test.ts +0 -274
  33. package/src/cron.test.ts +0 -342
  34. package/src/daemon-agent-def-api.test.ts +0 -166
  35. package/src/daemon-agent-defs-api.test.ts +0 -953
  36. package/src/daemon-agent-env-api.test.ts +0 -338
  37. package/src/daemon-attached-queue-store.test.ts +0 -65
  38. package/src/daemon-config-api.test.ts +0 -962
  39. package/src/daemon-jobs-api.test.ts +0 -271
  40. package/src/daemon-vault-chat.test.ts +0 -250
  41. package/src/daemon.test.ts +0 -746
  42. package/src/def-vaults.test.ts +0 -136
  43. package/src/delivery-state.test.ts +0 -110
  44. package/src/effective-env.test.ts +0 -114
  45. package/src/grants.test.ts +0 -638
  46. package/src/hub-jwt.test.ts +0 -161
  47. package/src/jobs.test.ts +0 -245
  48. package/src/mcp-http.test.ts +0 -265
  49. package/src/mint-token.test.ts +0 -152
  50. package/src/module-manifest.test.ts +0 -158
  51. package/src/programmatic-wiring.test.ts +0 -838
  52. package/src/registry.test.ts +0 -227
  53. package/src/resolve-port.test.ts +0 -64
  54. package/src/routing.test.ts +0 -184
  55. package/src/runner.test.ts +0 -506
  56. package/src/sandbox/config.test.ts +0 -150
  57. package/src/sandbox/egress.test.ts +0 -113
  58. package/src/sandbox/live-seatbelt.test.ts +0 -277
  59. package/src/sandbox/mounts.test.ts +0 -154
  60. package/src/sandbox/sandbox.test.ts +0 -168
  61. package/src/services-manifest.test.ts +0 -106
  62. package/src/spa-serve.test.ts +0 -116
  63. package/src/spawn-agent-cli.test.ts +0 -172
  64. package/src/spawn-agent.test.ts +0 -1218
  65. package/src/spawn-deps.test.ts +0 -54
  66. package/src/terminal-assets.test.ts +0 -50
  67. package/src/terminal.test.ts +0 -530
  68. package/src/transports/http-ui.test.ts +0 -455
  69. package/src/transports/telegram.test.ts +0 -174
  70. package/src/transports/vault.test.ts +0 -2011
  71. package/src/ui-kit.test.ts +0 -178
  72. package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
  73. package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
  74. package/web/ui/tsconfig.json +0 -21
@@ -52,7 +52,7 @@
52
52
  "module": "vault",
53
53
  "event": "note.created",
54
54
  "filter": {
55
- "tags": ["#agent/message/inbound"],
55
+ "tags": ["agent/message/inbound"],
56
56
  "has_metadata": ["channel"],
57
57
  "missing_metadata": ["channel_inbound_rendered_at"]
58
58
  }
@@ -85,7 +85,7 @@
85
85
  "module": "vault",
86
86
  "event": "note.created",
87
87
  "filter": {
88
- "tags": ["#agent/definition"]
88
+ "tags": ["agent/definition"]
89
89
  }
90
90
  },
91
91
  "sink": {
@@ -110,7 +110,7 @@
110
110
  "module": "vault",
111
111
  "event": "note.updated",
112
112
  "filter": {
113
- "tags": ["#agent/definition"]
113
+ "tags": ["agent/definition"]
114
114
  }
115
115
  },
116
116
  "sink": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/agent",
3
- "version": "0.2.2",
3
+ "version": "0.2.3-rc.11",
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",
@@ -24,6 +24,8 @@
24
24
  "test:spa": "cd web/ui && bun run test",
25
25
  "test:all": "bun run test && bun run test:spa",
26
26
  "test:e2e": "bun e2e/llm/run.ts",
27
+ "lint": "biome check .",
28
+ "lint:fix": "biome check --write .",
27
29
  "typecheck": "tsc --noEmit",
28
30
  "build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
29
31
  "prepack": "bun run build:spa"
@@ -39,6 +41,7 @@
39
41
  "typescript": "^5"
40
42
  },
41
43
  "devDependencies": {
44
+ "@biomejs/biome": "^1.9.4",
42
45
  "@types/bun": "latest"
43
46
  },
44
47
  "repository": {
package/src/agent-defs.ts CHANGED
@@ -389,6 +389,15 @@ export function parseAgentDef(note: {
389
389
  }
390
390
 
391
391
  // Filesystem read scope.
392
+ //
393
+ // NOTE (step-up, agent#80): `filesystem: "full"` is the dangerous, full-disk
394
+ // case. The step-up PIN gate is enforced on the HTTP spawn path only
395
+ // (`POST /api/agents` in daemon.ts). This VAULT-NATIVE path (a #agent/definition
396
+ // note with `filesystem: full`) is NOT step-up-gated — registering it requires
397
+ // `vault:write` to author the note, which is itself separately scope-gated, so a
398
+ // step-up challenge here would gate a capability the caller already had to hold a
399
+ // write credential to reach. If the threat model is ever revisited (e.g. less-
400
+ // trusted note authors), this is the gap to close.
392
401
  const filesystem = metaStr(meta.filesystem);
393
402
  if (filesystem !== undefined) {
394
403
  if (filesystem !== "workspace" && filesystem !== "full") {
package/src/auth.ts CHANGED
@@ -7,13 +7,26 @@
7
7
  * - Layer 1 (bridge / session↔channel): the bridge presents the token as an
8
8
  * `Authorization: Bearer` header on `/events` + `/api/*`.
9
9
  * - Layer 2 (human / chat UI): the page fetches a short-lived token from the
10
- * hub (`/admin/agent-token`) and attaches it as a Bearer header on the
11
- * `send` POST, and as a `?token=` query param on the `/ui/events` SSE
12
- * (EventSource can't set headers).
10
+ * hub (`/admin/agent-token`) and attaches it as a Bearer header on the
11
+ * `send` POST. For the browser SSE streams (`/ui/events`,
12
+ * `/api/channels/<ch>/turn-events`) — which an `EventSource` can't set a
13
+ * header on — the page does NOT put the JWT in the URL. Instead it mints a
14
+ * one-time SSE TICKET (`POST /api/ui/sse-ticket`, Bearer-authenticated) and
15
+ * opens `…?ticket=<nonce>`. See `requireSseTicket` below + `ui-ticket.ts`.
13
16
  *
14
- * `requireScope` accepts the token from EITHER source so one helper guards both
15
- * layers. The no-token path short-circuits before any JWKS fetch, keeping it
16
- * unit-testable without a live hub (same approach Layer 1 used).
17
+ * `requireScope` accepts the token from a Bearer header (and, for the
18
+ * agent:admin terminal WebSocket only, a `?token=` query param). The no-token
19
+ * path short-circuits before any JWKS fetch, keeping it unit-testable without a
20
+ * live hub (same approach Layer 1 used).
21
+ *
22
+ * WHY THE TICKET (agent#25). A full hub JWT in a `?token=` URL lands in any
23
+ * access/proxy log, browser history, or network trace — a credential leak
24
+ * mitigated before only by the token's short TTL. The browser SSE endpoints now
25
+ * trade the JWT for an opaque, single-use, ≤60s ticket (`requireSseTicket`); the
26
+ * JWT only ever travels in a `fetch` Bearer header. The legacy `?token=` SSE
27
+ * path was REMOVED (pre-1.0, no deprecation window). The terminal WebSocket
28
+ * (`agent:admin`) still uses `?token=` — a separate, operator-gated mechanism
29
+ * out of this change's scope.
17
30
  *
18
31
  * DUAL-ACCEPT (channel→agent rename transition,
19
32
  * `parachute-patterns/migrations/2026-06-17-channel-to-agent.md` rule 1). New
@@ -26,6 +39,8 @@
26
39
 
27
40
  import { validateHubJwt, HubJwtError } from "./hub-jwt.ts";
28
41
  import { extractBearer } from "@openparachute/scope-guard";
42
+ import { consumeTicket } from "./ui-ticket.ts";
43
+ import { isStepUpTokenValid, isStepUpConfigured } from "./step-up.ts";
29
44
 
30
45
  /** Agent scopes, declared here so callers share one spelling. */
31
46
  export const SCOPE_READ = "agent:read" as const;
@@ -79,15 +94,18 @@ export function json(data: unknown, status = 200): Response {
79
94
 
80
95
  /**
81
96
  * Extract a presented token from a request: the `Authorization: Bearer` header
82
- * first (the bridge + the UI's POST), falling back to a `?token=` query param
83
- * (the SSE case `EventSource` can't set headers). Returns null if neither is
84
- * present.
97
+ * first (the bridge, the UI's POST, the SSE-ticket mint), falling back to a
98
+ * `?token=` query param only when `allowQueryParam` is set. The ONLY caller that
99
+ * opts into the query param is the agent:admin terminal WebSocket
100
+ * (`new WebSocket()` can't set headers); the browser SSE streams moved to the
101
+ * one-time-ticket path (`requireSseTicket`) so a JWT never rides in a URL. Returns
102
+ * null if neither source is present.
85
103
  */
86
104
  export function extractToken(req: Request, url: URL, allowQueryParam = false): string | null {
87
105
  const bearer = extractBearer(req.headers.get("authorization"));
88
106
  if (bearer) return bearer;
89
- // `?token=` is opt-in (the SSE case only). The bridge + the UI POST present a
90
- // Bearer header, so they never enable it — keeps query-param tokens off every
107
+ // `?token=` is opt-in (the terminal WebSocket only). Every other caller presents
108
+ // a Bearer header, so they leave it false keeping query-param JWTs off every
91
109
  // endpoint that doesn't strictly need them (and out of those access logs).
92
110
  if (allowQueryParam) {
93
111
  const q = url.searchParams.get("token");
@@ -99,9 +117,10 @@ export function extractToken(req: Request, url: URL, allowQueryParam = false): s
99
117
  /**
100
118
  * Guard an HTTP endpoint on a hub-issued JWT carrying `scope`. The token arrives
101
119
  * as an `Authorization: Bearer` header; pass `allowQueryParam: true` to also
102
- * accept a `?token=` query param (the SSE case only — `EventSource` can't set
103
- * headers). Bridge + UI-POST callers leave it false, so query-param tokens are
104
- * confined to the one endpoint that needs them.
120
+ * accept a `?token=` query param (the agent:admin terminal WebSocket only —
121
+ * `new WebSocket()` can't set headers). All other callers leave it false, so
122
+ * query-param JWTs are confined to that one endpoint. Browser SSE streams use
123
+ * `requireSseTicket` (the one-time ticket), not this.
105
124
  *
106
125
  * Returns `null` when the request is authorized (caller proceeds), or a
107
126
  * `Response` (401/403) the caller must return as-is.
@@ -138,3 +157,152 @@ export async function requireScope(
138
157
  );
139
158
  }
140
159
  }
160
+
161
+ /**
162
+ * Mint endpoint for a one-time SSE ticket (agent#25). Authenticate the presented
163
+ * Bearer JWT for `scope` (the SAME validation `requireScope` runs — no-token →
164
+ * 401 pre-JWKS, bad/insufficient → 401/403), then issue a single-use, ≤60s
165
+ * opaque ticket carrying ONLY the token's validated scopes. The ticket — never
166
+ * the JWT — goes in the SSE URL. Returns the mint `Response` (200 `{ ticket,
167
+ * expires_at }`, or the gate's 401/403) for the caller to return as-is.
168
+ *
169
+ * `mintTicket` is injected (defaults to the real `ui-ticket.ts` store) so unit
170
+ * tests can assert what scopes get carried without reaching into the singleton.
171
+ * Critically, an UNAUTHENTICATED mint is impossible: the scope gate runs first
172
+ * and short-circuits before any ticket is created — minting without a valid
173
+ * bearer would be an auth bypass.
174
+ */
175
+ export async function mintSseTicket(
176
+ req: Request,
177
+ url: URL,
178
+ scope: string,
179
+ mint: (scopes: readonly string[]) => { ticket: string; expiresAt: number },
180
+ ): Promise<Response> {
181
+ const token = extractToken(req, url); // Bearer header ONLY — never a query param.
182
+ if (!token) {
183
+ return json({ error: "unauthorized", message: "Bearer token required" }, 401);
184
+ }
185
+ let scopes: string[];
186
+ try {
187
+ const claims = await validateHubJwt(token);
188
+ if (!grantsScope(claims.scopes, scope)) {
189
+ return json(
190
+ { error: "insufficient_scope", message: `requires ${scope}`, granted: claims.scopes },
191
+ 403,
192
+ );
193
+ }
194
+ // Carry the token's OWN validated scopes — never widen beyond what it holds.
195
+ scopes = claims.scopes;
196
+ } catch (err) {
197
+ return json(
198
+ { error: "unauthorized", message: err instanceof HubJwtError ? err.message : "invalid token" },
199
+ 401,
200
+ );
201
+ }
202
+ const { ticket, expiresAt } = mint(scopes);
203
+ return json({ ticket, expires_at: new Date(expiresAt).toISOString() });
204
+ }
205
+
206
+ /**
207
+ * Guard a browser SSE endpoint on a one-time `?ticket=<nonce>` (agent#25 — the
208
+ * EventSource auth path that replaced the leaky `?token=<JWT>`). Look up + CONSUME
209
+ * the ticket (single-use: a second connect 401s), then assert the ticket's carried
210
+ * scopes include `scope` (the ticket can never authorize more than the JWT that
211
+ * minted it — `mintSseTicket` stored exactly that JWT's scopes). Returns `null`
212
+ * when authorized (caller opens the stream) or a 401 `Response` to return as-is.
213
+ *
214
+ * No JWKS fetch on this path — the JWT was validated at MINT time and its scopes
215
+ * captured in the ticket; consume is a pure in-memory lookup. So an absent /
216
+ * expired / already-used / under-scoped ticket all map to 401 with no network I/O.
217
+ */
218
+ export function requireSseTicket(url: URL, scope: string): Response | null {
219
+ const consumed = consumeTicket(url.searchParams.get("ticket"));
220
+ if (!consumed) {
221
+ return json({ error: "unauthorized", message: "valid one-time SSE ticket required" }, 401);
222
+ }
223
+ if (!grantsScope(consumed.scopes, scope)) {
224
+ return json(
225
+ { error: "insufficient_scope", message: `ticket lacks ${scope}`, granted: consumed.scopes },
226
+ 403,
227
+ );
228
+ }
229
+ return null;
230
+ }
231
+
232
+ /**
233
+ * The header a request carries the step-up token on (agent#80). The terminal
234
+ * WebSocket — which `new WebSocket()` can't set a header on — uses the
235
+ * `?step_up=` query param instead (mirroring the `?token=` exception).
236
+ */
237
+ export const STEP_UP_TOKEN_HEADER = "x-step-up-token";
238
+
239
+ /** Extract a presented step-up token: the header first, then `?step_up=` when allowed. */
240
+ export function extractStepUpToken(req: Request, url: URL, allowQueryParam = false): string | null {
241
+ const header = req.headers.get(STEP_UP_TOKEN_HEADER);
242
+ if (header && header.length > 0) return header;
243
+ if (allowQueryParam) {
244
+ const q = url.searchParams.get("step_up");
245
+ if (q && q.length > 0) return q;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * SECOND-FACTOR gate (agent#80) for the genuinely dangerous `agent:admin` actions:
252
+ * set/rotate credentials, open a terminal, spawn a `filesystem: full` agent. The
253
+ * caller runs {@link requireScope}(`agent:admin`) FIRST; this asserts — IN ADDITION —
254
+ * a valid step-up token (the operator entered their PIN recently).
255
+ *
256
+ * - Step-up NOT configured (no PIN set) → returns `{ ok: false, reason: "setup" }`.
257
+ * The caller maps it to `403 { error: "step_up_required", reason: "setup" }` so
258
+ * the UI runs its FIRST-TIME PIN-setup flow before the action.
259
+ * - PIN configured + valid token → `{ ok: true }` (the action proceeds).
260
+ * - PIN configured + missing/expired token → `{ ok: false, reason: "token" }` →
261
+ * `403 { error: "step_up_required" }` so the UI PROMPTS for the PIN.
262
+ *
263
+ * The 403 is deliberately DISTINCT from `requireScope`'s 401 (no/invalid Bearer):
264
+ * a 401 means "re-authenticate", a 403 `step_up_required` means "enter your PIN".
265
+ * The step-up token NEVER widens scope — the request already passed `agent:admin`;
266
+ * this is purely a recency re-confirm on top.
267
+ *
268
+ * `allowQueryParam: true` accepts `?step_up=` for the terminal WebSocket only.
269
+ * Pure in-memory token check — no I/O on the gated request path, no secret logged.
270
+ */
271
+ export function requireStepUp(
272
+ req: Request,
273
+ url: URL,
274
+ allowQueryParam = false,
275
+ opts?: { configured?: () => boolean; valid?: (token: string | null) => boolean },
276
+ ): { ok: true } | { ok: false; response: Response } {
277
+ const isConfigured = opts?.configured ?? (() => isStepUpConfigured());
278
+ const isValid = opts?.valid ?? ((t: string | null) => isStepUpTokenValid(t));
279
+ if (!isConfigured()) {
280
+ // No PIN yet — the UI must set one before this action can proceed.
281
+ return {
282
+ ok: false,
283
+ response: json(
284
+ {
285
+ error: "step_up_required",
286
+ reason: "setup",
287
+ message: "set a step-up PIN before performing this action",
288
+ },
289
+ 403,
290
+ ),
291
+ };
292
+ }
293
+ const token = extractStepUpToken(req, url, allowQueryParam);
294
+ if (!isValid(token)) {
295
+ return {
296
+ ok: false,
297
+ response: json(
298
+ {
299
+ error: "step_up_required",
300
+ reason: "token",
301
+ message: "enter your step-up PIN to confirm this action",
302
+ },
303
+ 403,
304
+ ),
305
+ };
306
+ }
307
+ return { ok: true };
308
+ }
@@ -85,6 +85,7 @@ import type {
85
85
  DeliverResult,
86
86
  DeliverUsage,
87
87
  InterimSink,
88
+ RunContext,
88
89
  TurnSession,
89
90
  } from "./types.ts";
90
91
 
@@ -327,6 +328,32 @@ export function buildProgrammaticClaudeArgs(opts: {
327
328
  return argv;
328
329
  }
329
330
 
331
+ /**
332
+ * Render the {@link RunContext} as a concise, clearly-LABELED preamble to PREPEND to a turn's
333
+ * message (agent#162). A headless `claude -p` turn has no clock + no notion of which run it is,
334
+ * so the daemon hands it these facts (the real wall-clock, new-vs-resumed, why it fired, the
335
+ * prior turn count) — the agent then stamps ACCURATE times instead of fabricating them.
336
+ *
337
+ * It is a single fenced block clearly marked as daemon-injected runtime context (NOT the
338
+ * agent's own system prompt — that's untouched), then a blank line, then the real message. The
339
+ * `now` is always present; the rest are appended only when known. Returns the message UNCHANGED
340
+ * when `rc` is absent (additive — no behavior change for a caller that doesn't pass one).
341
+ */
342
+ export function renderRunContext(message: string, rc: RunContext | undefined): string {
343
+ if (!rc) return message;
344
+ const parts: string[] = [`now=${rc.now}`, `session=${rc.session}`];
345
+ if (typeof rc.priorTurnCount === "number" && rc.priorTurnCount >= 0) {
346
+ // The NUMBER of this turn (1-based) = completed turns + 1 — what an agent stamps as "turn N".
347
+ parts.push(`turn=${rc.priorTurnCount + 1}`);
348
+ }
349
+ if (rc.firedBy) parts.push(`fired-by=${rc.firedBy}`);
350
+ const preamble =
351
+ `[Run context — injected by the agent daemon (this is the real runtime state, NOT your ` +
352
+ `system prompt). Use these for any timestamp/clock or "which run is this" reasoning instead ` +
353
+ `of guessing: ${parts.join(", ")}]`;
354
+ return `${preamble}\n\n${message}`;
355
+ }
356
+
330
357
  /** Read the full text of a (possibly null) byte stream; null/error → "". */
331
358
  async function drainStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
332
359
  if (!stream) return "";
@@ -414,6 +441,7 @@ export class ProgrammaticBackend implements AgentBackend {
414
441
  session: TurnSession,
415
442
  onInterim?: InterimSink,
416
443
  attachments?: InboundAttachment[],
444
+ runContext?: RunContext,
417
445
  ): Promise<DeliverResult> {
418
446
  const spec = handle.spec;
419
447
  if (!spec) {
@@ -537,13 +565,18 @@ export class ProgrammaticBackend implements AgentBackend {
537
565
  // per-agent even when the working dir is shared. Best-effort + isolated: a single
538
566
  // attachment's fetch/stage failure logs + is SKIPPED (the turn still runs with the rest
539
567
  // + the text). Absent/empty → no staging, no prompt change (today's behavior exactly).
540
- let turnMessage = message;
568
+ // RUN CONTEXT (agent#162): prepend the daemon-injected runtime preamble (the real
569
+ // wall-clock + new/resumed + why-it-fired) so the headless turn reads ACCURATE facts
570
+ // instead of fabricating a clock. Done FIRST so the preamble sits at the very top of the
571
+ // prompt; attachments append after the (already-prefixed) message. Absent runContext →
572
+ // the message is unchanged (additive).
573
+ let turnMessage = renderRunContext(message, runContext);
541
574
  if (attachments && attachments.length > 0) {
542
575
  const staged = await this.stageAttachments(workspace, attachments, vaultArg);
543
576
  if (staged.length > 0) {
544
577
  const lines = staged.map((s) => `- ${s.absPath} (${s.mimeType})`);
545
578
  turnMessage =
546
- `${message}\n\n[Attached files — read them as needed:\n${lines.join("\n")}\n]`;
579
+ `${turnMessage}\n\n[Attached files — read them as needed:\n${lines.join("\n")}\n]`;
547
580
  }
548
581
  }
549
582