auggy 0.3.0

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 (121) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/README.md +161 -0
  4. package/package.json +76 -0
  5. package/src/agent-card.ts +39 -0
  6. package/src/agent.ts +283 -0
  7. package/src/agentmail-client.ts +138 -0
  8. package/src/augments/bash/index.ts +463 -0
  9. package/src/augments/bash/skill/SKILL.md +156 -0
  10. package/src/augments/budgets/budget-store.ts +513 -0
  11. package/src/augments/budgets/index.ts +134 -0
  12. package/src/augments/budgets/preamble.ts +93 -0
  13. package/src/augments/budgets/types.ts +89 -0
  14. package/src/augments/file-memory/index.ts +71 -0
  15. package/src/augments/filesystem/index.ts +533 -0
  16. package/src/augments/filesystem/skill/SKILL.md +142 -0
  17. package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
  18. package/src/augments/layered-memory/extractor/buffer.ts +56 -0
  19. package/src/augments/layered-memory/extractor/frequency.ts +79 -0
  20. package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
  21. package/src/augments/layered-memory/extractor/parse.ts +75 -0
  22. package/src/augments/layered-memory/extractor/prompt.md +26 -0
  23. package/src/augments/layered-memory/index.ts +757 -0
  24. package/src/augments/layered-memory/skill/SKILL.md +153 -0
  25. package/src/augments/layered-memory/storage/migrations/README.md +16 -0
  26. package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
  27. package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
  28. package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
  29. package/src/augments/layered-memory/storage/types.ts +98 -0
  30. package/src/augments/link/index.ts +489 -0
  31. package/src/augments/link/translate.ts +261 -0
  32. package/src/augments/notify/adapters/agentmail.ts +70 -0
  33. package/src/augments/notify/adapters/telegram.ts +60 -0
  34. package/src/augments/notify/adapters/webhook.ts +55 -0
  35. package/src/augments/notify/index.ts +284 -0
  36. package/src/augments/notify/skill/SKILL.md +150 -0
  37. package/src/augments/org-context/index.ts +721 -0
  38. package/src/augments/org-context/skill/SKILL.md +96 -0
  39. package/src/augments/skills/index.ts +103 -0
  40. package/src/augments/supabase-memory/index.ts +151 -0
  41. package/src/augments/telegram-transport/index.ts +312 -0
  42. package/src/augments/telegram-transport/polling.ts +55 -0
  43. package/src/augments/telegram-transport/webhook.ts +56 -0
  44. package/src/augments/turn-control/index.ts +61 -0
  45. package/src/augments/turn-control/skill/SKILL.md +155 -0
  46. package/src/augments/visitor-auth/email-validation.ts +66 -0
  47. package/src/augments/visitor-auth/index.ts +779 -0
  48. package/src/augments/visitor-auth/rate-limiter.ts +90 -0
  49. package/src/augments/visitor-auth/skill/SKILL.md +55 -0
  50. package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
  51. package/src/augments/visitor-auth/storage/types.ts +164 -0
  52. package/src/augments/visitor-auth/types.ts +123 -0
  53. package/src/augments/visitor-auth/verify-page.ts +179 -0
  54. package/src/augments/web-fetch/index.ts +331 -0
  55. package/src/augments/web-fetch/skill/SKILL.md +100 -0
  56. package/src/cli/agent-index.ts +289 -0
  57. package/src/cli/augment-catalog.ts +320 -0
  58. package/src/cli/augment-resolver.ts +597 -0
  59. package/src/cli/commands/add-skill.ts +194 -0
  60. package/src/cli/commands/add.ts +87 -0
  61. package/src/cli/commands/chat.ts +207 -0
  62. package/src/cli/commands/create.ts +462 -0
  63. package/src/cli/commands/dev.ts +139 -0
  64. package/src/cli/commands/eval.ts +180 -0
  65. package/src/cli/commands/ls.ts +66 -0
  66. package/src/cli/commands/remove.ts +95 -0
  67. package/src/cli/commands/restart.ts +40 -0
  68. package/src/cli/commands/start.ts +123 -0
  69. package/src/cli/commands/status.ts +104 -0
  70. package/src/cli/commands/stop.ts +84 -0
  71. package/src/cli/commands/visitors-revoke.ts +155 -0
  72. package/src/cli/commands/visitors.ts +101 -0
  73. package/src/cli/config-parser.ts +1034 -0
  74. package/src/cli/engine-resolver.ts +68 -0
  75. package/src/cli/index.ts +178 -0
  76. package/src/cli/model-picker.ts +89 -0
  77. package/src/cli/pid-registry.ts +146 -0
  78. package/src/cli/plist-generator.ts +117 -0
  79. package/src/cli/resolve-config.ts +56 -0
  80. package/src/cli/scaffold-skills.ts +158 -0
  81. package/src/cli/scaffold.ts +291 -0
  82. package/src/cli/skill-frontmatter.ts +51 -0
  83. package/src/cli/skill-validator.ts +151 -0
  84. package/src/cli/types.ts +228 -0
  85. package/src/cli/yaml-helpers.ts +66 -0
  86. package/src/engines/_shared/cost.ts +55 -0
  87. package/src/engines/_shared/schema-normalize.ts +75 -0
  88. package/src/engines/anthropic/pricing.ts +117 -0
  89. package/src/engines/anthropic.ts +483 -0
  90. package/src/engines/openai/pricing.ts +67 -0
  91. package/src/engines/openai.ts +446 -0
  92. package/src/engines/openrouter/pricing.ts +83 -0
  93. package/src/engines/openrouter.ts +185 -0
  94. package/src/helpers.ts +24 -0
  95. package/src/http.ts +387 -0
  96. package/src/index.ts +165 -0
  97. package/src/kernel/capability-table.ts +172 -0
  98. package/src/kernel/context-allocator.ts +161 -0
  99. package/src/kernel/history-manager.ts +198 -0
  100. package/src/kernel/lifecycle-manager.ts +106 -0
  101. package/src/kernel/output-validator.ts +35 -0
  102. package/src/kernel/preamble.ts +23 -0
  103. package/src/kernel/route-collector.ts +97 -0
  104. package/src/kernel/timeout.ts +21 -0
  105. package/src/kernel/tool-selector.ts +47 -0
  106. package/src/kernel/trace-emitter.ts +66 -0
  107. package/src/kernel/transport-queue.ts +147 -0
  108. package/src/kernel/turn-loop.ts +1148 -0
  109. package/src/memory/context-synthesis.ts +83 -0
  110. package/src/memory/memory-bus.ts +61 -0
  111. package/src/memory/registry.ts +80 -0
  112. package/src/memory/tools.ts +320 -0
  113. package/src/memory/types.ts +8 -0
  114. package/src/parts.ts +30 -0
  115. package/src/scaffold-templates/identity.md +31 -0
  116. package/src/telegram-client.ts +145 -0
  117. package/src/tokenizer.ts +14 -0
  118. package/src/transports/ag-ui-events.ts +253 -0
  119. package/src/transports/visitor-token.ts +82 -0
  120. package/src/transports/web-transport.ts +948 -0
  121. package/src/types.ts +1009 -0
@@ -0,0 +1,948 @@
1
+ import type {
2
+ Augment,
3
+ PeerIdentity,
4
+ TransportSpec,
5
+ TransportKernel,
6
+ TurnTrigger,
7
+ InboundMessage,
8
+ KernelEvent,
9
+ Part,
10
+ } from "../types";
11
+ import {
12
+ translateKernelEvent,
13
+ serializeSSE,
14
+ runFinished,
15
+ runError,
16
+ type AGUIEvent,
17
+ } from "./ag-ui-events";
18
+ import {
19
+ deriveSigningKey,
20
+ createVisitorToken,
21
+ verifyVisitorToken,
22
+ type VisitorTokenPayload,
23
+ } from "./visitor-token";
24
+ import { withTimeout, TimeoutError } from "../kernel/timeout";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export interface AgentAccessEntry {
31
+ id: string;
32
+ sharedSecret: string;
33
+ }
34
+
35
+ export interface WebTransportOptions {
36
+ port: number;
37
+ auth: { type: "bearer"; token: string };
38
+ cors?: { origins: string[] };
39
+ maxMessageLength?: number;
40
+ /**
41
+ * Admitted agent list. Each entry has an `id` (sent as `x-agent-id` header)
42
+ * and a `sharedSecret` (sent as `x-agent-secret` header). The transport
43
+ * does a timing-safe comparison before minting agent trust.
44
+ */
45
+ access?: { agents?: AgentAccessEntry[] };
46
+ concurrency?: number;
47
+ maxQueueDepth?: number;
48
+ rateLimitPerPeer?: { maxPerMinute: number };
49
+ visitorTokens?: {
50
+ enabled?: boolean;
51
+ ttlSeconds?: number;
52
+ signingKey?: string;
53
+ /**
54
+ * Optional real-time revocation check. Called after HMAC verification
55
+ * succeeds (fix C1). When the callback returns `true` for a visitorId,
56
+ * the token is treated as anonymous — rendering revoked tokens inert
57
+ * without waiting for their HMAC TTL to expire.
58
+ */
59
+ revocationCheck?: (visitorId: string) => boolean;
60
+ /**
61
+ * Stable identifier for this agent used to scope visitor tokens (fix C2).
62
+ * MUST match visitorAuth's `agentBinding` option. Default: `"auggy"`.
63
+ * Tokens minted for a different agentBinding are rejected, preventing
64
+ * cross-agent replay when two agents share the same signing key.
65
+ *
66
+ * Only enforce when explicitly configured — leaving this unset means the
67
+ * default `"auggy"` is used, which matches the visitorAuth default.
68
+ */
69
+ agentBinding?: string;
70
+ };
71
+ /**
72
+ * Optional URL to redirect GET / to. When set, `GET /` returns 302 to this URL.
73
+ * When unset, `GET /` returns 404. All other routes are unaffected.
74
+ *
75
+ * Used by operators to point visitors arriving at the agent's bare URL toward
76
+ * a polished frontend (LORF platform/chat, future spine visitor chat, custom).
77
+ */
78
+ publicFrontendUrl?: string;
79
+ /**
80
+ * Allow-list of upstream proxies whose `X-Forwarded-For` / `X-Real-IP`
81
+ * headers are trusted for per-route per-IP rate limiting (F16).
82
+ *
83
+ * Each entry is an exact IP string (CIDR ranges are not yet supported —
84
+ * v1 keeps it simple). When the connection's remote address matches an
85
+ * entry, the first XFF / X-Real-IP value is trusted. When it does not,
86
+ * the headers are IGNORED and the connection IP is used directly.
87
+ *
88
+ * Default: `[]` (default-secure). With no trusted proxy list, an
89
+ * untrusted client could spoof their `X-Forwarded-For` header and
90
+ * bypass per-IP rate limiting; the empty default forces operators to
91
+ * declare their proxy chain explicitly. On the first request that
92
+ * arrives with an XFF header AND no `trustedProxies` configured, a
93
+ * single `console.warn` per startup nudges the operator with a
94
+ * config hint (typical when deploying behind Railway / Fly /
95
+ * Cloudflare for the first time).
96
+ */
97
+ trustedProxies?: string[];
98
+ }
99
+
100
+ interface AGUIRunRequestBody {
101
+ messages: Array<{ role: string; content: string }>;
102
+ threadId?: string;
103
+ contextId?: string;
104
+ taskId?: string;
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Body-size cap helper (Finding 2: byte-counted enforcement)
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * Read a ReadableStream up to `cap` bytes. Returns the buffered Uint8Array on
113
+ * success, or `null` if the stream exceeded the cap. Throws on stream errors.
114
+ *
115
+ * This is the byte-counted body cap enforcement (vs. trusting content-length).
116
+ */
117
+ async function readBodyWithCap(
118
+ body: ReadableStream<Uint8Array>,
119
+ cap: number,
120
+ ): Promise<Uint8Array | null> {
121
+ const reader = body.getReader();
122
+ const chunks: Uint8Array[] = [];
123
+ let total = 0;
124
+ try {
125
+ // eslint-disable-next-line no-constant-condition
126
+ while (true) {
127
+ const { done, value } = await reader.read();
128
+ if (done) break;
129
+ if (value) {
130
+ total += value.byteLength;
131
+ if (total > cap) {
132
+ await reader.cancel();
133
+ return null;
134
+ }
135
+ chunks.push(value);
136
+ }
137
+ }
138
+ } finally {
139
+ reader.releaseLock();
140
+ }
141
+ const out = new Uint8Array(total);
142
+ let offset = 0;
143
+ for (const c of chunks) {
144
+ out.set(c, offset);
145
+ offset += c.byteLength;
146
+ }
147
+ return out;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Caller-IP helper (Finding 3: per-IP rate limiting)
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /**
155
+ * Normalize an IP string. Strips the IPv4-mapped IPv6 prefix
156
+ * (`::ffff:1.2.3.4` → `1.2.3.4`) so operators can list `"127.0.0.1"` in
157
+ * `trustedProxies` without worrying about which form Bun's
158
+ * `server.requestIP()` happens to return on a given platform/socket.
159
+ *
160
+ * Returns the input unchanged for any other shape. Exported for direct
161
+ * unit testing — the IPv4-mapped form is hard to trigger reliably from
162
+ * an integration test (Bun's localhost typically returns `::1` or
163
+ * `127.0.0.1` directly).
164
+ */
165
+ export function normalizeIp(ip: string | null | undefined): string | null {
166
+ if (!ip) return null;
167
+ const m = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
168
+ return m ? m[1]! : ip;
169
+ }
170
+
171
+ /**
172
+ * Extract the caller's IP for rate-limit keying.
173
+ *
174
+ * F16 — only honors `x-forwarded-for` / `x-real-ip` when the connection's
175
+ * remote address is on the configured `trustedProxies` allow-list. Without
176
+ * this gate, an untrusted client could spoof XFF and skip per-IP rate
177
+ * limiting. With the empty default `trustedProxies: []`, the headers are
178
+ * always ignored and the connection IP is used directly.
179
+ *
180
+ * Even WITH a trusted proxy, the leftmost XFF entry cannot be trusted —
181
+ * an attacker can pre-seed `X-Forwarded-For: spoofed` before connecting,
182
+ * and an append-style proxy will produce
183
+ * `X-Forwarded-For: spoofed, attacker-real-ip`. The leftmost is still
184
+ * attacker-controlled. Standard fix (mirrors nginx, Express `trust proxy`):
185
+ * walk right-to-left, dropping trusted-proxy hops as we go. The first
186
+ * NON-trusted entry encountered is the actual client IP. If every entry
187
+ * is on the trusted list (very unusual chain of internal hops), fall
188
+ * through to the connection IP.
189
+ *
190
+ * The first time an XFF arrives WITHOUT `trustedProxies` configured (or
191
+ * from a connection IP not on the list), `xffOnUntrusted` fires once per
192
+ * startup — operators deploying behind a known proxy (Railway, Fly,
193
+ * Cloudflare) get a clear hint to add the proxy IP rather than silently
194
+ * degrading. Latched on XFF specifically (not X-Real-IP) so the warning
195
+ * text matches the trigger.
196
+ *
197
+ * Returns `"unknown"` when no source resolves.
198
+ */
199
+ function getCallerIp(
200
+ req: Request,
201
+ server: { requestIP?: (req: Request) => { address?: string } | null } | undefined,
202
+ trustedProxies: readonly string[],
203
+ xffOnUntrusted: () => void,
204
+ ): string {
205
+ const xff = req.headers.get("x-forwarded-for");
206
+ const realIp = req.headers.get("x-real-ip");
207
+ let connIp: string | null = null;
208
+ try {
209
+ connIp = server?.requestIP?.(req)?.address ?? null;
210
+ } catch {
211
+ connIp = null;
212
+ }
213
+ const connIpNorm = normalizeIp(connIp);
214
+ const proxiesNorm = trustedProxies.map((p) => normalizeIp(p) ?? p);
215
+ const proxyIsTrusted = connIpNorm !== null && proxiesNorm.includes(connIpNorm);
216
+
217
+ if (xff && !proxyIsTrusted) {
218
+ // Warn-once: XFF arrived from an untrusted source. Almost certainly an
219
+ // operator that hasn't configured trustedProxies after deploying behind
220
+ // a proxy. Narrowed to XFF (not X-Real-IP) because the warning copy
221
+ // names XFF specifically.
222
+ xffOnUntrusted();
223
+ }
224
+
225
+ if (proxyIsTrusted && xff) {
226
+ // Right-to-left walk. Drop entries that are themselves trusted proxies
227
+ // (each such entry was a known intermediate hop). Return the first
228
+ // non-trusted entry — that's the client IP under the standard
229
+ // append-style XFF convention.
230
+ const entries = xff
231
+ .split(",")
232
+ .map((s) => normalizeIp(s.trim()) ?? s.trim())
233
+ .filter(Boolean);
234
+ for (let i = entries.length - 1; i >= 0; i--) {
235
+ const entry = entries[i]!;
236
+ if (!proxiesNorm.includes(entry)) return entry;
237
+ }
238
+ // All XFF entries are themselves trusted proxies — chain consists
239
+ // entirely of internal hops. Fall through to connIp (the immediate
240
+ // peer, also a trusted proxy).
241
+ }
242
+ if (proxyIsTrusted && realIp) {
243
+ // X-Real-IP is a single value set by the proxy (no append semantics);
244
+ // trust it directly.
245
+ return normalizeIp(realIp.trim()) ?? realIp.trim();
246
+ }
247
+ return connIpNorm ?? "unknown";
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Idempotency-Key validation
252
+ // ---------------------------------------------------------------------------
253
+
254
+ const IDEMPOTENCY_KEY_RE = /^[A-Za-z0-9_-]{1,128}$/;
255
+
256
+ function validateIdempotencyKey(value: string): boolean {
257
+ return IDEMPOTENCY_KEY_RE.test(value);
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Timing-safe string comparison (constant-time)
262
+ // ---------------------------------------------------------------------------
263
+
264
+ const textEncoder = new TextEncoder();
265
+
266
+ /**
267
+ * Timing-safe equality check for two strings. Returns true iff they are
268
+ * byte-for-byte identical. Both inputs are encoded before comparison so
269
+ * the comparison always runs over the full longer length.
270
+ */
271
+ function timingSafeEqual(a: string, b: string): boolean {
272
+ const ab = textEncoder.encode(a);
273
+ const bb = textEncoder.encode(b);
274
+ // If lengths differ, we still walk the full longer length so timing
275
+ // doesn't leak whether the prefix matched.
276
+ const len = Math.max(ab.length, bb.length);
277
+ let diff = ab.length ^ bb.length; // non-zero if lengths differ
278
+ for (let i = 0; i < len; i++) {
279
+ diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
280
+ }
281
+ return diff === 0;
282
+ }
283
+
284
+ /**
285
+ * AG-UI-compatible HTTP transport.
286
+ *
287
+ * Endpoints:
288
+ * - POST /agent/run — AG-UI SSE endpoint
289
+ * - GET /health — liveness check
290
+ * - GET /.well-known/agent-card.json — Agent Card (via kernel.getAgentCard)
291
+ *
292
+ * ## Identity resolution — four paths (evaluated in order)
293
+ *
294
+ * Path 1 — Creator: bearer token matches `auth.token` AND no agent/visitor
295
+ * headers. Mints `{ trustLevel: "creator" }`.
296
+ *
297
+ * Path 2 — Agent: `x-agent-id` + `x-agent-secret` headers present. Looks
298
+ * up the agent in `opts.access.agents`; if the secret matches (timing-safe)
299
+ * mints `{ trustLevel: "agent" }`. Wrong secret → 401 (no silent downgrade).
300
+ *
301
+ * Path 3 — Public recognized: `x-visitor-token` with valid HMAC. Mints
302
+ * `{ trustLevel: "public", publicSubstate: "recognized" }`.
303
+ *
304
+ * Path 4 — Public anonymous: default. Mints
305
+ * `{ trustLevel: "public", publicSubstate: "anonymous" }`.
306
+ *
307
+ * ## Idempotency-Key
308
+ *
309
+ * When `Idempotency-Key` header is present, validated (1-128 chars,
310
+ * `[A-Za-z0-9_-]`) and used as `turnId`. Absent → fresh UUID.
311
+ * Malformed → HTTP 400.
312
+ *
313
+ * ## Rejected turns
314
+ *
315
+ * When the kernel rejects a turn with `errorClass: "cap-denied"`,
316
+ * the SSE payload carries `code: "CAP_DENIED"`. For
317
+ * `errorClass: "admission-state-failed"`, code is `"ADMISSION_FAILED"`.
318
+ * HTTP status remains 200 for the SSE response in T4 — T5 will add the
319
+ * synchronous gate-decision API that allows choosing 429/503 before
320
+ * opening the stream.
321
+ */
322
+ export function webTransport(opts: WebTransportOptions): Augment {
323
+ let server: ReturnType<typeof Bun.serve> | null = null;
324
+ let kernel: TransportKernel | null = null;
325
+
326
+ // PR γ.1 — augment-registered routes captured at register() time.
327
+ // Empty until register fires; once populated, immutable for the server's lifetime.
328
+ // Type matches TransportKernel.getAugmentRoutes(); runtime values are CollectedRoute
329
+ // (which extends AugmentHttpRoute with augmentName) — we cast where needed.
330
+ let augmentRoutes: readonly import("../types").AugmentHttpRoute[] = [];
331
+ let augmentRouteMap: Map<string, import("../types").AugmentHttpRoute> = new Map();
332
+
333
+ // PR γ.1 — per-route rate-limit state. Sliding-window timestamps keyed by
334
+ // "<METHOD> <path>". NOT per-peer — auth-none routes have no peer.
335
+ const routeHits = new Map<string, number[]>();
336
+
337
+ // F16 — warn-once latch for "XFF arrived from untrusted connection".
338
+ // The first time getCallerIp sees this condition without a configured
339
+ // trustedProxies list (or with the connection IP not on the list), we
340
+ // emit a single console.warn so operators deploying behind Railway / Fly /
341
+ // Cloudflare for the first time get a clear hint to configure their
342
+ // proxy chain. Latched per-instance so the warning isn't spammed every
343
+ // request.
344
+ const trustedProxies: readonly string[] = opts.trustedProxies ?? [];
345
+ let xffUntrustedWarned = false;
346
+ function xffOnUntrusted(): void {
347
+ if (xffUntrustedWarned) return;
348
+ xffUntrustedWarned = true;
349
+ if (trustedProxies.length === 0) {
350
+ console.warn(
351
+ "[web-transport] X-Forwarded-For header received but trustedProxies is unset. " +
352
+ "Per-IP rate limiting is using the connection IP, NOT the XFF header. " +
353
+ "If you deploy behind a proxy (Railway, Fly, Cloudflare), set " +
354
+ "webTransport.trustedProxies to the proxy IP(s) so the header is honored.",
355
+ );
356
+ } else {
357
+ console.warn(
358
+ "[web-transport] X-Forwarded-For header received from a connection IP that is " +
359
+ "NOT on trustedProxies. The header is being ignored for rate limiting. " +
360
+ `Configured trustedProxies: ${trustedProxies.join(", ")}.`,
361
+ );
362
+ }
363
+ }
364
+
365
+ function checkRouteRateLimit(
366
+ routeKey: string,
367
+ ip: string,
368
+ max: number,
369
+ ): { allowed: true } | { allowed: false; retryAfterSec: number } {
370
+ const fullKey = `${routeKey}|${ip}`;
371
+ const now = Date.now();
372
+ const windowStart = now - 60_000;
373
+ const hits = (routeHits.get(fullKey) ?? []).filter((t) => t > windowStart);
374
+ if (hits.length >= max) {
375
+ const oldestInWindow = hits[0]!;
376
+ const retryAfterMs = oldestInWindow + 60_000 - now;
377
+ routeHits.set(fullKey, hits);
378
+ return { allowed: false, retryAfterSec: Math.max(1, Math.ceil(retryAfterMs / 1000)) };
379
+ }
380
+ hits.push(now);
381
+ routeHits.set(fullKey, hits);
382
+ return { allowed: true };
383
+ }
384
+
385
+ const maxMessageLength = opts.maxMessageLength ?? 4000;
386
+ // F2: visitor tokens are opt-in (enabled === true) rather than opt-out
387
+ // (enabled !== false). Requiring an explicit signingKey at onBoot prevents
388
+ // the silent mismatch where webTransport boots with an ephemeral key that
389
+ // differs from the one visitorAuth uses to mint tokens. When configured via
390
+ // the augment-resolver, visitorAuth's signingKey is auto-injected and
391
+ // enabled is set to true. Direct callers must pass both explicitly.
392
+ const visitorTokensEnabled = opts.visitorTokens?.enabled === true;
393
+ const visitorTokenTtl = opts.visitorTokens?.ttlSeconds ?? 30 * 24 * 3600;
394
+ let signingKey: CryptoKey | null = null;
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // Identity resolver — four paths
398
+ // ---------------------------------------------------------------------------
399
+
400
+ const identify = (raw: unknown): PeerIdentity | null => {
401
+ const req = raw as {
402
+ headers: Record<string, string>;
403
+ __visitorPayload?: VisitorTokenPayload;
404
+ __threadId?: string;
405
+ };
406
+ const headers = req.headers;
407
+ const kind = (headers["x-peer-kind"] as PeerIdentity["kind"]) ?? "human";
408
+
409
+ const agentId = headers["x-agent-id"];
410
+ const agentSecret = headers["x-agent-secret"];
411
+ const hasAgentHeaders = typeof agentId === "string" && typeof agentSecret === "string";
412
+
413
+ // PATH 2: Agent credentials — present regardless of bearer auth.
414
+ // If x-agent-id / x-agent-secret are both set, this is a deliberate
415
+ // agent authentication attempt. A wrong secret MUST return null (→ 401),
416
+ // not silently fall through to public.
417
+ if (hasAgentHeaders) {
418
+ const admittedAgents = opts.access?.agents ?? [];
419
+ const entry = admittedAgents.find((a) => a.id === agentId);
420
+ if (!entry || !timingSafeEqual(agentSecret, entry.sharedSecret)) {
421
+ // Signal failed agent auth — the HTTP handler checks this sentinel.
422
+ return null;
423
+ }
424
+ return {
425
+ id: `agent:${agentId}`,
426
+ kind: "agent",
427
+ trustLevel: "agent",
428
+ sourceAugment: "web",
429
+ displayName: headers["x-peer-name"],
430
+ orgId: headers["x-org-id"],
431
+ };
432
+ }
433
+
434
+ // PATH 1: Creator — bearer-only request (no visitor token either).
435
+ // The bearer token is already validated by the HTTP handler before identify()
436
+ // is called. If there's no visitor token, this is a direct creator call.
437
+ if (!req.__visitorPayload && !headers["x-visitor-token"]) {
438
+ return {
439
+ id: "creator",
440
+ kind: "human",
441
+ trustLevel: "creator",
442
+ sourceAugment: "web",
443
+ displayName: headers["x-peer-name"],
444
+ orgId: headers["x-org-id"],
445
+ };
446
+ }
447
+
448
+ // PATH 3: Public recognized — visitor token was verified before identify() is called.
449
+ if (req.__visitorPayload) {
450
+ return {
451
+ id: req.__visitorPayload.visitorId,
452
+ kind,
453
+ trustLevel: "public",
454
+ publicSubstate: "recognized",
455
+ sourceAugment: "web",
456
+ displayName: headers["x-peer-name"],
457
+ orgId: headers["x-org-id"],
458
+ };
459
+ }
460
+
461
+ // PATH 4: Public anonymous — no agent headers, no verified visitor token.
462
+ // Use the threadId from the request body (injected as __threadId) for the peer ID.
463
+ const threadId = req.__threadId ?? crypto.randomUUID();
464
+ return {
465
+ id: `anon-${threadId}`,
466
+ kind,
467
+ trustLevel: "public",
468
+ publicSubstate: "anonymous",
469
+ sourceAugment: "web",
470
+ displayName: headers["x-peer-name"],
471
+ orgId: headers["x-org-id"],
472
+ };
473
+ };
474
+
475
+ const transport: TransportSpec = {
476
+ async register(k: TransportKernel, _augmentName: string) {
477
+ kernel = k;
478
+ augmentRoutes = k.getAugmentRoutes();
479
+ augmentRouteMap = new Map();
480
+ for (const r of augmentRoutes) {
481
+ augmentRouteMap.set(`${r.method} ${r.path}`, r);
482
+ // Operator-visible audit: log every auth: "none" route so an operator
483
+ // grepping the boot log can spot unauthenticated surfaces.
484
+ // Runtime values are CollectedRoute (extends AugmentHttpRoute with augmentName).
485
+ if (r.auth === "none") {
486
+ const augmentName = (r as { augmentName?: string }).augmentName ?? "(unknown)";
487
+ console.warn(
488
+ `[web-transport] augment "${augmentName}" registered ${r.method} ${r.path} with auth: "none" — public, unauthenticated.`,
489
+ );
490
+ }
491
+ }
492
+ },
493
+ identify,
494
+ concurrency: opts.concurrency ?? 1,
495
+ maxQueueDepth: opts.maxQueueDepth ?? 50,
496
+ rateLimitPerPeer: opts.rateLimitPerPeer,
497
+ };
498
+
499
+ function isValidAuth(header: string): boolean {
500
+ const expected = `Bearer ${opts.auth.token}`;
501
+ // Use timing-safe comparison to prevent token extraction via timing side-channel.
502
+ return timingSafeEqual(header, expected);
503
+ }
504
+
505
+ async function handleAgentRun(req: Request): Promise<Response> {
506
+ const authHeader = req.headers.get("authorization") ?? "";
507
+ if (!isValidAuth(authHeader)) {
508
+ return json({ error: "unauthorized" }, 401);
509
+ }
510
+
511
+ // --- Idempotency-Key ---
512
+ let turnId: string;
513
+ const idempotencyKey = req.headers.get("idempotency-key");
514
+ if (idempotencyKey !== null) {
515
+ if (!validateIdempotencyKey(idempotencyKey)) {
516
+ return json(
517
+ {
518
+ error: "invalid_idempotency_key",
519
+ reason: "Idempotency-Key must be 1–128 characters matching [A-Za-z0-9_-]",
520
+ },
521
+ 400,
522
+ );
523
+ }
524
+ turnId = idempotencyKey;
525
+ } else {
526
+ turnId = crypto.randomUUID();
527
+ }
528
+
529
+ // --- Visitor token handling ---
530
+ let visitorPayload: VisitorTokenPayload | null = null;
531
+ let newToken: string | null = null;
532
+
533
+ if (visitorTokensEnabled && signingKey) {
534
+ const tokenHeader = req.headers.get("x-visitor-token");
535
+ if (tokenHeader) {
536
+ visitorPayload = await verifyVisitorToken(signingKey, tokenHeader);
537
+ // Fix C1: reject tokens whose visitor has since been revoked.
538
+ // Called after HMAC verification succeeds so revoked identities cannot
539
+ // continue to authenticate with old tokens until the HMAC TTL expires.
540
+ if (visitorPayload) {
541
+ if (opts.visitorTokens?.revocationCheck?.(visitorPayload.visitorId)) {
542
+ visitorPayload = null;
543
+ }
544
+ }
545
+ // Fix C2: reject tokens minted for a different agentBinding.
546
+ // Only enforced when agentBinding is explicitly configured; leaving it
547
+ // unset skips the check for backward compatibility.
548
+ if (visitorPayload) {
549
+ const expectedBinding = opts.visitorTokens?.agentBinding;
550
+ if (expectedBinding !== undefined && visitorPayload.agentId !== expectedBinding) {
551
+ visitorPayload = null;
552
+ }
553
+ }
554
+ }
555
+ if (!visitorPayload) {
556
+ // Check if this looks like an agent auth attempt — don't issue visitor
557
+ // tokens to agent-credential requests (they'll be resolved as agent/creator).
558
+ const agentId = req.headers.get("x-agent-id");
559
+ const agentSecret = req.headers.get("x-agent-secret");
560
+ const hasAgentHeaders = agentId !== null && agentSecret !== null;
561
+ const hasVisitorTokenAttempt = tokenHeader !== null;
562
+
563
+ if (hasVisitorTokenAttempt && !hasAgentHeaders) {
564
+ // Had a visitor token header but it was invalid or missing — mint a fresh
565
+ // token to send in the response so the recipient has a valid token for their
566
+ // NEXT request. Do NOT assign issued.payload to visitorPayload here: the
567
+ // current request presented either no token or a bad one, so it stays
568
+ // public:anonymous. The freshly-issued token is for future requests only.
569
+ //
570
+ // Fix C2: use agentBinding when configured, else agent-card name.
571
+ // This ensures the anon-token and the visitorAuth-minted token agree on
572
+ // the agentId embedded in the payload, enabling the agentBinding check below.
573
+ const agentName =
574
+ opts.visitorTokens?.agentBinding ?? kernel?.getAgentCard()?.provider?.name ?? "auggy";
575
+ const issued = await createVisitorToken(signingKey, agentName, visitorTokenTtl);
576
+ newToken = issued.token;
577
+ // visitorPayload intentionally left null — this request is anonymous.
578
+ }
579
+ // hasAgentHeaders case: no visitor token for agent requests.
580
+ }
581
+ }
582
+
583
+ // --- Build headers map ---
584
+ const headers: Record<string, string> = {};
585
+ req.headers.forEach((v, k) => {
586
+ headers[k.toLowerCase()] = v;
587
+ });
588
+
589
+ // --- Parse body (needed for threadId for anonymous peer ID) ---
590
+ let body: AGUIRunRequestBody;
591
+ try {
592
+ body = (await req.json()) as AGUIRunRequestBody;
593
+ } catch {
594
+ return json({ error: "invalid JSON body" }, 400);
595
+ }
596
+ if (!Array.isArray(body.messages) || body.messages.length === 0) {
597
+ return json({ error: "messages array is required" }, 400);
598
+ }
599
+
600
+ const lastMessage = body.messages[body.messages.length - 1]!;
601
+ const text = lastMessage.content ?? "";
602
+ if (text.length > maxMessageLength) {
603
+ return json({ error: "message too long", limit: maxMessageLength }, 413);
604
+ }
605
+
606
+ if (!kernel) {
607
+ return json({ error: "transport not registered" }, 500);
608
+ }
609
+
610
+ // Derive threadId — needed before identify() so anonymous peer IDs are stable.
611
+ const threadId = body.threadId ?? body.contextId ?? crypto.randomUUID();
612
+
613
+ // Build identify argument. Inject __threadId so the anonymous path can use it.
614
+ const identifyArg: {
615
+ headers: Record<string, string>;
616
+ __visitorPayload?: VisitorTokenPayload;
617
+ __threadId: string;
618
+ } = { headers, __threadId: threadId };
619
+ if (visitorPayload) {
620
+ identifyArg.__visitorPayload = visitorPayload;
621
+ }
622
+
623
+ // --- Check agent auth failure explicitly ---
624
+ // If x-agent-id + x-agent-secret are present, identify() returns null on bad secret.
625
+ const agentIdHeader = req.headers.get("x-agent-id");
626
+ const agentSecretHeader = req.headers.get("x-agent-secret");
627
+ const isAgentAttempt = agentIdHeader !== null && agentSecretHeader !== null;
628
+
629
+ const peer = identify(identifyArg);
630
+ if (!peer) {
631
+ if (isAgentAttempt) {
632
+ // Explicit agent auth attempt with wrong credentials.
633
+ return json({ error: "invalid agent credentials" }, 401);
634
+ }
635
+ // Fallback (should not happen with the four-path design, but guard).
636
+ return json({ error: "missing peer identity" }, 400);
637
+ }
638
+
639
+ const parts: Part[] = [{ kind: "text", text }];
640
+ const inbound: InboundMessage = {
641
+ parts,
642
+ sourceAugment: "web",
643
+ peer,
644
+ timestamp: Date.now(),
645
+ contextId: body.contextId,
646
+ taskId: body.taskId,
647
+ };
648
+ const trigger: TurnTrigger = {
649
+ type: "message",
650
+ turnId,
651
+ threadId,
652
+ contextId: body.contextId,
653
+ taskId: body.taskId,
654
+ timestamp: Date.now(),
655
+ source: "web",
656
+ peer,
657
+ payload: inbound,
658
+ };
659
+
660
+ const k = kernel;
661
+ const encoder = new TextEncoder();
662
+
663
+ const stream = new ReadableStream<Uint8Array>({
664
+ start(controller) {
665
+ let streamClosed = false;
666
+
667
+ const patchThreadId = (e: AGUIEvent): AGUIEvent => {
668
+ if (e.type === "RUN_FINISHED" && !e.threadId) {
669
+ // Spread to preserve `result` (and any future fields) the
670
+ // translator attaches; only the threadId needs patching.
671
+ return { ...e, threadId };
672
+ }
673
+ return e;
674
+ };
675
+
676
+ const writeEvent = (e: AGUIEvent) => {
677
+ if (streamClosed) return; // guard against enqueue after close
678
+ try {
679
+ controller.enqueue(encoder.encode(serializeSSE(patchThreadId(e))));
680
+ } catch {
681
+ // Stream already closed (client disconnect) — swallow
682
+ streamClosed = true;
683
+ }
684
+ };
685
+
686
+ const onEvent = (kernelEvent: KernelEvent) => {
687
+ for (const e of translateKernelEvent(kernelEvent)) {
688
+ writeEvent(e);
689
+ }
690
+ };
691
+
692
+ (async () => {
693
+ try {
694
+ const result = await k.handleInbound(trigger, { onEvent });
695
+ if (result.status === "rejected") {
696
+ // Map errorClass to a structured code for SSE consumers.
697
+ // T5 will refine this to return 429/503 HTTP status before
698
+ // opening the stream (requires a synchronous gate-decision API).
699
+ let code: string;
700
+ if (result.errorClass === "cap-denied") {
701
+ code = "CAP_DENIED";
702
+ } else if (result.errorClass === "admission-state-failed") {
703
+ code = "ADMISSION_FAILED";
704
+ } else {
705
+ code = "REJECTED";
706
+ }
707
+ writeEvent(
708
+ runError({
709
+ message: result.errorResponse ?? "request rejected by transport",
710
+ code,
711
+ }),
712
+ );
713
+ writeEvent(runFinished({ threadId, runId: trigger.turnId, status: result.status }));
714
+ }
715
+ } catch (err) {
716
+ writeEvent(runError({ message: String(err), code: "INTERNAL" }));
717
+ writeEvent(runFinished({ threadId, runId: trigger.turnId, status: "failed" }));
718
+ } finally {
719
+ streamClosed = true;
720
+ try {
721
+ controller.close();
722
+ } catch {
723
+ /* already closed */
724
+ }
725
+ }
726
+ })();
727
+ },
728
+ });
729
+
730
+ const sseHeaders: Record<string, string> = {
731
+ "content-type": "text/event-stream",
732
+ "cache-control": "no-cache",
733
+ connection: "keep-alive",
734
+ };
735
+ if (newToken) {
736
+ sseHeaders["x-visitor-token"] = newToken;
737
+ }
738
+ if (opts.cors) {
739
+ sseHeaders["access-control-allow-origin"] = opts.cors.origins.join(",");
740
+ sseHeaders["access-control-expose-headers"] = "x-visitor-token, idempotency-key";
741
+ }
742
+ return new Response(stream, { status: 200, headers: sseHeaders });
743
+ }
744
+
745
+ function handleCorsPreFlight(): Response {
746
+ const headers: Record<string, string> = {
747
+ "access-control-allow-methods": "GET, POST, OPTIONS",
748
+ "access-control-allow-headers":
749
+ "content-type, authorization, x-peer-id, x-peer-kind, x-peer-name, x-org-id, x-visitor-token, x-agent-id, x-agent-secret, idempotency-key",
750
+ "access-control-expose-headers": "x-visitor-token, idempotency-key",
751
+ "access-control-max-age": "86400",
752
+ };
753
+ if (opts.cors) {
754
+ headers["access-control-allow-origin"] = opts.cors.origins.join(",");
755
+ }
756
+ return new Response(null, { status: 204, headers });
757
+ }
758
+
759
+ function handleHealth(): Response {
760
+ return json({ status: "healthy" }, 200);
761
+ }
762
+
763
+ function handleAgentCard(): Response {
764
+ if (!kernel) {
765
+ return json({ error: "transport not registered" }, 500);
766
+ }
767
+ return json(kernel.getAgentCard(), 200);
768
+ }
769
+
770
+ function json(body: unknown, status: number): Response {
771
+ const headers: Record<string, string> = {
772
+ "content-type": "application/json",
773
+ };
774
+ if (opts.cors) {
775
+ headers["access-control-allow-origin"] = opts.cors.origins.join(",");
776
+ }
777
+ return new Response(JSON.stringify(body), { status, headers });
778
+ }
779
+
780
+ return {
781
+ name: "web",
782
+ capabilities: ["transport"],
783
+ transport,
784
+ async onBoot() {
785
+ if (visitorTokensEnabled) {
786
+ const keySource = opts.visitorTokens?.signingKey;
787
+ if (!keySource) {
788
+ throw new Error(
789
+ "[web-transport] visitorTokens.enabled is true but signingKey is not set. " +
790
+ "If visitorAuth is mounted, the resolver should inject this automatically — file an issue if you see this. " +
791
+ "Otherwise, mount visitorAuth (which is the augment that mints visitor tokens) or set visitorTokens.enabled: false explicitly.",
792
+ );
793
+ }
794
+ signingKey = await deriveSigningKey(keySource);
795
+ }
796
+ server = Bun.serve({
797
+ port: opts.port,
798
+ idleTimeout: 120, // 120s — covers long model calls + tool chains
799
+ async fetch(req, server) {
800
+ const url = new URL(req.url);
801
+
802
+ // CORS preflight — required for browser-based AG-UI clients
803
+ if (req.method === "OPTIONS") {
804
+ return handleCorsPreFlight();
805
+ }
806
+
807
+ // GET / — optional redirect to operator-configured publicFrontendUrl
808
+ if (req.method === "GET" && url.pathname === "/") {
809
+ if (opts.publicFrontendUrl) {
810
+ return Response.redirect(opts.publicFrontendUrl, 302);
811
+ }
812
+ return new Response("Not Found", { status: 404 });
813
+ }
814
+
815
+ if (req.method === "POST" && url.pathname === "/agent/run") {
816
+ return handleAgentRun(req);
817
+ }
818
+ if (req.method === "GET" && url.pathname === "/health") {
819
+ return handleHealth();
820
+ }
821
+ if (req.method === "GET" && url.pathname === "/.well-known/agent-card.json") {
822
+ return handleAgentCard();
823
+ }
824
+
825
+ // PR γ.1 — augment-registered routes. Dispatched by exact (method, path).
826
+ const augmentRoute = augmentRouteMap.get(`${req.method} ${url.pathname}`);
827
+ if (augmentRoute) {
828
+ // Finding 4: Default-deny — anything not explicitly "none" requires bearer.
829
+ // The collector rejects unknown auth values at boot, but defense in depth
830
+ // keeps dispatch fail-closed against runtime mutations.
831
+ if (augmentRoute.auth !== "none") {
832
+ const authHeader = req.headers.get("authorization") ?? "";
833
+ if (!isValidAuth(authHeader)) {
834
+ return new Response(JSON.stringify({ error: "unauthorized" }), {
835
+ status: 401,
836
+ headers: { "content-type": "application/json" },
837
+ });
838
+ }
839
+ }
840
+ // auth: "none" — no check; fall through to body cap
841
+
842
+ // Finding 2 — body-size cap. Buffer the request body up to maxBodyBytes
843
+ // bytes, enforcing actual byte-count (not just content-length header).
844
+ // Adversarial clients can omit content-length or use chunked encoding
845
+ // to bypass a header-only check.
846
+ const maxBodyBytes = augmentRoute.maxBodyBytes ?? 1_048_576;
847
+ let dispatchReq: Request = req;
848
+ if (req.method !== "GET" && req.method !== "HEAD" && req.body) {
849
+ try {
850
+ const buffered = await readBodyWithCap(req.body, maxBodyBytes);
851
+ if (buffered === null) {
852
+ return new Response(JSON.stringify({ error: "payload-too-large" }), {
853
+ status: 413,
854
+ headers: { "content-type": "application/json" },
855
+ });
856
+ }
857
+ // Reconstruct the Request with the buffered body so the handler
858
+ // sees the same bytes (and can call req.text(), req.json(), etc).
859
+ dispatchReq = new Request(req.url, {
860
+ method: req.method,
861
+ headers: req.headers,
862
+ body: buffered,
863
+ });
864
+ } catch (_err) {
865
+ // Body read errors are 400 — caller's problem, not ours.
866
+ return new Response(JSON.stringify({ error: "bad-body" }), {
867
+ status: 400,
868
+ headers: { "content-type": "application/json" },
869
+ });
870
+ }
871
+ }
872
+
873
+ // Finding 3 — per-route rate limit keyed by route + caller IP.
874
+ // Prevents one client from exhausting the bucket for everyone.
875
+ if (augmentRoute.rateLimit) {
876
+ const routeKey = `${augmentRoute.method} ${augmentRoute.path}`;
877
+ const ip = getCallerIp(req, server, trustedProxies, xffOnUntrusted);
878
+ const rl = checkRouteRateLimit(routeKey, ip, augmentRoute.rateLimit.maxPerMinute);
879
+ if (!rl.allowed) {
880
+ return new Response(JSON.stringify({ error: "rate-limited" }), {
881
+ status: 429,
882
+ headers: {
883
+ "content-type": "application/json",
884
+ "retry-after": String(rl.retryAfterSec),
885
+ },
886
+ });
887
+ }
888
+ }
889
+
890
+ try {
891
+ // Finding 1 — AbortController for cooperative cancellation on timeout.
892
+ // The controller fires on timeout so handlers that listen to the signal
893
+ // can bail out of side-effecting work instead of continuing after 504.
894
+ const timeoutMs = augmentRoute.timeoutMs ?? 30_000;
895
+ const controller = new AbortController();
896
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
897
+ try {
898
+ return await withTimeout(
899
+ () => augmentRoute.handler(dispatchReq, { signal: controller.signal }),
900
+ timeoutMs,
901
+ );
902
+ } finally {
903
+ clearTimeout(timer);
904
+ }
905
+ } catch (err) {
906
+ if (err instanceof TimeoutError) {
907
+ return new Response(JSON.stringify({ error: "timeout" }), {
908
+ status: 504,
909
+ headers: { "content-type": "application/json" },
910
+ });
911
+ }
912
+ const augmentName =
913
+ (augmentRoute as { augmentName?: string }).augmentName ?? "unknown";
914
+ console.error(
915
+ `[web-transport] augment "${augmentName}" handler ${augmentRoute.method} ${augmentRoute.path} threw: ${(err as Error).message}`,
916
+ );
917
+ return new Response(JSON.stringify({ error: "internal" }), {
918
+ status: 500,
919
+ headers: { "content-type": "application/json" },
920
+ });
921
+ }
922
+ }
923
+
924
+ // Method-mismatch detection: if any registered augment route matches
925
+ // the path but a different method, return 405 with Allow header listing
926
+ // all methods supported for that path (RFC 9110 §15.5.6).
927
+ const allowedMethods = augmentRoutes
928
+ .filter((r) => r.path === url.pathname && r.method !== req.method)
929
+ .map((r) => r.method);
930
+ if (allowedMethods.length > 0) {
931
+ return new Response("Method Not Allowed", {
932
+ status: 405,
933
+ headers: { allow: allowedMethods.join(", ") },
934
+ });
935
+ }
936
+
937
+ return new Response("Not Found", { status: 404 });
938
+ },
939
+ });
940
+ },
941
+ async onShutdown() {
942
+ if (server) {
943
+ server.stop();
944
+ server = null;
945
+ }
946
+ },
947
+ };
948
+ }