@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
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";
@@ -85,6 +86,10 @@ import { ClientRegistry, sseFrame } from "./routing.ts";
85
86
  import { DeliveryState } from "./delivery-state.ts";
86
87
  import {
87
88
  requireScope,
89
+ mintSseTicket,
90
+ requireSseTicket,
91
+ requireStepUp,
92
+ grantsScope,
88
93
  extractToken,
89
94
  json as authJson,
90
95
  SCOPE_READ,
@@ -93,6 +98,16 @@ import {
93
98
  SCOPE_ADMIN,
94
99
  SCOPE_TERMINAL,
95
100
  } from "./auth.ts";
101
+ import {
102
+ isStepUpConfigured,
103
+ isValidPinFormat,
104
+ setStepUpPin,
105
+ verifyStepUpPin,
106
+ mintStepUpToken,
107
+ stepUpLimiter,
108
+ StepUpPinFormatError,
109
+ } from "./step-up.ts";
110
+ import { mintTicket } from "./ui-ticket.ts";
96
111
  import {
97
112
  createTerminalWsHandlers,
98
113
  type TerminalWsData,
@@ -100,6 +115,7 @@ import {
100
115
  import { TERMINAL_UI_HTML } from "./terminal-ui.ts";
101
116
  import { serveTerminalAsset } from "./terminal-assets.ts";
102
117
  import { isSpaPath, serveSpa, spaDistDir } from "./spa-serve.ts";
118
+ import { runBootPreflight, type PreflightResult } from "./preflight.ts";
103
119
  import {
104
120
  buildSpecFromBody,
105
121
  setupProgrammaticSpawn,
@@ -326,6 +342,9 @@ export function contextFor(
326
342
  // Phase 1: carry inbound file attachments through to the turn (the programmatic
327
343
  // backend stages them into the agent's private workspace so the turn can Read them).
328
344
  ...(msg.attachments && msg.attachments.length > 0 ? { attachments: msg.attachments } : {}),
345
+ // agent#162: carry the inbound sender so the drain can derive the run-context
346
+ // `fired-by` (a scheduled `runner:<jobId>` fire vs an interactive/delegated message).
347
+ ...(msg.meta?.sender ? { sender: msg.meta.sender } : {}),
329
348
  });
330
349
  return;
331
350
  }
@@ -350,6 +369,9 @@ export function contextFor(
350
369
  // Phase 1: carry inbound attachments through the pending buffer too, so a turn
351
370
  // that runs on register() still stages them.
352
371
  ...(msg.attachments && msg.attachments.length > 0 ? { attachments: msg.attachments } : {}),
372
+ // agent#162: carry the sender through the pending buffer too, so a turn that runs on
373
+ // register() still derives the right run-context `fired-by`.
374
+ ...(msg.meta?.sender ? { sender: msg.meta.sender } : {}),
353
375
  });
354
376
  if (outcome === "queued") return;
355
377
  // outcome === "unknown" — not an expected programmatic channel. It may still be a
@@ -719,7 +741,7 @@ export function buildWriteThread(channels: Map<string, Channel>): WriteThread {
719
741
  }
720
742
  // Only a transport with a durable store implements writeThread (the VaultTransport).
721
743
  if (!ch.transport.writeThread) return;
722
- await ch.transport.writeThread({
744
+ const written = await ch.transport.writeThread({
723
745
  channel: thread.channel,
724
746
  ...(thread.name ? { name: thread.name } : {}),
725
747
  ...(thread.definition ? { definition: thread.definition } : {}),
@@ -743,6 +765,13 @@ export function buildWriteThread(channels: Map<string, Channel>): WriteThread {
743
765
  ...(thread.sameTurn ? { sameTurn: true } : {}),
744
766
  ...(thread.phase ? { phase: thread.phase } : {}),
745
767
  });
768
+ // Surface the WRITTEN note id so the drain can set a RESOLVABLE callback `source_thread`
769
+ // (agent#124). `writeThread` returns `{ sent: [id] }` — the id is the actual note an
770
+ // orchestrator can pull with `query-notes { id }` for BOTH modes (single-threaded: the
771
+ // deterministic `Threads/<safeChannel>/<safeName>` note; multi-threaded: the per-fire
772
+ // `Threads/<safeChannel>/<uuid>` note). Empty/absent → undefined (the drain falls back).
773
+ const id = written?.sent?.[0];
774
+ return id ? { id } : undefined;
746
775
  };
747
776
  }
748
777
 
@@ -1124,8 +1153,13 @@ function redirect(location: string): Response {
1124
1153
  // is `agent:write`.
1125
1154
  //
1126
1155
  // Layer 2 — human / chat UI — gates the http-ui transport's `send` (POST,
1127
- // `agent:send`) + `/ui/events` SSE (`?token=` query, `agent:read`) inside
1128
- // `http-ui.ts`'s ingestHttp using the same `requireScope`.
1156
+ // `agent:send`, Bearer) with `requireScope`. The browser SSE streams
1157
+ // (`/ui/events`, `/api/channels/<ch>/turn-events`, `agent:read`) gate on a
1158
+ // ONE-TIME ticket (`requireSseTicket`) instead of a `?token=<JWT>` query —
1159
+ // `EventSource` can't set a header, and a JWT in a URL leaks into access logs
1160
+ // (agent#25). The page mints the ticket at `POST /api/ui/sse-ticket` (Bearer,
1161
+ // agent:read) and opens `…?ticket=<nonce>`; the ticket is single-use + ≤60s and
1162
+ // carries only the minting token's scopes.
1129
1163
  //
1130
1164
  // Discovery + the page itself (/health, /.parachute/config[/schema], /ui) stay
1131
1165
  // OPEN — non-sensitive, and /ui must load to bootstrap its token fetch.
@@ -1182,6 +1216,12 @@ export async function authorizeTerminalUpgrade(
1182
1216
  const denied = await requireScope(req, url, SCOPE_TERMINAL, true);
1183
1217
  if (denied) return { ok: false, response: denied };
1184
1218
 
1219
+ // STEP-UP required (agent#80): a terminal is a raw host shell — the single most
1220
+ // dangerous capability. allowQueryParam: true so the WS presents the step-up
1221
+ // token as `?step_up=` (it can't set the `X-Step-Up-Token` header).
1222
+ const step = requireStepUp(req, url, true);
1223
+ if (!step.ok) return { ok: false, response: step.response };
1224
+
1185
1225
  // tmux session name convention: `<name>-agent`. Attach a viewer pty to THIS
1186
1226
  // session; the session itself is created by the spawn path.
1187
1227
  const session = `${agentName}-agent`;
@@ -1286,6 +1326,13 @@ export function createFetchHandler(
1286
1326
  url: string;
1287
1327
  tokenPresent: boolean;
1288
1328
  }>;
1329
+ /**
1330
+ * The boot dependency-PREFLIGHT result (agent#156) — surfaced on `/health` so the
1331
+ * admin UI can show that programmatic turns will fail until the missing deps
1332
+ * (`bwrap`/`rg`/`socat`/`claude`) are installed. `main` passes the boot check;
1333
+ * absent (a plain createFetchHandler / tests) → omitted from `/health`.
1334
+ */
1335
+ preflight?: PreflightResult;
1289
1336
  },
1290
1337
  ): (req: Request, server?: { upgrade: (req: Request, opts: { data: TerminalWsData }) => boolean }) => Promise<Response> {
1291
1338
  // The per-channel turn-event SSE registry — subscribers of the live "watch it
@@ -1478,6 +1525,10 @@ export function createFetchHandler(
1478
1525
  // (`programmatic · idle|working|queued:N`) instead of `mcp_sessions` — a
1479
1526
  // programmatic agent has no live subscriber, so SSE/MCP counts don't describe it.
1480
1527
  if (url.pathname === "/health") {
1528
+ // Surface the boot dependency-preflight (agent#156) so the admin UI can show
1529
+ // that programmatic turns will fail until the missing deps are installed. Only
1530
+ // present when `main` passed the boot check (absent in a plain handler/tests).
1531
+ const preflight = opts?.preflight;
1481
1532
  return json({
1482
1533
  status: "ok",
1483
1534
  channels: [...channels.values()].map((c) => ({
@@ -1496,6 +1547,15 @@ export function createFetchHandler(
1496
1547
  status: s.state === "queued" ? `queued:${s.queued}` : s.state,
1497
1548
  };
1498
1549
  }),
1550
+ ...(preflight
1551
+ ? {
1552
+ dependencies: {
1553
+ ok: preflight.ok,
1554
+ // The binary names missing on PATH — what programmatic turns need installed.
1555
+ missing: preflight.missing.map((d) => d.bin),
1556
+ },
1557
+ }
1558
+ : {}),
1499
1559
  });
1500
1560
  }
1501
1561
 
@@ -1783,6 +1843,169 @@ export function createFetchHandler(
1783
1843
  }
1784
1844
  }
1785
1845
 
1846
+ // ---------------------------------------------------------------------
1847
+ // STEP-UP AUTH (PIN) — second factor for high-privilege actions (agent#80).
1848
+ //
1849
+ // The dangerous `agent:admin` actions (set credentials, open a terminal,
1850
+ // spawn a `filesystem: full` agent) require a step-up token IN ADDITION to
1851
+ // the `agent:admin` Bearer. This block is the PIN setup + exchange surface;
1852
+ // the gating lives at each dangerous endpoint (via `requireStepUp`).
1853
+ //
1854
+ // GET /api/step-up → { configured } — is a PIN set? (UI: setup vs prompt)
1855
+ // POST /api/step-up { pin } → validate PIN (rate-limited) → { stepUpToken, expires_at }
1856
+ // POST /api/step-up/pin { newPin, currentPin? } → set/rotate the PIN
1857
+ //
1858
+ // All `agent:admin`-gated (the operator's cookie-minted Bearer). The PIN is
1859
+ // hashed+salted server-side (step-up.ts); it is NEVER returned or logged.
1860
+ // Externally hub strips `/agent`, so these are `<hub>/agent/api/step-up`.
1861
+ // ---------------------------------------------------------------------
1862
+ if (url.pathname === "/api/step-up" && req.method === "GET") {
1863
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
1864
+ if (denied) return denied;
1865
+ // Whether a PIN is configured — the UI branches setup-flow vs PIN-prompt.
1866
+ return json({ configured: isStepUpConfigured() });
1867
+ }
1868
+
1869
+ if (url.pathname === "/api/step-up" && req.method === "POST") {
1870
+ // Exchange: validate the PIN, then mint a short-lived step-up token. The
1871
+ // session must already hold `agent:admin` (this is a SECOND factor on top,
1872
+ // never a substitute — the token carries no scope of its own).
1873
+ let claims;
1874
+ try {
1875
+ const token = extractToken(req, url);
1876
+ if (!token) return json({ error: "unauthorized", message: "Bearer token required" }, 401);
1877
+ claims = await validateHubJwt(token);
1878
+ } catch (err) {
1879
+ return json(
1880
+ { error: "unauthorized", message: err instanceof Error ? err.message : "invalid token" },
1881
+ 401,
1882
+ );
1883
+ }
1884
+ if (!grantsScope(claims.scopes, SCOPE_ADMIN)) {
1885
+ return json(
1886
+ { error: "insufficient_scope", message: `requires ${SCOPE_ADMIN}`, granted: claims.scopes },
1887
+ 403,
1888
+ );
1889
+ }
1890
+ // No PIN configured yet — there's nothing to exchange. Tell the UI to run
1891
+ // its first-time setup (distinct from a wrong-PIN 401).
1892
+ if (!isStepUpConfigured()) {
1893
+ return json(
1894
+ { error: "step_up_not_configured", message: "set a step-up PIN first (POST /api/step-up/pin)" },
1895
+ 409,
1896
+ );
1897
+ }
1898
+ let body: { pin?: unknown };
1899
+ try {
1900
+ body = (await req.json()) as typeof body;
1901
+ } catch {
1902
+ return json({ error: "invalid JSON body" }, 400);
1903
+ }
1904
+ if (typeof body.pin !== "string" || body.pin.length === 0) {
1905
+ return json({ error: "body.pin (non-empty string) is required" }, 400);
1906
+ }
1907
+ // Rate-limit BEFORE the (expensive, brute-forceable) argon2 verify, keyed by
1908
+ // the operator subject — a stolen-cookie attacker can't grind the PIN. A
1909
+ // DENIED attempt returns 429 (the limiter does not count it again).
1910
+ const limited = stepUpLimiter.checkAndRecord(`step-up:${claims.sub}`);
1911
+ if (!limited.allowed) {
1912
+ return new Response(
1913
+ JSON.stringify({
1914
+ error: "rate_limited",
1915
+ message: "too many PIN attempts — wait before retrying",
1916
+ retry_after_seconds: limited.retryAfterSeconds,
1917
+ }),
1918
+ {
1919
+ status: 429,
1920
+ headers: {
1921
+ "content-type": "application/json",
1922
+ "retry-after": String(limited.retryAfterSeconds ?? 60),
1923
+ },
1924
+ },
1925
+ );
1926
+ }
1927
+ const ok = await verifyStepUpPin(body.pin);
1928
+ if (!ok) {
1929
+ // Wrong PIN — 401. The attempt already counted toward the lockout above.
1930
+ // Never echo the PIN back.
1931
+ return json({ error: "invalid_pin", message: "incorrect PIN" }, 401);
1932
+ }
1933
+ // Correct PIN — clear the attempt bucket (a fresh window for the next time)
1934
+ // and mint a reusable, short-TTL step-up token.
1935
+ stepUpLimiter.clear(`step-up:${claims.sub}`);
1936
+ const { token: stepUpToken, expiresAt } = mintStepUpToken();
1937
+ return json({ stepUpToken, expires_at: new Date(expiresAt).toISOString() });
1938
+ }
1939
+
1940
+ if (url.pathname === "/api/step-up/pin" && req.method === "POST") {
1941
+ // Set (first time) or rotate the step-up PIN. agent:admin-gated; if a PIN
1942
+ // already exists, the CURRENT PIN must be supplied + verified (rotation
1943
+ // needs the old PIN, so a hijacked session can't silently replace it).
1944
+ let claims;
1945
+ try {
1946
+ const token = extractToken(req, url);
1947
+ if (!token) return json({ error: "unauthorized", message: "Bearer token required" }, 401);
1948
+ claims = await validateHubJwt(token);
1949
+ } catch (err) {
1950
+ return json(
1951
+ { error: "unauthorized", message: err instanceof Error ? err.message : "invalid token" },
1952
+ 401,
1953
+ );
1954
+ }
1955
+ if (!grantsScope(claims.scopes, SCOPE_ADMIN)) {
1956
+ return json(
1957
+ { error: "insufficient_scope", message: `requires ${SCOPE_ADMIN}`, granted: claims.scopes },
1958
+ 403,
1959
+ );
1960
+ }
1961
+ let body: { newPin?: unknown; currentPin?: unknown };
1962
+ try {
1963
+ body = (await req.json()) as typeof body;
1964
+ } catch {
1965
+ return json({ error: "invalid JSON body" }, 400);
1966
+ }
1967
+ if (!isValidPinFormat(body.newPin)) {
1968
+ return json({ error: "body.newPin must be 4–12 digits" }, 400);
1969
+ }
1970
+ // Rotation: a PIN already exists → require + verify the current one (rate-limited).
1971
+ // SHARES the exchange bucket (same `step-up:<sub>` key) on purpose: both verify
1972
+ // the PIN, so an attacker can't get a fresh grind window by alternating endpoints.
1973
+ if (isStepUpConfigured()) {
1974
+ const limited = stepUpLimiter.checkAndRecord(`step-up:${claims.sub}`);
1975
+ if (!limited.allowed) {
1976
+ return new Response(
1977
+ JSON.stringify({
1978
+ error: "rate_limited",
1979
+ message: "too many PIN attempts — wait before retrying",
1980
+ retry_after_seconds: limited.retryAfterSeconds,
1981
+ }),
1982
+ {
1983
+ status: 429,
1984
+ headers: {
1985
+ "content-type": "application/json",
1986
+ "retry-after": String(limited.retryAfterSeconds ?? 60),
1987
+ },
1988
+ },
1989
+ );
1990
+ }
1991
+ if (typeof body.currentPin !== "string" || !(await verifyStepUpPin(body.currentPin))) {
1992
+ return json(
1993
+ { error: "invalid_pin", message: "the current PIN is required to change it" },
1994
+ 401,
1995
+ );
1996
+ }
1997
+ stepUpLimiter.clear(`step-up:${claims.sub}`);
1998
+ }
1999
+ try {
2000
+ await setStepUpPin(body.newPin);
2001
+ } catch (err) {
2002
+ if (err instanceof StepUpPinFormatError) return json({ error: err.message }, 400);
2003
+ return json({ error: `failed to set PIN: ${(err as Error).message}` }, 500);
2004
+ }
2005
+ // Echo back only the fact of the write — never the PIN.
2006
+ return json({ ok: true, configured: true });
2007
+ }
2008
+
1786
2009
  // ---------------------------------------------------------------------
1787
2010
  // Claude OAuth credential store (design §6) — the per-channel secret a
1788
2011
  // launched agent session runs on (`CLAUDE_CODE_OAUTH_TOKEN`). Same
@@ -1802,11 +2025,15 @@ export function createFetchHandler(
1802
2025
 
1803
2026
  if (req.method === "GET") {
1804
2027
  // Inspect WITHOUT leaking the secret: whether a default is set + which
1805
- // channels carry an override (names only).
2028
+ // channels carry an override (names only). A status read — no step-up.
1806
2029
  return json(describeClaudeCredentials(defaultStateDir()));
1807
2030
  }
1808
2031
 
1809
- // POST — set the default / operator-level token.
2032
+ // POST — set the default / operator-level token. STEP-UP required (agent#80):
2033
+ // setting a credential can exfiltrate the operator's Claude token.
2034
+ const step = requireStepUp(req, url);
2035
+ if (!step.ok) return step.response;
2036
+
1810
2037
  let credBody: { token?: unknown };
1811
2038
  try {
1812
2039
  credBody = (await req.json()) as typeof credBody;
@@ -1829,6 +2056,10 @@ export function createFetchHandler(
1829
2056
  if (credMatch && (req.method === "POST" || req.method === "DELETE")) {
1830
2057
  const denied = await requireScope(req, url, SCOPE_ADMIN);
1831
2058
  if (denied) return denied;
2059
+ // STEP-UP required (agent#80): both set + remove of a per-channel Claude
2060
+ // credential are high-privilege credential-store mutations.
2061
+ const step = requireStepUp(req, url);
2062
+ if (!step.ok) return step.response;
1832
2063
  const channel = decodeURIComponent(credMatch[1]!);
1833
2064
 
1834
2065
  if (req.method === "DELETE") {
@@ -1883,9 +2114,15 @@ export function createFetchHandler(
1883
2114
 
1884
2115
  if (req.method === "GET") {
1885
2116
  // Inspect WITHOUT leaking values: names per channel + the default layer.
2117
+ // A status read — no step-up.
1886
2118
  return json(describeChannelEnv(defaultStateDir()));
1887
2119
  }
1888
2120
 
2121
+ // STEP-UP required (agent#80): set/remove of an env secret (GH_TOKEN,
2122
+ // CLOUDFLARE_API_TOKEN, …) is a credential-store mutation.
2123
+ const step = requireStepUp(req, url);
2124
+ if (!step.ok) return step.response;
2125
+
1889
2126
  let envBody: { channel?: unknown; name?: unknown; value?: unknown };
1890
2127
  try {
1891
2128
  envBody = (await req.json()) as typeof envBody;
@@ -1979,6 +2216,15 @@ export function createFetchHandler(
1979
2216
  throw err;
1980
2217
  }
1981
2218
 
2219
+ // STEP-UP required (agent#80) ONLY for the dangerous filesystem case: a
2220
+ // `filesystem: "full"` agent runs UNSANDBOXED with read access to the whole
2221
+ // disk. Ordinary sandboxed (workspace-confined) spawns stay frictionless —
2222
+ // gate just the high-blast-radius case.
2223
+ if (spec.filesystem === "full") {
2224
+ const step = requireStepUp(req, url);
2225
+ if (!step.ok) return step.response;
2226
+ }
2227
+
1982
2228
  // CHANNEL EXCLUSION: a channel routes inbound to at most one agent. Refuse a
1983
2229
  // spawn for a DIFFERENT programmatic agent onto an already-occupied wake channel
1984
2230
  // (re-spawning the SAME name onto its OWN channel is the idempotent-replace path).
@@ -2120,6 +2366,14 @@ export function createFetchHandler(
2120
2366
  // ---------------------------------------------------------------------
2121
2367
  if (url.pathname === "/api/agent-defs" && (req.method === "GET" || req.method === "POST")) {
2122
2368
  // GET is READ-scoped (a listing, no secrets); POST is admin (it mints/writes).
2369
+ //
2370
+ // NOTE (step-up, agent#80/#154): POST is intentionally NOT step-up-gated, even
2371
+ // though a `#agent/definition` note can carry `filesystem: "full"`. Authoring a
2372
+ // def already requires the scope-gated `vault:write` (the daemon writes the note
2373
+ // with a vault token), so a step-up challenge here would gate a capability the
2374
+ // caller already had to hold a write credential to reach — mirrors the carve-out
2375
+ // comment on the vault-native parse path in agent-defs.ts. The `filesystem:full`
2376
+ // SPAWN path (`POST /api/agents`) IS step-up-gated; this AUTHORING path is not.
2123
2377
  const scope = req.method === "GET" ? SCOPE_READ : SCOPE_ADMIN;
2124
2378
  const denied = await requireScope(req, url, scope);
2125
2379
  if (denied) return denied;
@@ -2273,6 +2527,12 @@ export function createFetchHandler(
2273
2527
  // GET is READ-scoped to mirror GET /api/agent-defs — the listing is non-sensitive
2274
2528
  // ({vault,url,tokenPresent}); `tokenPresent` is a boolean, NEVER the token value.
2275
2529
  // POST is admin (it mints a token + writes config).
2530
+ //
2531
+ // NOTE (step-up, agent#80/#154): POST is intentionally NOT step-up-gated. It mints
2532
+ // a VAULT-SCOPED token (`vault:<name>:write`) — a lower blast radius than the
2533
+ // Claude OAuth credential / terminal / full-fs spawn the step-up PIN guards (those
2534
+ // can exfiltrate every token or open a raw host shell). A def-vault token only
2535
+ // reaches the named vault's notes, so `agent:admin` alone is the right bar here.
2276
2536
  const scope = req.method === "GET" ? SCOPE_READ : SCOPE_ADMIN;
2277
2537
  const denied = await requireScope(req, url, scope);
2278
2538
  if (denied) return denied;
@@ -2745,8 +3005,21 @@ export function createFetchHandler(
2745
3005
  return json({ ok: true, reloaded: result });
2746
3006
  }
2747
3007
 
3008
+ // One-time SSE ticket mint — POST /api/ui/sse-ticket (agent#25). The chat
3009
+ // page can't put its hub JWT in an EventSource URL without leaking it into
3010
+ // access logs, so it trades the JWT (presented HERE as a Bearer header — no
3011
+ // leak) for a single-use, ≤60s opaque ticket it puts in the SSE URL instead.
3012
+ // Bearer-gated on `agent:read` (the scope both browser SSE streams require);
3013
+ // the minted ticket carries ONLY the token's own validated scopes, so it can
3014
+ // never authorize more than the JWT did. An unauthenticated mint is impossible
3015
+ // — `mintSseTicket` runs the scope gate before issuing anything. Returns
3016
+ // `{ ticket, expires_at }`. Externally `<hub>/agent/api/ui/sse-ticket`.
3017
+ if (req.method === "POST" && url.pathname === "/api/ui/sse-ticket") {
3018
+ return mintSseTicket(req, url, SCOPE_READ, mintTicket);
3019
+ }
3020
+
2748
3021
  // Turn-event SSE — GET /api/channels/<ch>/turn-events (chat-facing; gated on
2749
- // `agent:read`, same scope as the transcript poll + /ui/events). The streaming
3022
+ // a one-time SSE ticket carrying `agent:read`). The streaming
2750
3023
  // view (design 2026-06-16 build item #1): the chat subscribes here to watch a
2751
3024
  // PROGRAMMATIC turn work in real time — interim assistant text + tool_use, then a
2752
3025
  // done/error lifecycle event. EPHEMERAL by design: no backlog/replay (the durable
@@ -2758,11 +3031,12 @@ export function createFetchHandler(
2758
3031
  {
2759
3032
  const turnMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/turn-events$/);
2760
3033
  if (req.method === "GET" && turnMatch) {
2761
- // allowQueryParam=true: this SSE is consumed by a browser EventSource, which
2762
- // cannot set an Authorization headerit authenticates via ?token=. Without
2763
- // this the live-streaming view 401s in the browser and never connects. (The
2764
- // stdio-bridge /events SSE uses a Bearer header, so it doesn't need this.)
2765
- const denied = await requireScope(req, url, SCOPE_READ, true);
3034
+ // Browser EventSource can't set an Authorization header, so this SSE
3035
+ // authenticates via a one-time `?ticket=<nonce>` (agent#25) minted by
3036
+ // POST /api/ui/sse-ticket (Bearer-gated) and consumed single-use here. The
3037
+ // hub JWT never rides in this URL. (The stdio-bridge /events SSE uses a
3038
+ // Bearer header, so it never needed a query credential at all.)
3039
+ const denied = requireSseTicket(url, SCOPE_READ);
2766
3040
  if (denied) return denied;
2767
3041
  const channelName = decodeURIComponent(turnMatch[1]!);
2768
3042
  const clientId = crypto.randomUUID();
@@ -3008,6 +3282,14 @@ function main(): void {
3008
3282
  mkdirSync(STATE_DIR, { recursive: true });
3009
3283
  mkdirSync(INBOX_DIR, { recursive: true });
3010
3284
 
3285
+ // BOOT DEPENDENCY PREFLIGHT (agent#156). A fresh box can't run a programmatic
3286
+ // `claude -p` turn until bwrap/rg/socat + the claude CLI are on PATH — pre-#156
3287
+ // each surfaced only as a failed *turn*, one at a time. Check them ONCE at boot and
3288
+ // log a single clear warning (with the install one-liners) when any is missing. It's
3289
+ // advisory, never fatal: the daemon may run only attached-backend agents that need
3290
+ // none of these, so we warn + keep serving. The result is also surfaced on /health.
3291
+ const preflight = runBootPreflight();
3292
+
3011
3293
  // Verify the one MCP SDK internal our HTTP-MCP delivery accounting reads
3012
3294
  // (`_streamMapping['_GET_stream']`, see assertMcpSdkStreamContract). A screaming
3013
3295
  // boot error on SDK drift beats discovering it as silent message loss later.
@@ -3124,7 +3406,7 @@ function main(): void {
3124
3406
  buildInstantiateDeps(channels, registry, deliveryState, programmatic, attachedQueue),
3125
3407
  );
3126
3408
 
3127
- const fetchHandler = createFetchHandler(channels, registry, { deliveryState, programmatic, attachedQueue, turnEvents, jobStore, runner, agentDefs });
3409
+ const fetchHandler = createFetchHandler(channels, registry, { deliveryState, programmatic, attachedQueue, turnEvents, jobStore, runner, agentDefs, preflight });
3128
3410
  const server = Bun.serve<TerminalWsData, never>({
3129
3411
  port: PORT,
3130
3412
  hostname: "127.0.0.1",
@@ -3277,6 +3559,29 @@ function main(): void {
3277
3559
  const bindings = await resolveDefVaults({ hubOrigin: getHubOrigin(), managerBearer });
3278
3560
  for (const b of bindings) agentDefs.addVault(b);
3279
3561
  if (bindings.length === 0) return; // nothing bound — vault-native path idle.
3562
+
3563
+ // AUTO-REGISTER the per-def-vault runtime triggers (agent#157) so "define an
3564
+ // agent → it runs" needs NO manual trigger setup + NO restart-to-pick-up:
3565
+ // - the def-watch create/edit triggers, BARE-keyed (`agent/definition`) so a
3566
+ // created/edited def auto-fires the rescan — upsert-by-name REPLACES any
3567
+ // stale `#agent/definition`-keyed `conn_agentdefs-*` row the hub provisioned;
3568
+ // - the inbound trigger (`agent/message/inbound` + has_metadata:[agent]) so a
3569
+ // new inbound note wakes the agent without a hand-registered trigger.
3570
+ // Mints the admin (triggers API) + agent:send (webhook bearer) tokens the same
3571
+ // way the hub's Connections engine does (attenuated to the operator bearer).
3572
+ // Best-effort: a mint refusal / unreachable vault is logged, never fatal — the
3573
+ // 60s loadAll poll below stays the correctness floor. Skipped with no operator
3574
+ // bearer (can't mint) — the vault-native path still runs own-vault.
3575
+ if (managerBearer) {
3576
+ await registerAllDefVaultTriggers(bindings, { hubOrigin: getHubOrigin(), managerBearer }).catch(
3577
+ (err) => {
3578
+ console.warn(
3579
+ `parachute-agent: def-vault trigger auto-registration failed (continuing): ${(err as Error).message}`,
3580
+ );
3581
+ },
3582
+ );
3583
+ }
3584
+
3280
3585
  const n = await agentDefs.loadAll();
3281
3586
  console.log(
3282
3587
  `parachute-agent: vault-native agent defs — ${n} instantiated from ${bindings.length} def-vault(s).`,