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.
- package/CHANGELOG.md +96 -0
- package/LICENSE +201 -0
- package/README.md +161 -0
- package/package.json +76 -0
- package/src/agent-card.ts +39 -0
- package/src/agent.ts +283 -0
- package/src/agentmail-client.ts +138 -0
- package/src/augments/bash/index.ts +463 -0
- package/src/augments/bash/skill/SKILL.md +156 -0
- package/src/augments/budgets/budget-store.ts +513 -0
- package/src/augments/budgets/index.ts +134 -0
- package/src/augments/budgets/preamble.ts +93 -0
- package/src/augments/budgets/types.ts +89 -0
- package/src/augments/file-memory/index.ts +71 -0
- package/src/augments/filesystem/index.ts +533 -0
- package/src/augments/filesystem/skill/SKILL.md +142 -0
- package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
- package/src/augments/layered-memory/extractor/buffer.ts +56 -0
- package/src/augments/layered-memory/extractor/frequency.ts +79 -0
- package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
- package/src/augments/layered-memory/extractor/parse.ts +75 -0
- package/src/augments/layered-memory/extractor/prompt.md +26 -0
- package/src/augments/layered-memory/index.ts +757 -0
- package/src/augments/layered-memory/skill/SKILL.md +153 -0
- package/src/augments/layered-memory/storage/migrations/README.md +16 -0
- package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
- package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
- package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
- package/src/augments/layered-memory/storage/types.ts +98 -0
- package/src/augments/link/index.ts +489 -0
- package/src/augments/link/translate.ts +261 -0
- package/src/augments/notify/adapters/agentmail.ts +70 -0
- package/src/augments/notify/adapters/telegram.ts +60 -0
- package/src/augments/notify/adapters/webhook.ts +55 -0
- package/src/augments/notify/index.ts +284 -0
- package/src/augments/notify/skill/SKILL.md +150 -0
- package/src/augments/org-context/index.ts +721 -0
- package/src/augments/org-context/skill/SKILL.md +96 -0
- package/src/augments/skills/index.ts +103 -0
- package/src/augments/supabase-memory/index.ts +151 -0
- package/src/augments/telegram-transport/index.ts +312 -0
- package/src/augments/telegram-transport/polling.ts +55 -0
- package/src/augments/telegram-transport/webhook.ts +56 -0
- package/src/augments/turn-control/index.ts +61 -0
- package/src/augments/turn-control/skill/SKILL.md +155 -0
- package/src/augments/visitor-auth/email-validation.ts +66 -0
- package/src/augments/visitor-auth/index.ts +779 -0
- package/src/augments/visitor-auth/rate-limiter.ts +90 -0
- package/src/augments/visitor-auth/skill/SKILL.md +55 -0
- package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
- package/src/augments/visitor-auth/storage/types.ts +164 -0
- package/src/augments/visitor-auth/types.ts +123 -0
- package/src/augments/visitor-auth/verify-page.ts +179 -0
- package/src/augments/web-fetch/index.ts +331 -0
- package/src/augments/web-fetch/skill/SKILL.md +100 -0
- package/src/cli/agent-index.ts +289 -0
- package/src/cli/augment-catalog.ts +320 -0
- package/src/cli/augment-resolver.ts +597 -0
- package/src/cli/commands/add-skill.ts +194 -0
- package/src/cli/commands/add.ts +87 -0
- package/src/cli/commands/chat.ts +207 -0
- package/src/cli/commands/create.ts +462 -0
- package/src/cli/commands/dev.ts +139 -0
- package/src/cli/commands/eval.ts +180 -0
- package/src/cli/commands/ls.ts +66 -0
- package/src/cli/commands/remove.ts +95 -0
- package/src/cli/commands/restart.ts +40 -0
- package/src/cli/commands/start.ts +123 -0
- package/src/cli/commands/status.ts +104 -0
- package/src/cli/commands/stop.ts +84 -0
- package/src/cli/commands/visitors-revoke.ts +155 -0
- package/src/cli/commands/visitors.ts +101 -0
- package/src/cli/config-parser.ts +1034 -0
- package/src/cli/engine-resolver.ts +68 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/model-picker.ts +89 -0
- package/src/cli/pid-registry.ts +146 -0
- package/src/cli/plist-generator.ts +117 -0
- package/src/cli/resolve-config.ts +56 -0
- package/src/cli/scaffold-skills.ts +158 -0
- package/src/cli/scaffold.ts +291 -0
- package/src/cli/skill-frontmatter.ts +51 -0
- package/src/cli/skill-validator.ts +151 -0
- package/src/cli/types.ts +228 -0
- package/src/cli/yaml-helpers.ts +66 -0
- package/src/engines/_shared/cost.ts +55 -0
- package/src/engines/_shared/schema-normalize.ts +75 -0
- package/src/engines/anthropic/pricing.ts +117 -0
- package/src/engines/anthropic.ts +483 -0
- package/src/engines/openai/pricing.ts +67 -0
- package/src/engines/openai.ts +446 -0
- package/src/engines/openrouter/pricing.ts +83 -0
- package/src/engines/openrouter.ts +185 -0
- package/src/helpers.ts +24 -0
- package/src/http.ts +387 -0
- package/src/index.ts +165 -0
- package/src/kernel/capability-table.ts +172 -0
- package/src/kernel/context-allocator.ts +161 -0
- package/src/kernel/history-manager.ts +198 -0
- package/src/kernel/lifecycle-manager.ts +106 -0
- package/src/kernel/output-validator.ts +35 -0
- package/src/kernel/preamble.ts +23 -0
- package/src/kernel/route-collector.ts +97 -0
- package/src/kernel/timeout.ts +21 -0
- package/src/kernel/tool-selector.ts +47 -0
- package/src/kernel/trace-emitter.ts +66 -0
- package/src/kernel/transport-queue.ts +147 -0
- package/src/kernel/turn-loop.ts +1148 -0
- package/src/memory/context-synthesis.ts +83 -0
- package/src/memory/memory-bus.ts +61 -0
- package/src/memory/registry.ts +80 -0
- package/src/memory/tools.ts +320 -0
- package/src/memory/types.ts +8 -0
- package/src/parts.ts +30 -0
- package/src/scaffold-templates/identity.md +31 -0
- package/src/telegram-client.ts +145 -0
- package/src/tokenizer.ts +14 -0
- package/src/transports/ag-ui-events.ts +253 -0
- package/src/transports/visitor-token.ts +82 -0
- package/src/transports/web-transport.ts +948 -0
- 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
|
+
}
|