@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/step-up.ts ADDED
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Step-up auth (PIN) for high-privilege agent-admin actions (agent#80).
3
+ *
4
+ * ## The risk this closes
5
+ *
6
+ * A single authenticated `agent:admin` session (the operator's hub cookie traded
7
+ * for an `agent:admin` Bearer) can today do ANYTHING dangerous with no re-confirm:
8
+ * - set/rotate credentials (the Claude OAuth token + the generic env store)
9
+ * → exfiltrate vault / channel / Claude tokens,
10
+ * - open a TERMINAL → a raw host shell,
11
+ * - spawn a `filesystem: full` (unsandboxed) agent → read the whole disk.
12
+ * "One session = total control over the operator's vault + tokens."
13
+ *
14
+ * ## The design (mirrors hub's admin-lock PIN, design `2026-06-17-admin-ui-lock.md`)
15
+ *
16
+ * A SECOND factor on top of `agent:admin`: an operator-set PIN, exchanged for a
17
+ * short-lived **step-up token**. The dangerous endpoints require BOTH a valid
18
+ * `agent:admin` Bearer AND a valid step-up token; everything else stays
19
+ * frictionless.
20
+ *
21
+ * 1. **PIN, set once by the operator** (`setStepUpPin`). Stored HASHED + SALTED
22
+ * (`Bun.password.hash`, argon2id — salt is embedded in the PHC string) in
23
+ * `~/.parachute/agent/step-up.json`, mode 0600. NEVER plaintext, NEVER logged,
24
+ * NEVER returned. Setting/changing it requires the current `agent:admin`
25
+ * session and, if a PIN already exists, the current PIN.
26
+ * 2. **Step-up exchange** (`mintStepUpToken` after `verifyStepUpPin`): an opaque
27
+ * 256-bit CSPRNG nonce, TTL ~5min, held server-side in a TTL'd map. REUSABLE
28
+ * within its window (unlike the single-use SSE ticket) — one PIN entry buys a
29
+ * short working window across several gated actions.
30
+ * 3. **Gate** (`requireStepUp` in `auth.ts`): the dangerous endpoints assert a
31
+ * valid step-up token (header `X-Step-Up-Token`, or `?step_up=` for the
32
+ * terminal WebSocket which can't set a header) in addition to `agent:admin`.
33
+ * A missing/expired token → `403 { error: "step_up_required" }`, distinct from
34
+ * a plain 401 (no/invalid Bearer), so the UI knows to PROMPT vs RE-AUTH.
35
+ *
36
+ * ## Security properties (all load-bearing)
37
+ *
38
+ * - PIN hashed + salted (argon2id); never logged / returned. The hash never
39
+ * leaves this module — the only readers are `verifyStepUpPin` + `setStepUpPin`.
40
+ * - Rate-limited with LOCKOUT ({@link stepUpLimiter}): a compromised `agent:admin`
41
+ * session can't brute-force the PIN. 5 wrong PINs / 5 min, mirroring hub's
42
+ * `unlockLimiter`.
43
+ * - Step-up token: opaque (256-bit nonce), short TTL, SERVER-SIDE only. It NEVER
44
+ * widens scope — it's a second factor ON TOP of `agent:admin`, never a
45
+ * substitute. A request still needs its own valid `agent:admin` Bearer.
46
+ * - No secret in any log: neither the PIN nor the hash nor the token is ever
47
+ * written to a log line.
48
+ *
49
+ * Process-local in-memory token state by design (mirrors `ui-ticket.ts` + the
50
+ * daemon's other in-process registries). The daemon is single-instance per
51
+ * machine; tokens live ≤5min and are cheap to lose on restart (the UI re-prompts).
52
+ */
53
+
54
+ import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync } from "fs";
55
+ import { join } from "path";
56
+ import { defaultStateDir } from "./registry.ts";
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // PIN storage (argon2id hash in step-up.json, mode 0600)
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * PIN format: 4–12 digits. A numeric PIN is the phone-lock affordance (mirrors
64
+ * hub's `ADMIN_LOCK_PIN_RE`). The real defense is the rate-limiter + the fact the
65
+ * session is already `agent:admin`-authenticated — this is a second, convenience-
66
+ * grade re-confirm gate, not a high-entropy secret.
67
+ */
68
+ export const STEP_UP_PIN_RE = /^[0-9]{4,12}$/;
69
+
70
+ /** Whether a candidate string is a well-formed PIN (format check only). */
71
+ export function isValidPinFormat(pin: unknown): pin is string {
72
+ return typeof pin === "string" && STEP_UP_PIN_RE.test(pin);
73
+ }
74
+
75
+ /** The on-disk `step-up.json` shape. Namespaced so a future field can coexist. */
76
+ interface StepUpFile {
77
+ /** argon2id PHC hash of the operator PIN (salt embedded). Never plaintext. */
78
+ pinHash?: string;
79
+ }
80
+
81
+ /** Absolute path to the step-up.json store in a state dir. */
82
+ export function stepUpFilePath(stateDir?: string): string {
83
+ return join(stateDir ?? defaultStateDir(), "step-up.json");
84
+ }
85
+
86
+ /** Read `step-up.json`. Returns `{}` when absent. */
87
+ function readStepUpFile(stateDir?: string): StepUpFile {
88
+ const file = stepUpFilePath(stateDir);
89
+ if (!existsSync(file)) return {};
90
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as StepUpFile;
91
+ if (!parsed || typeof parsed !== "object") {
92
+ throw new Error(`step-up: ${file} must be a JSON object`);
93
+ }
94
+ return parsed;
95
+ }
96
+
97
+ /**
98
+ * Persist `step-up.json` 0600 — it holds the PIN hash. Creates the state dir if
99
+ * needed; `chmod`s 0600 unconditionally (writeFileSync's `mode` only applies on
100
+ * CREATE, so an existing file under a looser umask is tightened on every write) —
101
+ * the exact discipline `credentials.ts` / `registry.ts` keep for secrets.
102
+ */
103
+ function writeStepUpFile(file: StepUpFile, stateDir?: string): void {
104
+ const dir = stateDir ?? defaultStateDir();
105
+ mkdirSync(dir, { recursive: true });
106
+ const path = stepUpFilePath(dir);
107
+ writeFileSync(path, JSON.stringify(file, null, 2) + "\n", { mode: 0o600 });
108
+ chmodSync(path, 0o600);
109
+ }
110
+
111
+ /** True iff a step-up PIN is configured (the feature is set up for this install). */
112
+ export function isStepUpConfigured(stateDir?: string): boolean {
113
+ const h = readStepUpFile(stateDir).pinHash;
114
+ return typeof h === "string" && h.length > 0;
115
+ }
116
+
117
+ /** Thrown by {@link setStepUpPin} when the PIN format is rejected. */
118
+ export class StepUpPinFormatError extends Error {
119
+ constructor() {
120
+ super("PIN must be 4–12 digits");
121
+ this.name = "StepUpPinFormatError";
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Set (first-time) or rotate the step-up PIN. Hashes with argon2id
127
+ * (`Bun.password.hash` — salted, salt embedded in the PHC string). The CALLER
128
+ * must enforce that:
129
+ * - the request is `agent:admin`-authenticated, and
130
+ * - if a PIN ALREADY exists, the current PIN was verified first
131
+ * ({@link verifyStepUpPin}) — rotating a PIN needs the old one.
132
+ * This function trusts that gating; it only validates format + writes the hash.
133
+ *
134
+ * Returns nothing. Throws {@link StepUpPinFormatError} on a malformed PIN.
135
+ */
136
+ export async function setStepUpPin(newPin: string, stateDir?: string): Promise<void> {
137
+ if (!isValidPinFormat(newPin)) throw new StepUpPinFormatError();
138
+ const hash = await Bun.password.hash(newPin, "argon2id");
139
+ const file = readStepUpFile(stateDir);
140
+ file.pinHash = hash;
141
+ writeStepUpFile(file, stateDir);
142
+ }
143
+
144
+ /**
145
+ * Verify a submitted PIN against the stored hash. Returns false when no PIN is
146
+ * configured (defensive — callers gate on {@link isStepUpConfigured} first) or the
147
+ * hash is malformed. The CALLER must run the rate-limiter BEFORE this (a wrong PIN
148
+ * must count toward the lockout). The PIN is never logged.
149
+ */
150
+ export async function verifyStepUpPin(pin: string, stateDir?: string): Promise<boolean> {
151
+ if (typeof pin !== "string" || pin.length === 0) return false;
152
+ const hash = readStepUpFile(stateDir).pinHash;
153
+ if (typeof hash !== "string" || hash.length === 0) return false;
154
+ try {
155
+ return await Bun.password.verify(pin, hash);
156
+ } catch {
157
+ // Corrupt / unparseable hash — fail closed.
158
+ return false;
159
+ }
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Step-up token store — opaque nonce, TTL'd, REUSABLE within its window
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Default step-up token lifetime — 5 min (the issue's ~5min). Long enough for an
168
+ * operator to set a credential / open a terminal / spawn after one PIN entry,
169
+ * short enough that a stolen token (or a walk-away) is bounded.
170
+ */
171
+ export const STEP_UP_TOKEN_TTL_MS = 5 * 60 * 1000;
172
+
173
+ /** Nonce entropy: 32 bytes = 256 bits, matching the SSE ticket's floor. */
174
+ const STEP_UP_TOKEN_BYTES = 32;
175
+
176
+ /** A minted step-up token's server-side record. Never leaves the process. */
177
+ interface StepUpTokenRecord {
178
+ /** Epoch ms after which the token is expired (treated as absent). */
179
+ expiresAt: number;
180
+ }
181
+
182
+ /** The process-local step-up token store. nonce → record. */
183
+ const stepUpTokens = new Map<string, StepUpTokenRecord>();
184
+
185
+ /** base64url-encode bytes (no padding) — URL-safe, no `+`/`/`/`=`. */
186
+ function base64url(bytes: Uint8Array): string {
187
+ let bin = "";
188
+ for (const b of bytes) bin += String.fromCharCode(b);
189
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
190
+ }
191
+
192
+ function pruneExpiredStepUpTokens(now = Date.now()): void {
193
+ for (const [k, rec] of stepUpTokens) {
194
+ if (now >= rec.expiresAt) stepUpTokens.delete(k);
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Mint a step-up token valid for `ttlMs` (default {@link STEP_UP_TOKEN_TTL_MS}).
200
+ * The CALLER must have already verified the PIN — this never authenticates. The
201
+ * token is opaque (no scope/claims rides in it); it is purely a "the PIN was
202
+ * entered recently" capability checked alongside the `agent:admin` Bearer.
203
+ * Returns the nonce + its absolute expiry.
204
+ */
205
+ export function mintStepUpToken(ttlMs = STEP_UP_TOKEN_TTL_MS): {
206
+ token: string;
207
+ expiresAt: number;
208
+ } {
209
+ pruneExpiredStepUpTokens();
210
+ const bytes = new Uint8Array(STEP_UP_TOKEN_BYTES);
211
+ crypto.getRandomValues(bytes);
212
+ const token = base64url(bytes);
213
+ const expiresAt = Date.now() + ttlMs;
214
+ stepUpTokens.set(token, { expiresAt });
215
+ return { token, expiresAt };
216
+ }
217
+
218
+ /**
219
+ * Whether a step-up token is currently valid. REUSABLE within its window (does NOT
220
+ * delete on read — unlike the single-use SSE ticket), so one PIN entry buys a short
221
+ * window across several gated actions. An absent / expired token returns false (and
222
+ * an expired one is lazily pruned). Pure in-memory lookup — no I/O, no secret log.
223
+ */
224
+ export function isStepUpTokenValid(token: string | null | undefined, now = Date.now()): boolean {
225
+ if (!token) return false;
226
+ const rec = stepUpTokens.get(token);
227
+ if (!rec) return false;
228
+ if (now >= rec.expiresAt) {
229
+ stepUpTokens.delete(token);
230
+ return false;
231
+ }
232
+ return true;
233
+ }
234
+
235
+ /** Explicitly revoke a step-up token ("lock now"). Idempotent. */
236
+ export function revokeStepUpToken(token: string | null | undefined): void {
237
+ if (token) stepUpTokens.delete(token);
238
+ }
239
+
240
+ /** Test seam: clear the in-memory token store. */
241
+ export function _resetStepUpTokensForTest(): void {
242
+ stepUpTokens.clear();
243
+ }
244
+
245
+ /** Test seam: the live step-up token count. */
246
+ export function _stepUpTokenCountForTest(): number {
247
+ return stepUpTokens.size;
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // PIN brute-force limiter (lockout) — mirrors hub's admin-lock unlockLimiter
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /**
255
+ * 5 wrong PINs / 5-min sliding window before lockout. The step-up exchange is
256
+ * already `agent:admin`-gated, so the threat is a COMPROMISED session (stolen
257
+ * cookie → minted Bearer) grinding argon2id PIN verifications without bound. Keyed
258
+ * per-session (the validated token's subject) so an attacker can't get a fresh
259
+ * bucket by rotating something cheap. Same floor + posture as hub's `unlockLimiter`.
260
+ */
261
+ export const STEP_UP_MAX_ATTEMPTS = 5;
262
+ export const STEP_UP_WINDOW_MS = 5 * 60 * 1000;
263
+
264
+ export interface RateLimitResult {
265
+ /** True if the attempt is admitted; the caller proceeds to the PIN check. */
266
+ allowed: boolean;
267
+ /** Seconds until the bucket frees up (only set when denied). Always >= 1. */
268
+ retryAfterSeconds?: number;
269
+ }
270
+
271
+ /**
272
+ * A small sliding-window rate limiter (the shape mirrors hub's `RateLimiter`,
273
+ * inlined here to keep the agent module dependency-free). Each key keeps the last
274
+ * N admitted attempt timestamps; on a new attempt we prune anything older than the
275
+ * window, count what remains, and allow/deny. `now` is injectable for tests.
276
+ */
277
+ export class StepUpRateLimiter {
278
+ private readonly buckets = new Map<string, number[]>();
279
+
280
+ constructor(
281
+ private readonly maxAttempts: number,
282
+ private readonly windowMs: number,
283
+ ) {}
284
+
285
+ /**
286
+ * Record an attempt and return whether it's admitted. A DENIED attempt is NOT
287
+ * recorded (so a flood of denials can't push the reset further out). `now` is
288
+ * epoch ms (injectable).
289
+ */
290
+ checkAndRecord(key: string, now = Date.now()): RateLimitResult {
291
+ const cutoff = now - this.windowMs;
292
+ const pruned = (this.buckets.get(key) ?? []).filter((t) => t > cutoff);
293
+ if (pruned.length >= this.maxAttempts) {
294
+ const resetAtMs = (pruned[0] ?? now) + this.windowMs;
295
+ const retryAfterSeconds = Math.max(1, Math.ceil((resetAtMs - now) / 1000));
296
+ this.buckets.set(key, pruned);
297
+ return { allowed: false, retryAfterSeconds };
298
+ }
299
+ pruned.push(now);
300
+ this.buckets.set(key, pruned);
301
+ return { allowed: true };
302
+ }
303
+
304
+ /** Clear a key's bucket (called on a SUCCESSFUL PIN entry so it resets). */
305
+ clear(key: string): void {
306
+ this.buckets.delete(key);
307
+ }
308
+
309
+ /** Test seam: wipe all buckets. */
310
+ reset(): void {
311
+ this.buckets.clear();
312
+ }
313
+ }
314
+
315
+ /** The singleton PIN-attempt limiter (all step-up exchanges share one bucket map). */
316
+ export const stepUpLimiter = new StepUpRateLimiter(STEP_UP_MAX_ATTEMPTS, STEP_UP_WINDOW_MS);
@@ -164,6 +164,65 @@ ${SHELL_JS}
164
164
  });
165
165
  }
166
166
 
167
+ // --- step-up PIN (agent#80) --------------------------------------------
168
+ // A terminal is a raw host shell — the most dangerous capability — so the WS
169
+ // upgrade requires a STEP-UP TOKEN on top of the agent:admin Bearer. The WS
170
+ // can't set a header, so we present it as a step_up query param. We fetch the step-up
171
+ // status, prompt for the PIN (or set one first), exchange it for a short-TTL
172
+ // token, and cache it on window.__stepUp. Re-prompt on expiry / WS auth-fail.
173
+ // This page is server-rendered (no React) so the prompt is a native dialog.
174
+ function authedJson(suffix, init) {
175
+ init = init || {};
176
+ var headers = init.headers || {};
177
+ headers["accept"] = "application/json";
178
+ if (window.__token) headers["authorization"] = "Bearer " + window.__token;
179
+ init.headers = headers;
180
+ return fetch(MOUNT + "/api" + suffix, init);
181
+ }
182
+ function ensureStepUp() {
183
+ if (window.__stepUp && window.__stepUpExp && Date.now() < window.__stepUpExp - 5000) {
184
+ return Promise.resolve(window.__stepUp);
185
+ }
186
+ return authedJson("/step-up").then(function (r) {
187
+ return r.json();
188
+ }).then(function (s) {
189
+ if (!s.configured) {
190
+ var np = window.prompt("Set a step-up PIN (4-12 digits) — required to open a terminal:");
191
+ if (!np) return null;
192
+ var confirm = window.prompt("Confirm the PIN:");
193
+ if (confirm !== np) { showNotice("The PINs didn't match — try again.", true); return null; }
194
+ return authedJson("/step-up/pin", {
195
+ method: "POST",
196
+ headers: { "content-type": "application/json" },
197
+ body: JSON.stringify({ newPin: np }),
198
+ }).then(function (r) {
199
+ if (!r.ok) { showNotice("Could not set the PIN (must be 4-12 digits).", true); return null; }
200
+ return exchangePin(np);
201
+ });
202
+ }
203
+ var pin = window.prompt("Enter your step-up PIN to open a terminal:");
204
+ if (!pin) return null;
205
+ return exchangePin(pin);
206
+ });
207
+ }
208
+ function exchangePin(pin) {
209
+ return authedJson("/step-up", {
210
+ method: "POST",
211
+ headers: { "content-type": "application/json" },
212
+ body: JSON.stringify({ pin: pin }),
213
+ }).then(function (r) {
214
+ if (r.status === 401) { showNotice("Incorrect PIN.", true); return null; }
215
+ if (r.status === 429) { showNotice("Too many PIN attempts — wait a minute.", true); return null; }
216
+ if (!r.ok) { showNotice("Step-up failed (" + r.status + ").", true); return null; }
217
+ return r.json();
218
+ }).then(function (body) {
219
+ if (!body || !body.stepUpToken) return null;
220
+ window.__stepUp = body.stepUpToken;
221
+ window.__stepUpExp = body.expires_at ? new Date(body.expires_at).getTime() : Date.now() + 5 * 60000;
222
+ return window.__stepUp;
223
+ });
224
+ }
225
+
167
226
  // --- WebSocket relay ----------------------------------------------------
168
227
  // The path segment is the AGENT name — the daemon attaches to that agent's tmux
169
228
  // session (<name>-agent). NOT a channel.
@@ -172,6 +231,7 @@ ${SHELL_JS}
172
231
  var dims = "cols=" + term.cols + "&rows=" + term.rows;
173
232
  var u = proto + "//" + location.host + MOUNT + "/terminal/" + encodeURIComponent(agent) + "?" + dims;
174
233
  if (window.__token) u += "&token=" + encodeURIComponent(window.__token);
234
+ if (window.__stepUp) u += "&step_up=" + encodeURIComponent(window.__stepUp);
175
235
  return u;
176
236
  }
177
237
 
@@ -181,6 +241,19 @@ ${SHELL_JS}
181
241
  if (ws) { manualClose = true; try { ws.close(); } catch (_e) {} ws = null; }
182
242
  manualClose = false;
183
243
  clearNotice();
244
+ // Step-up FIRST — a terminal needs the PIN-minted token (agent#80). Only open
245
+ // the socket once we hold one; a cancelled prompt leaves the page idle.
246
+ setStatus("step-up…");
247
+ ensureStepUp().then(function (tok) {
248
+ if (!tok) { setStatus("step-up required", "err"); return; }
249
+ openSocket(agent);
250
+ }).catch(function () {
251
+ setStatus("step-up failed", "err");
252
+ showNotice("Could not complete step-up. Reload and try again.", true);
253
+ });
254
+ }
255
+
256
+ function openSocket(agent) {
184
257
  setStatus("connecting…");
185
258
  doFit();
186
259
  var socket = new WebSocket(wsUrl(agent));
@@ -16,8 +16,9 @@
16
16
  * to them (mirroring the daemon's `/events` SSE pattern for bridges).
17
17
  *
18
18
  * It owns two HTTP surfaces via `ingestHttp` (scoped to ITS OWN channel name):
19
- * 1. POST /api/channels/<name>/send — body {text} → ctx.emit(...) → {ok:true}
20
- * 2. GET /ui/events?channel=<name> — SSE stream the browser subscribes to
19
+ * 1. POST /api/channels/<name>/send — body {text} → ctx.emit(...) → {ok:true}
20
+ * 2. GET /ui/events?channel=<name>&ticket= — SSE stream the browser subscribes to
21
+ * (one-time ticket auth — agent#25; the JWT never rides in this URL)
21
22
  * The static `/ui` chat page itself is global and served by the daemon, since
22
23
  * it's a channel picker across all http-ui channels.
23
24
  */
@@ -30,7 +31,7 @@ import type {
30
31
  PermissionArgs,
31
32
  } from "../transport.ts";
32
33
  import { sseFrame } from "../routing.ts";
33
- import { requireScope, json, SCOPE_SEND, SCOPE_READ } from "../auth.ts";
34
+ import { requireScope, requireSseTicket, json, SCOPE_SEND, SCOPE_READ } from "../auth.ts";
34
35
 
35
36
  /** A connected browser SSE client (one per open chat page on this channel). */
36
37
  interface UiClient {
@@ -168,11 +169,12 @@ export class HttpUiTransport implements Transport {
168
169
  url.pathname === "/ui/events" &&
169
170
  url.searchParams.get("channel") === channel
170
171
  ) {
171
- // Layer 2: gate on `agent:read`. EventSource can't set headers, so the
172
- // token rides in as a `?token=` query param the ONLY endpoint that opts
173
- // into the query-param fallback (allowQueryParam: true). No-token → 401
174
- // before the stream opens.
175
- const denied = await requireScope(req, url, SCOPE_READ, true);
172
+ // Layer 2: EventSource can't set headers, so this gates on a one-time
173
+ // `?ticket=<nonce>` (agent#25)minted by POST /api/ui/sse-ticket
174
+ // (Bearer-gated) and consumed single-use here, carrying `agent:read`. The
175
+ // hub JWT never appears in this URL (the leak the ticket closes). Absent /
176
+ // expired / already-used ticket 401 before the stream opens.
177
+ const denied = requireSseTicket(url, SCOPE_READ);
176
178
  if (denied) return denied;
177
179
  const clientId = crypto.randomUUID();
178
180
  const clients = this.uiClients;
@@ -96,6 +96,17 @@ export interface VaultTransportConfig {
96
96
  webhookSecret?: string;
97
97
  /** Optional path prefix for written notes. Default `channel`. */
98
98
  notePathPrefix?: string;
99
+ /**
100
+ * Whether `start()` fires the best-effort `ensureSchema()` tag-schema upsert
101
+ * against the connected vault. Default `true` (back-compat — the daemon always
102
+ * declares the module's tag inheritance on connect). Tests that construct a
103
+ * transport with a fake token set this `false` so `start()` does NOT hit the
104
+ * live vault on 127.0.0.1:1940 (which 401s the fake token → ~one `console.warn`
105
+ * per schema entry of benign noise). The "tag both parent + child" write floor
106
+ * means a channel works regardless, so skipping the declaration is safe; it's
107
+ * only a setup optimization, not a runtime contract. See #32.
108
+ */
109
+ declareSchemaOnStart?: boolean;
99
110
  }
100
111
 
101
112
  /** The note shape the daemon hands `ingestInbound` (a subset of the trigger payload). */
@@ -261,11 +272,11 @@ export class InboundClaimConflictError extends Error {
261
272
  /** Parent tag (NEW, namespaced) — carried LITERALLY on every note WE write; query
262
273
  * this + metadata.channel to see BOTH directions of a channel (the slash children
263
274
  * are namespace, not inheritance). */
264
- const AGENT_MESSAGE_TAG = "#agent/message";
275
+ const AGENT_MESSAGE_TAG = "agent/message";
265
276
  /** Inbound child (NEW) — the vault trigger fires on this exact tag (never matches outbound → no loop). */
266
- const AGENT_MESSAGE_INBOUND_TAG = "#agent/message/inbound";
277
+ const AGENT_MESSAGE_INBOUND_TAG = "agent/message/inbound";
267
278
  /** Outbound child (NEW) — replies carry this; the trigger's exact-match predicate excludes it. */
268
- const AGENT_MESSAGE_OUTBOUND_TAG = "#agent/message/outbound";
279
+ const AGENT_MESSAGE_OUTBOUND_TAG = "agent/message/outbound";
269
280
 
270
281
  /** Metadata key carrying the channel-queue claim status (design 2026-06-18). */
271
282
  const STATUS_META_KEY = "status";
@@ -369,7 +380,7 @@ function buildThreadSummaryBody(t: {
369
380
  * (it always queries the exact leaf tag); it exists for the nice human rollup, per
370
381
  * the design's namespacing decision.
371
382
  */
372
- export const AGENT_ROOT_TAG = "#agent";
383
+ export const AGENT_ROOT_TAG = "agent";
373
384
 
374
385
  /**
375
386
  * Agent-definition tag — a vault-native agent IS a `#agent/definition` note (design
@@ -377,7 +388,7 @@ export const AGENT_ROOT_TAG = "#agent";
377
388
  * METADATA is the config (name, backend, workspace, isolation, the def-vault binding).
378
389
  * The module reads these notes from a def-vault and instantiates each as a live agent.
379
390
  */
380
- export const AGENT_DEFINITION_TAG = "#agent/definition";
391
+ export const AGENT_DEFINITION_TAG = "agent/definition";
381
392
 
382
393
  /**
383
394
  * Scheduled-job tag — the runner's vault-native job store (design
@@ -386,7 +397,7 @@ export const AGENT_DEFINITION_TAG = "#agent/definition";
386
397
  * `#agent/message`. Introduced in Phase 2 as the flat `#agent-job`; moved into the
387
398
  * `#agent/*` namespace (`#agent/job`) by the vault-native-agents work (Phase 4a).
388
399
  */
389
- export const AGENT_JOB_TAG = "#agent/job";
400
+ export const AGENT_JOB_TAG = "agent/job";
390
401
  /** Default path prefix under which job notes are written: `Channels/<ch>/jobs/<id>`. */
391
402
  const JOB_PATH_PREFIX = "Channels";
392
403
 
@@ -413,7 +424,7 @@ const JOB_PATH_PREFIX = "Channels";
413
424
  * The note carries `['#agent/thread']` EXACTLY — NOT a message tag, NOT the inbound
414
425
  * child — so it can never wake a session (no loop).
415
426
  */
416
- export const AGENT_THREAD_TAG = "#agent/thread";
427
+ export const AGENT_THREAD_TAG = "agent/thread";
417
428
  /** Default path prefix under which thread notes are written: `Threads/<ch>/<leaf>`. */
418
429
  const THREAD_PATH_PREFIX = "Threads";
419
430
 
@@ -557,7 +568,7 @@ export const AGENT_VAULT_TRIGGER_TEMPLATE = {
557
568
  name: "channel_inbound_<channel>", // hub substitutes the channel name
558
569
  events: ["created"],
559
570
  when: {
560
- tags: ["#agent/message/inbound"],
571
+ tags: ["agent/message/inbound"],
561
572
  has_metadata: ["agent"],
562
573
  missing_metadata: ["channel_inbound_rendered_at"],
563
574
  },
@@ -581,7 +592,7 @@ export const AGENT_DEF_VAULT_TRIGGER_TEMPLATE = {
581
592
  name: "agent_def_reload",
582
593
  events: ["created", "updated", "deleted"],
583
594
  when: {
584
- tags: ["#agent/definition"],
595
+ tags: ["agent/definition"],
585
596
  },
586
597
  action: {
587
598
  webhook: "<hub-origin>/agent/api/vault/agent-def", // hub fills origin + the auth.bearer
@@ -605,6 +616,8 @@ export class VaultTransport implements Transport {
605
616
  */
606
617
  readonly webhookSecret?: string;
607
618
  private readonly pathPrefix: string;
619
+ /** See `VaultTransportConfig.declareSchemaOnStart`. Default `true`. */
620
+ private readonly declareSchemaOnStart: boolean;
608
621
 
609
622
  constructor(config: VaultTransportConfig) {
610
623
  if (!config.vault) {
@@ -621,6 +634,7 @@ export class VaultTransport implements Transport {
621
634
  this.token = config.token;
622
635
  this.webhookSecret = config.webhookSecret;
623
636
  this.pathPrefix = (config.notePathPrefix ?? DEFAULT_PATH_PREFIX).replace(/\/$/, "");
637
+ this.declareSchemaOnStart = config.declareSchemaOnStart ?? true;
624
638
  }
625
639
 
626
640
  /**
@@ -642,7 +656,11 @@ export class VaultTransport implements Transport {
642
656
  // "tag both parent + child" floor in the note writes is the fail-safe, so the
643
657
  // channel works even if this declaration never lands. Fire-and-forget — no
644
658
  // reason to delay the channel coming up on a schema upsert.
645
- void this.ensureSchema();
659
+ //
660
+ // Suppressible via `declareSchemaOnStart: false` — tests with a fake token
661
+ // set this so `start()` doesn't 401 against the live vault (benign warn noise,
662
+ // #32). The write floor makes the declaration optional anyway.
663
+ if (this.declareSchemaOnStart) void this.ensureSchema();
646
664
  }
647
665
 
648
666
  // -------------------------------------------------------------------------
@@ -657,11 +675,11 @@ export class VaultTransport implements Transport {
657
675
  * `decodeURIComponent`'d (parachute-vault `src/routes.ts` handleTags, the
658
676
  * "Routes with tag name" block + `routing.ts` `apiPath.startsWith("/tags")`).
659
677
  * Because the route matches a SINGLE path segment (`[^/]+`, no literal slash)
660
- * and decodes it, the tag name — which contains BOTH `#` and `/`
661
- * (`#agent/message/inbound`) — must be `encodeURIComponent`'d so the `#`
662
- * becomes `%23` and the `/` becomes `%2F`; the route then decodes that back to
663
- * the literal name. A bare `/` in the URL would fail the `[^/]+` match → 404,
664
- * silently dropping the declaration. The PUT body is `{ description?, parent_names? }`.
678
+ * and decodes it, the tag name — which contains a `/`
679
+ * (`agent/message/inbound`) — must be `encodeURIComponent`'d so the `/` becomes
680
+ * `%2F`; the route then decodes that back to the literal name. A bare `/` in the
681
+ * URL would fail the `[^/]+` match → 404, silently dropping the declaration. The
682
+ * PUT body is `{ description?, parent_names? }`.
665
683
  *
666
684
  * Best-effort + non-fatal by contract: every failure is caught and `console.warn`'d,
667
685
  * never thrown — the tag-both write floor is the fallback.
@@ -669,8 +687,8 @@ export class VaultTransport implements Transport {
669
687
  async ensureSchema(): Promise<void> {
670
688
  for (const entry of AGENT_VAULT_TAG_SCHEMA) {
671
689
  try {
672
- // Single-segment, percent-encoded name: `#agent/message/inbound` →
673
- // `%23agent%2Fmessage%2Finbound`. The vault decodes it back to the literal.
690
+ // Single-segment, percent-encoded name: `agent/message/inbound` →
691
+ // `agent%2Fmessage%2Finbound`. The vault decodes it back to the literal.
674
692
  const url = `${this.vaultUrl}/vault/${this.vault}/api/tags/${encodeURIComponent(entry.name)}`;
675
693
  const body: {
676
694
  description?: string;
@@ -1099,7 +1117,7 @@ export class VaultTransport implements Transport {
1099
1117
  * namespace, not query inheritance, so we never key off them here.
1100
1118
  *
1101
1119
  * GET <vaultUrl>/vault/<vault>/api/notes
1102
- * ?tag=%23agent%2Fmessage (the `#` + `/` MUST be percent-encoded)
1120
+ * ?tag=agent%2Fmessage (the `/` MUST be percent-encoded)
1103
1121
  * &include_content=true (we need the bodies)
1104
1122
  * &limit=<n> (default 200)
1105
1123
  *
@@ -1138,7 +1156,7 @@ export class VaultTransport implements Transport {
1138
1156
  // empty transcript).
1139
1157
  const fetchByTag = async (tag: string): Promise<RawNote[]> => {
1140
1158
  const params = new URLSearchParams();
1141
- params.set("tag", tag); // URLSearchParams encodes `#` → `%23`
1159
+ params.set("tag", tag); // URLSearchParams encodes `/` → `%2F`
1142
1160
  params.set("include_content", "true");
1143
1161
  params.set("limit", String(fetchLimit));
1144
1162
  const url = `${this.vaultUrl}/vault/${this.vault}/api/notes?${params.toString()}`;
@@ -1380,7 +1398,7 @@ export class VaultTransport implements Transport {
1380
1398
  // Overfetch (the tag query spans all channels) then keep this channel's items.
1381
1399
  const fetchLimit = Math.min(Math.max(limit * 4, 500), 2000);
1382
1400
  const params = new URLSearchParams();
1383
- params.set("tag", AGENT_MESSAGE_INBOUND_TAG); // → %23agent%2Fmessage%2Finbound
1401
+ params.set("tag", AGENT_MESSAGE_INBOUND_TAG); // → agent%2Fmessage%2Finbound
1384
1402
  params.set("include_content", "true");
1385
1403
  params.set("limit", String(fetchLimit));
1386
1404
  // NEWEST-first at the vault (default order_by is `updated_at`) so a hard cap drops
@@ -1508,7 +1526,7 @@ export class VaultTransport implements Transport {
1508
1526
 
1509
1527
  /**
1510
1528
  * List the scheduled-job notes in THIS channel's vault. Queries by the parent
1511
- * `#agent/job` tag (URLSearchParams encodes `#`→`%23`, `/`→`%2F`) and returns ALL job
1529
+ * `#agent/job` tag (URLSearchParams encodes `/`→`%2F`) and returns ALL job
1512
1530
  * notes in the vault — the CALLER filters by `metadata.channel` (same index-free
1513
1531
  * pattern as loadTranscript; we don't assume a `channel` index exists). Throws
1514
1532
  * on a non-ok vault response so the API surfaces a clear error rather than a
@@ -1517,7 +1535,7 @@ export class VaultTransport implements Transport {
1517
1535
  async listJobNotes(opts?: { limit?: number }): Promise<JobNote[]> {
1518
1536
  const limit = opts?.limit ?? 500;
1519
1537
  const params = new URLSearchParams();
1520
- params.set("tag", AGENT_JOB_TAG); // → %23agent%2Fjob
1538
+ params.set("tag", AGENT_JOB_TAG); // → agent%2Fjob
1521
1539
  params.set("include_content", "true");
1522
1540
  params.set("limit", String(limit));
1523
1541
  const url = `${this.vaultUrl}/vault/${this.vault}/api/notes?${params.toString()}`;
package/src/ui-kit.ts CHANGED
@@ -376,9 +376,12 @@ export const SHELL_JS = `
376
376
  el.className = "app-status" + (kind ? " " + kind : "");
377
377
  }
378
378
  // Hub-minted agent token (cookie-gated to the logged-in operator). Cached on
379
- // window.__token; pages attach it as a Bearer header and/or ?token= param.
380
- // (Endpoint renamed /admin/channel-token /admin/agent-token in the
381
- // channel→agent rename; the hub 301-redirects the old path for old bookmarks.)
379
+ // window.__token; pages attach it as a Bearer header. (Browser SSE auth moved
380
+ // off the query-param token to a one-time ticket in agent#25, so the JWT never
381
+ // lands in a URL; this template is the retired server-rendered shell, superseded
382
+ // by the SPA.) Endpoint renamed /admin/channel-token to /admin/agent-token in
383
+ // the channel-to-agent rename; the hub 301-redirects the old path for old
384
+ // bookmarks.
382
385
  function fetchToken() {
383
386
  return fetch(window.location.origin + "/admin/agent-token", { credentials: "include" })
384
387
  .then(function (r) { if (!r.ok) throw new Error("token " + r.status); return r.json(); })