@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.
Files changed (72) 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/registry.ts +65 -27
  6. package/src/daemon.ts +311 -12
  7. package/src/def-vault-triggers.ts +317 -0
  8. package/src/preflight.ts +139 -0
  9. package/src/spawn-agent.ts +16 -0
  10. package/src/step-up.ts +316 -0
  11. package/src/terminal-ui.ts +73 -0
  12. package/src/transports/http-ui.ts +10 -8
  13. package/src/transports/vault.ts +40 -22
  14. package/src/ui-kit.ts +6 -3
  15. package/src/ui-ticket.ts +121 -0
  16. package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
  17. package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
  18. package/web/ui/dist/index.html +2 -2
  19. package/src/_parked/interactive-spawn.test.ts +0 -324
  20. package/src/_parked/interactive-spawn.ts +0 -701
  21. package/src/agent-defs.test.ts +0 -1504
  22. package/src/agent-mcp-config.test.ts +0 -115
  23. package/src/agents.test.ts +0 -360
  24. package/src/auth.test.ts +0 -46
  25. package/src/backends/attached-queue.test.ts +0 -376
  26. package/src/backends/programmatic.test.ts +0 -1715
  27. package/src/backends/registry.test.ts +0 -1494
  28. package/src/backends/stream-json.test.ts +0 -570
  29. package/src/channel-backend-wiring.test.ts +0 -237
  30. package/src/credentials.test.ts +0 -274
  31. package/src/cron.test.ts +0 -342
  32. package/src/daemon-agent-def-api.test.ts +0 -166
  33. package/src/daemon-agent-defs-api.test.ts +0 -953
  34. package/src/daemon-agent-env-api.test.ts +0 -338
  35. package/src/daemon-attached-queue-store.test.ts +0 -65
  36. package/src/daemon-config-api.test.ts +0 -962
  37. package/src/daemon-jobs-api.test.ts +0 -271
  38. package/src/daemon-vault-chat.test.ts +0 -250
  39. package/src/daemon.test.ts +0 -746
  40. package/src/def-vaults.test.ts +0 -136
  41. package/src/delivery-state.test.ts +0 -110
  42. package/src/effective-env.test.ts +0 -114
  43. package/src/grants.test.ts +0 -638
  44. package/src/hub-jwt.test.ts +0 -161
  45. package/src/jobs.test.ts +0 -245
  46. package/src/mcp-http.test.ts +0 -265
  47. package/src/mint-token.test.ts +0 -152
  48. package/src/module-manifest.test.ts +0 -158
  49. package/src/programmatic-wiring.test.ts +0 -838
  50. package/src/registry.test.ts +0 -227
  51. package/src/resolve-port.test.ts +0 -64
  52. package/src/routing.test.ts +0 -184
  53. package/src/runner.test.ts +0 -506
  54. package/src/sandbox/config.test.ts +0 -150
  55. package/src/sandbox/egress.test.ts +0 -113
  56. package/src/sandbox/live-seatbelt.test.ts +0 -277
  57. package/src/sandbox/mounts.test.ts +0 -154
  58. package/src/sandbox/sandbox.test.ts +0 -168
  59. package/src/services-manifest.test.ts +0 -106
  60. package/src/spa-serve.test.ts +0 -116
  61. package/src/spawn-agent-cli.test.ts +0 -172
  62. package/src/spawn-agent.test.ts +0 -1218
  63. package/src/spawn-deps.test.ts +0 -54
  64. package/src/terminal-assets.test.ts +0 -50
  65. package/src/terminal.test.ts +0 -530
  66. package/src/transports/http-ui.test.ts +0 -455
  67. package/src/transports/telegram.test.ts +0 -174
  68. package/src/transports/vault.test.ts +0 -2011
  69. package/src/ui-kit.test.ts +0 -178
  70. package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
  71. package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
  72. 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,
@@ -719,7 +735,7 @@ export function buildWriteThread(channels: Map<string, Channel>): WriteThread {
719
735
  }
720
736
  // Only a transport with a durable store implements writeThread (the VaultTransport).
721
737
  if (!ch.transport.writeThread) return;
722
- await ch.transport.writeThread({
738
+ const written = await ch.transport.writeThread({
723
739
  channel: thread.channel,
724
740
  ...(thread.name ? { name: thread.name } : {}),
725
741
  ...(thread.definition ? { definition: thread.definition } : {}),
@@ -743,6 +759,13 @@ export function buildWriteThread(channels: Map<string, Channel>): WriteThread {
743
759
  ...(thread.sameTurn ? { sameTurn: true } : {}),
744
760
  ...(thread.phase ? { phase: thread.phase } : {}),
745
761
  });
762
+ // Surface the WRITTEN note id so the drain can set a RESOLVABLE callback `source_thread`
763
+ // (agent#124). `writeThread` returns `{ sent: [id] }` — the id is the actual note an
764
+ // orchestrator can pull with `query-notes { id }` for BOTH modes (single-threaded: the
765
+ // deterministic `Threads/<safeChannel>/<safeName>` note; multi-threaded: the per-fire
766
+ // `Threads/<safeChannel>/<uuid>` note). Empty/absent → undefined (the drain falls back).
767
+ const id = written?.sent?.[0];
768
+ return id ? { id } : undefined;
746
769
  };
747
770
  }
748
771
 
@@ -1124,8 +1147,13 @@ function redirect(location: string): Response {
1124
1147
  // is `agent:write`.
1125
1148
  //
1126
1149
  // 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`.
1150
+ // `agent:send`, Bearer) with `requireScope`. The browser SSE streams
1151
+ // (`/ui/events`, `/api/channels/<ch>/turn-events`, `agent:read`) gate on a
1152
+ // ONE-TIME ticket (`requireSseTicket`) instead of a `?token=<JWT>` query —
1153
+ // `EventSource` can't set a header, and a JWT in a URL leaks into access logs
1154
+ // (agent#25). The page mints the ticket at `POST /api/ui/sse-ticket` (Bearer,
1155
+ // agent:read) and opens `…?ticket=<nonce>`; the ticket is single-use + ≤60s and
1156
+ // carries only the minting token's scopes.
1129
1157
  //
1130
1158
  // Discovery + the page itself (/health, /.parachute/config[/schema], /ui) stay
1131
1159
  // OPEN — non-sensitive, and /ui must load to bootstrap its token fetch.
@@ -1182,6 +1210,12 @@ export async function authorizeTerminalUpgrade(
1182
1210
  const denied = await requireScope(req, url, SCOPE_TERMINAL, true);
1183
1211
  if (denied) return { ok: false, response: denied };
1184
1212
 
1213
+ // STEP-UP required (agent#80): a terminal is a raw host shell — the single most
1214
+ // dangerous capability. allowQueryParam: true so the WS presents the step-up
1215
+ // token as `?step_up=` (it can't set the `X-Step-Up-Token` header).
1216
+ const step = requireStepUp(req, url, true);
1217
+ if (!step.ok) return { ok: false, response: step.response };
1218
+
1185
1219
  // tmux session name convention: `<name>-agent`. Attach a viewer pty to THIS
1186
1220
  // session; the session itself is created by the spawn path.
1187
1221
  const session = `${agentName}-agent`;
@@ -1286,6 +1320,13 @@ export function createFetchHandler(
1286
1320
  url: string;
1287
1321
  tokenPresent: boolean;
1288
1322
  }>;
1323
+ /**
1324
+ * The boot dependency-PREFLIGHT result (agent#156) — surfaced on `/health` so the
1325
+ * admin UI can show that programmatic turns will fail until the missing deps
1326
+ * (`bwrap`/`rg`/`socat`/`claude`) are installed. `main` passes the boot check;
1327
+ * absent (a plain createFetchHandler / tests) → omitted from `/health`.
1328
+ */
1329
+ preflight?: PreflightResult;
1289
1330
  },
1290
1331
  ): (req: Request, server?: { upgrade: (req: Request, opts: { data: TerminalWsData }) => boolean }) => Promise<Response> {
1291
1332
  // The per-channel turn-event SSE registry — subscribers of the live "watch it
@@ -1478,6 +1519,10 @@ export function createFetchHandler(
1478
1519
  // (`programmatic · idle|working|queued:N`) instead of `mcp_sessions` — a
1479
1520
  // programmatic agent has no live subscriber, so SSE/MCP counts don't describe it.
1480
1521
  if (url.pathname === "/health") {
1522
+ // Surface the boot dependency-preflight (agent#156) so the admin UI can show
1523
+ // that programmatic turns will fail until the missing deps are installed. Only
1524
+ // present when `main` passed the boot check (absent in a plain handler/tests).
1525
+ const preflight = opts?.preflight;
1481
1526
  return json({
1482
1527
  status: "ok",
1483
1528
  channels: [...channels.values()].map((c) => ({
@@ -1496,6 +1541,15 @@ export function createFetchHandler(
1496
1541
  status: s.state === "queued" ? `queued:${s.queued}` : s.state,
1497
1542
  };
1498
1543
  }),
1544
+ ...(preflight
1545
+ ? {
1546
+ dependencies: {
1547
+ ok: preflight.ok,
1548
+ // The binary names missing on PATH — what programmatic turns need installed.
1549
+ missing: preflight.missing.map((d) => d.bin),
1550
+ },
1551
+ }
1552
+ : {}),
1499
1553
  });
1500
1554
  }
1501
1555
 
@@ -1783,6 +1837,169 @@ export function createFetchHandler(
1783
1837
  }
1784
1838
  }
1785
1839
 
1840
+ // ---------------------------------------------------------------------
1841
+ // STEP-UP AUTH (PIN) — second factor for high-privilege actions (agent#80).
1842
+ //
1843
+ // The dangerous `agent:admin` actions (set credentials, open a terminal,
1844
+ // spawn a `filesystem: full` agent) require a step-up token IN ADDITION to
1845
+ // the `agent:admin` Bearer. This block is the PIN setup + exchange surface;
1846
+ // the gating lives at each dangerous endpoint (via `requireStepUp`).
1847
+ //
1848
+ // GET /api/step-up → { configured } — is a PIN set? (UI: setup vs prompt)
1849
+ // POST /api/step-up { pin } → validate PIN (rate-limited) → { stepUpToken, expires_at }
1850
+ // POST /api/step-up/pin { newPin, currentPin? } → set/rotate the PIN
1851
+ //
1852
+ // All `agent:admin`-gated (the operator's cookie-minted Bearer). The PIN is
1853
+ // hashed+salted server-side (step-up.ts); it is NEVER returned or logged.
1854
+ // Externally hub strips `/agent`, so these are `<hub>/agent/api/step-up`.
1855
+ // ---------------------------------------------------------------------
1856
+ if (url.pathname === "/api/step-up" && req.method === "GET") {
1857
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
1858
+ if (denied) return denied;
1859
+ // Whether a PIN is configured — the UI branches setup-flow vs PIN-prompt.
1860
+ return json({ configured: isStepUpConfigured() });
1861
+ }
1862
+
1863
+ if (url.pathname === "/api/step-up" && req.method === "POST") {
1864
+ // Exchange: validate the PIN, then mint a short-lived step-up token. The
1865
+ // session must already hold `agent:admin` (this is a SECOND factor on top,
1866
+ // never a substitute — the token carries no scope of its own).
1867
+ let claims;
1868
+ try {
1869
+ const token = extractToken(req, url);
1870
+ if (!token) return json({ error: "unauthorized", message: "Bearer token required" }, 401);
1871
+ claims = await validateHubJwt(token);
1872
+ } catch (err) {
1873
+ return json(
1874
+ { error: "unauthorized", message: err instanceof Error ? err.message : "invalid token" },
1875
+ 401,
1876
+ );
1877
+ }
1878
+ if (!grantsScope(claims.scopes, SCOPE_ADMIN)) {
1879
+ return json(
1880
+ { error: "insufficient_scope", message: `requires ${SCOPE_ADMIN}`, granted: claims.scopes },
1881
+ 403,
1882
+ );
1883
+ }
1884
+ // No PIN configured yet — there's nothing to exchange. Tell the UI to run
1885
+ // its first-time setup (distinct from a wrong-PIN 401).
1886
+ if (!isStepUpConfigured()) {
1887
+ return json(
1888
+ { error: "step_up_not_configured", message: "set a step-up PIN first (POST /api/step-up/pin)" },
1889
+ 409,
1890
+ );
1891
+ }
1892
+ let body: { pin?: unknown };
1893
+ try {
1894
+ body = (await req.json()) as typeof body;
1895
+ } catch {
1896
+ return json({ error: "invalid JSON body" }, 400);
1897
+ }
1898
+ if (typeof body.pin !== "string" || body.pin.length === 0) {
1899
+ return json({ error: "body.pin (non-empty string) is required" }, 400);
1900
+ }
1901
+ // Rate-limit BEFORE the (expensive, brute-forceable) argon2 verify, keyed by
1902
+ // the operator subject — a stolen-cookie attacker can't grind the PIN. A
1903
+ // DENIED attempt returns 429 (the limiter does not count it again).
1904
+ const limited = stepUpLimiter.checkAndRecord(`step-up:${claims.sub}`);
1905
+ if (!limited.allowed) {
1906
+ return new Response(
1907
+ JSON.stringify({
1908
+ error: "rate_limited",
1909
+ message: "too many PIN attempts — wait before retrying",
1910
+ retry_after_seconds: limited.retryAfterSeconds,
1911
+ }),
1912
+ {
1913
+ status: 429,
1914
+ headers: {
1915
+ "content-type": "application/json",
1916
+ "retry-after": String(limited.retryAfterSeconds ?? 60),
1917
+ },
1918
+ },
1919
+ );
1920
+ }
1921
+ const ok = await verifyStepUpPin(body.pin);
1922
+ if (!ok) {
1923
+ // Wrong PIN — 401. The attempt already counted toward the lockout above.
1924
+ // Never echo the PIN back.
1925
+ return json({ error: "invalid_pin", message: "incorrect PIN" }, 401);
1926
+ }
1927
+ // Correct PIN — clear the attempt bucket (a fresh window for the next time)
1928
+ // and mint a reusable, short-TTL step-up token.
1929
+ stepUpLimiter.clear(`step-up:${claims.sub}`);
1930
+ const { token: stepUpToken, expiresAt } = mintStepUpToken();
1931
+ return json({ stepUpToken, expires_at: new Date(expiresAt).toISOString() });
1932
+ }
1933
+
1934
+ if (url.pathname === "/api/step-up/pin" && req.method === "POST") {
1935
+ // Set (first time) or rotate the step-up PIN. agent:admin-gated; if a PIN
1936
+ // already exists, the CURRENT PIN must be supplied + verified (rotation
1937
+ // needs the old PIN, so a hijacked session can't silently replace it).
1938
+ let claims;
1939
+ try {
1940
+ const token = extractToken(req, url);
1941
+ if (!token) return json({ error: "unauthorized", message: "Bearer token required" }, 401);
1942
+ claims = await validateHubJwt(token);
1943
+ } catch (err) {
1944
+ return json(
1945
+ { error: "unauthorized", message: err instanceof Error ? err.message : "invalid token" },
1946
+ 401,
1947
+ );
1948
+ }
1949
+ if (!grantsScope(claims.scopes, SCOPE_ADMIN)) {
1950
+ return json(
1951
+ { error: "insufficient_scope", message: `requires ${SCOPE_ADMIN}`, granted: claims.scopes },
1952
+ 403,
1953
+ );
1954
+ }
1955
+ let body: { newPin?: unknown; currentPin?: unknown };
1956
+ try {
1957
+ body = (await req.json()) as typeof body;
1958
+ } catch {
1959
+ return json({ error: "invalid JSON body" }, 400);
1960
+ }
1961
+ if (!isValidPinFormat(body.newPin)) {
1962
+ return json({ error: "body.newPin must be 4–12 digits" }, 400);
1963
+ }
1964
+ // Rotation: a PIN already exists → require + verify the current one (rate-limited).
1965
+ // SHARES the exchange bucket (same `step-up:<sub>` key) on purpose: both verify
1966
+ // the PIN, so an attacker can't get a fresh grind window by alternating endpoints.
1967
+ if (isStepUpConfigured()) {
1968
+ const limited = stepUpLimiter.checkAndRecord(`step-up:${claims.sub}`);
1969
+ if (!limited.allowed) {
1970
+ return new Response(
1971
+ JSON.stringify({
1972
+ error: "rate_limited",
1973
+ message: "too many PIN attempts — wait before retrying",
1974
+ retry_after_seconds: limited.retryAfterSeconds,
1975
+ }),
1976
+ {
1977
+ status: 429,
1978
+ headers: {
1979
+ "content-type": "application/json",
1980
+ "retry-after": String(limited.retryAfterSeconds ?? 60),
1981
+ },
1982
+ },
1983
+ );
1984
+ }
1985
+ if (typeof body.currentPin !== "string" || !(await verifyStepUpPin(body.currentPin))) {
1986
+ return json(
1987
+ { error: "invalid_pin", message: "the current PIN is required to change it" },
1988
+ 401,
1989
+ );
1990
+ }
1991
+ stepUpLimiter.clear(`step-up:${claims.sub}`);
1992
+ }
1993
+ try {
1994
+ await setStepUpPin(body.newPin);
1995
+ } catch (err) {
1996
+ if (err instanceof StepUpPinFormatError) return json({ error: err.message }, 400);
1997
+ return json({ error: `failed to set PIN: ${(err as Error).message}` }, 500);
1998
+ }
1999
+ // Echo back only the fact of the write — never the PIN.
2000
+ return json({ ok: true, configured: true });
2001
+ }
2002
+
1786
2003
  // ---------------------------------------------------------------------
1787
2004
  // Claude OAuth credential store (design §6) — the per-channel secret a
1788
2005
  // launched agent session runs on (`CLAUDE_CODE_OAUTH_TOKEN`). Same
@@ -1802,11 +2019,15 @@ export function createFetchHandler(
1802
2019
 
1803
2020
  if (req.method === "GET") {
1804
2021
  // Inspect WITHOUT leaking the secret: whether a default is set + which
1805
- // channels carry an override (names only).
2022
+ // channels carry an override (names only). A status read — no step-up.
1806
2023
  return json(describeClaudeCredentials(defaultStateDir()));
1807
2024
  }
1808
2025
 
1809
- // POST — set the default / operator-level token.
2026
+ // POST — set the default / operator-level token. STEP-UP required (agent#80):
2027
+ // setting a credential can exfiltrate the operator's Claude token.
2028
+ const step = requireStepUp(req, url);
2029
+ if (!step.ok) return step.response;
2030
+
1810
2031
  let credBody: { token?: unknown };
1811
2032
  try {
1812
2033
  credBody = (await req.json()) as typeof credBody;
@@ -1829,6 +2050,10 @@ export function createFetchHandler(
1829
2050
  if (credMatch && (req.method === "POST" || req.method === "DELETE")) {
1830
2051
  const denied = await requireScope(req, url, SCOPE_ADMIN);
1831
2052
  if (denied) return denied;
2053
+ // STEP-UP required (agent#80): both set + remove of a per-channel Claude
2054
+ // credential are high-privilege credential-store mutations.
2055
+ const step = requireStepUp(req, url);
2056
+ if (!step.ok) return step.response;
1832
2057
  const channel = decodeURIComponent(credMatch[1]!);
1833
2058
 
1834
2059
  if (req.method === "DELETE") {
@@ -1883,9 +2108,15 @@ export function createFetchHandler(
1883
2108
 
1884
2109
  if (req.method === "GET") {
1885
2110
  // Inspect WITHOUT leaking values: names per channel + the default layer.
2111
+ // A status read — no step-up.
1886
2112
  return json(describeChannelEnv(defaultStateDir()));
1887
2113
  }
1888
2114
 
2115
+ // STEP-UP required (agent#80): set/remove of an env secret (GH_TOKEN,
2116
+ // CLOUDFLARE_API_TOKEN, …) is a credential-store mutation.
2117
+ const step = requireStepUp(req, url);
2118
+ if (!step.ok) return step.response;
2119
+
1889
2120
  let envBody: { channel?: unknown; name?: unknown; value?: unknown };
1890
2121
  try {
1891
2122
  envBody = (await req.json()) as typeof envBody;
@@ -1979,6 +2210,15 @@ export function createFetchHandler(
1979
2210
  throw err;
1980
2211
  }
1981
2212
 
2213
+ // STEP-UP required (agent#80) ONLY for the dangerous filesystem case: a
2214
+ // `filesystem: "full"` agent runs UNSANDBOXED with read access to the whole
2215
+ // disk. Ordinary sandboxed (workspace-confined) spawns stay frictionless —
2216
+ // gate just the high-blast-radius case.
2217
+ if (spec.filesystem === "full") {
2218
+ const step = requireStepUp(req, url);
2219
+ if (!step.ok) return step.response;
2220
+ }
2221
+
1982
2222
  // CHANNEL EXCLUSION: a channel routes inbound to at most one agent. Refuse a
1983
2223
  // spawn for a DIFFERENT programmatic agent onto an already-occupied wake channel
1984
2224
  // (re-spawning the SAME name onto its OWN channel is the idempotent-replace path).
@@ -2120,6 +2360,14 @@ export function createFetchHandler(
2120
2360
  // ---------------------------------------------------------------------
2121
2361
  if (url.pathname === "/api/agent-defs" && (req.method === "GET" || req.method === "POST")) {
2122
2362
  // GET is READ-scoped (a listing, no secrets); POST is admin (it mints/writes).
2363
+ //
2364
+ // NOTE (step-up, agent#80/#154): POST is intentionally NOT step-up-gated, even
2365
+ // though a `#agent/definition` note can carry `filesystem: "full"`. Authoring a
2366
+ // def already requires the scope-gated `vault:write` (the daemon writes the note
2367
+ // with a vault token), so a step-up challenge here would gate a capability the
2368
+ // caller already had to hold a write credential to reach — mirrors the carve-out
2369
+ // comment on the vault-native parse path in agent-defs.ts. The `filesystem:full`
2370
+ // SPAWN path (`POST /api/agents`) IS step-up-gated; this AUTHORING path is not.
2123
2371
  const scope = req.method === "GET" ? SCOPE_READ : SCOPE_ADMIN;
2124
2372
  const denied = await requireScope(req, url, scope);
2125
2373
  if (denied) return denied;
@@ -2273,6 +2521,12 @@ export function createFetchHandler(
2273
2521
  // GET is READ-scoped to mirror GET /api/agent-defs — the listing is non-sensitive
2274
2522
  // ({vault,url,tokenPresent}); `tokenPresent` is a boolean, NEVER the token value.
2275
2523
  // POST is admin (it mints a token + writes config).
2524
+ //
2525
+ // NOTE (step-up, agent#80/#154): POST is intentionally NOT step-up-gated. It mints
2526
+ // a VAULT-SCOPED token (`vault:<name>:write`) — a lower blast radius than the
2527
+ // Claude OAuth credential / terminal / full-fs spawn the step-up PIN guards (those
2528
+ // can exfiltrate every token or open a raw host shell). A def-vault token only
2529
+ // reaches the named vault's notes, so `agent:admin` alone is the right bar here.
2276
2530
  const scope = req.method === "GET" ? SCOPE_READ : SCOPE_ADMIN;
2277
2531
  const denied = await requireScope(req, url, scope);
2278
2532
  if (denied) return denied;
@@ -2745,8 +2999,21 @@ export function createFetchHandler(
2745
2999
  return json({ ok: true, reloaded: result });
2746
3000
  }
2747
3001
 
3002
+ // One-time SSE ticket mint — POST /api/ui/sse-ticket (agent#25). The chat
3003
+ // page can't put its hub JWT in an EventSource URL without leaking it into
3004
+ // access logs, so it trades the JWT (presented HERE as a Bearer header — no
3005
+ // leak) for a single-use, ≤60s opaque ticket it puts in the SSE URL instead.
3006
+ // Bearer-gated on `agent:read` (the scope both browser SSE streams require);
3007
+ // the minted ticket carries ONLY the token's own validated scopes, so it can
3008
+ // never authorize more than the JWT did. An unauthenticated mint is impossible
3009
+ // — `mintSseTicket` runs the scope gate before issuing anything. Returns
3010
+ // `{ ticket, expires_at }`. Externally `<hub>/agent/api/ui/sse-ticket`.
3011
+ if (req.method === "POST" && url.pathname === "/api/ui/sse-ticket") {
3012
+ return mintSseTicket(req, url, SCOPE_READ, mintTicket);
3013
+ }
3014
+
2748
3015
  // 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
3016
+ // a one-time SSE ticket carrying `agent:read`). The streaming
2750
3017
  // view (design 2026-06-16 build item #1): the chat subscribes here to watch a
2751
3018
  // PROGRAMMATIC turn work in real time — interim assistant text + tool_use, then a
2752
3019
  // done/error lifecycle event. EPHEMERAL by design: no backlog/replay (the durable
@@ -2758,11 +3025,12 @@ export function createFetchHandler(
2758
3025
  {
2759
3026
  const turnMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/turn-events$/);
2760
3027
  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);
3028
+ // Browser EventSource can't set an Authorization header, so this SSE
3029
+ // authenticates via a one-time `?ticket=<nonce>` (agent#25) minted by
3030
+ // POST /api/ui/sse-ticket (Bearer-gated) and consumed single-use here. The
3031
+ // hub JWT never rides in this URL. (The stdio-bridge /events SSE uses a
3032
+ // Bearer header, so it never needed a query credential at all.)
3033
+ const denied = requireSseTicket(url, SCOPE_READ);
2766
3034
  if (denied) return denied;
2767
3035
  const channelName = decodeURIComponent(turnMatch[1]!);
2768
3036
  const clientId = crypto.randomUUID();
@@ -3008,6 +3276,14 @@ function main(): void {
3008
3276
  mkdirSync(STATE_DIR, { recursive: true });
3009
3277
  mkdirSync(INBOX_DIR, { recursive: true });
3010
3278
 
3279
+ // BOOT DEPENDENCY PREFLIGHT (agent#156). A fresh box can't run a programmatic
3280
+ // `claude -p` turn until bwrap/rg/socat + the claude CLI are on PATH — pre-#156
3281
+ // each surfaced only as a failed *turn*, one at a time. Check them ONCE at boot and
3282
+ // log a single clear warning (with the install one-liners) when any is missing. It's
3283
+ // advisory, never fatal: the daemon may run only attached-backend agents that need
3284
+ // none of these, so we warn + keep serving. The result is also surfaced on /health.
3285
+ const preflight = runBootPreflight();
3286
+
3011
3287
  // Verify the one MCP SDK internal our HTTP-MCP delivery accounting reads
3012
3288
  // (`_streamMapping['_GET_stream']`, see assertMcpSdkStreamContract). A screaming
3013
3289
  // boot error on SDK drift beats discovering it as silent message loss later.
@@ -3124,7 +3400,7 @@ function main(): void {
3124
3400
  buildInstantiateDeps(channels, registry, deliveryState, programmatic, attachedQueue),
3125
3401
  );
3126
3402
 
3127
- const fetchHandler = createFetchHandler(channels, registry, { deliveryState, programmatic, attachedQueue, turnEvents, jobStore, runner, agentDefs });
3403
+ const fetchHandler = createFetchHandler(channels, registry, { deliveryState, programmatic, attachedQueue, turnEvents, jobStore, runner, agentDefs, preflight });
3128
3404
  const server = Bun.serve<TerminalWsData, never>({
3129
3405
  port: PORT,
3130
3406
  hostname: "127.0.0.1",
@@ -3277,6 +3553,29 @@ function main(): void {
3277
3553
  const bindings = await resolveDefVaults({ hubOrigin: getHubOrigin(), managerBearer });
3278
3554
  for (const b of bindings) agentDefs.addVault(b);
3279
3555
  if (bindings.length === 0) return; // nothing bound — vault-native path idle.
3556
+
3557
+ // AUTO-REGISTER the per-def-vault runtime triggers (agent#157) so "define an
3558
+ // agent → it runs" needs NO manual trigger setup + NO restart-to-pick-up:
3559
+ // - the def-watch create/edit triggers, BARE-keyed (`agent/definition`) so a
3560
+ // created/edited def auto-fires the rescan — upsert-by-name REPLACES any
3561
+ // stale `#agent/definition`-keyed `conn_agentdefs-*` row the hub provisioned;
3562
+ // - the inbound trigger (`agent/message/inbound` + has_metadata:[agent]) so a
3563
+ // new inbound note wakes the agent without a hand-registered trigger.
3564
+ // Mints the admin (triggers API) + agent:send (webhook bearer) tokens the same
3565
+ // way the hub's Connections engine does (attenuated to the operator bearer).
3566
+ // Best-effort: a mint refusal / unreachable vault is logged, never fatal — the
3567
+ // 60s loadAll poll below stays the correctness floor. Skipped with no operator
3568
+ // bearer (can't mint) — the vault-native path still runs own-vault.
3569
+ if (managerBearer) {
3570
+ await registerAllDefVaultTriggers(bindings, { hubOrigin: getHubOrigin(), managerBearer }).catch(
3571
+ (err) => {
3572
+ console.warn(
3573
+ `parachute-agent: def-vault trigger auto-registration failed (continuing): ${(err as Error).message}`,
3574
+ );
3575
+ },
3576
+ );
3577
+ }
3578
+
3280
3579
  const n = await agentDefs.loadAll();
3281
3580
  console.log(
3282
3581
  `parachute-agent: vault-native agent defs — ${n} instantiated from ${bindings.length} def-vault(s).`,