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,185 @@
1
+ import OpenAI from "openai";
2
+ import {
3
+ assembleOpenAISystemMessage,
4
+ buildOpenAIModelResponse,
5
+ convertOpenAIMessages,
6
+ convertOpenAITools,
7
+ } from "./openai";
8
+ import { resolveSlug, priceOpenRouterResponse } from "./openrouter/pricing";
9
+ import type { AssembledPrompt, ModelClient, ModelDelta, ModelResponse } from "../types";
10
+
11
+ /**
12
+ * OpenRouter engine — wraps the official `openai` SDK with the OpenRouter
13
+ * baseURL and OpenRouter-specific extras (`reasoning` wrapper, `provider`
14
+ * routing). The wire format is OpenAI Chat Completions, so message and tool
15
+ * conversion are imported directly from the OpenAI engine.
16
+ *
17
+ * The TS SDK has no named `extra_body` parameter (the Python SDK does); extra
18
+ * body fields are passed by spreading them into the `create()` params with a
19
+ * type cast — the SDK forwards unknown fields at runtime.
20
+ */
21
+ export interface OpenRouterEngineOptions {
22
+ /** API key. Falls back to `process.env.OPENROUTER_API_KEY` if omitted.
23
+ * If neither is set, the factory THROWS — passing `apiKey: undefined`
24
+ * to the SDK would silently fall through to `OPENAI_API_KEY` and use the
25
+ * wrong credential against OpenRouter's endpoint. */
26
+ apiKey?: string;
27
+ /** OpenRouter model slug (e.g. "qwen/qwen3.5-397b-a17b", "openai/gpt-5"). */
28
+ model: string;
29
+ /** Total context window in tokens. Defaults to 128_000 (conservative).
30
+ * OpenRouter model context windows vary widely — set this per model.
31
+ * Too high and the kernel may build prompts the upstream provider
32
+ * cannot accept; too low and you waste budget. */
33
+ maxContextTokens?: number;
34
+ /** Per-turn output cap, sent as `max_completion_tokens`. Defaults to 4096. */
35
+ maxTokens?: number;
36
+ /** Reasoning effort for reasoning-capable models (Qwen3.5 thinking,
37
+ * o-series via OpenRouter, etc). Forwarded as `reasoning.effort` in the
38
+ * OpenRouter-normalized request body. See OpenAIEngineOptions for the
39
+ * semantics of each value. */
40
+ reasoningEffort?: OpenAI.Chat.ChatCompletionReasoningEffort;
41
+ /** OpenRouter provider routing hints. Forwarded as the `provider` body
42
+ * field. Slugs in `only`/`ignore` are NOT semantically validated — typos
43
+ * silently fall back to OpenRouter's default routing. */
44
+ providerRouting?: OpenRouterProviderRouting;
45
+ /**
46
+ * Override pricing for cost estimation. If set, the adapter uses these rates
47
+ * instead of the built-in pricing table. Useful for unknown models or custom
48
+ * pricing arrangements. USD per million tokens.
49
+ *
50
+ * Accepts the full Pricing shape; cache fields are accepted for type symmetry
51
+ * with Anthropic but not used by the OpenRouter adapter today (no cache-token
52
+ * usage is parsed from OpenRouter responses).
53
+ */
54
+ costOverride?: import("./_shared/cost").Pricing;
55
+ }
56
+
57
+ /** OpenRouter provider routing config (forwarded as the `provider` body field).
58
+ * Mirrors `ProviderRouting` in `src/cli/types.ts` but lives here so the
59
+ * engine module is self-contained for the public API. */
60
+ export interface OpenRouterProviderRouting {
61
+ /** Allowlist of provider slugs (e.g. ["OpenAI", "Anthropic"]). */
62
+ only?: string[];
63
+ /** Denylist of provider slugs. */
64
+ ignore?: string[];
65
+ /** Sort upstream providers by this attribute. */
66
+ sort?: "price" | "throughput" | "latency";
67
+ /** Cap upstream prices in USD per million tokens. */
68
+ max_price?: { prompt?: number; completion?: number };
69
+ }
70
+
71
+ const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
72
+
73
+ /** Local extension of the SDK request type with OpenRouter-specific extras.
74
+ * These fields don't exist in the SDK's typed surface — OpenRouter's
75
+ * server reads them when present and the SDK forwards them unchanged. */
76
+ type OpenRouterChatParams = OpenAI.Chat.ChatCompletionCreateParamsNonStreaming & {
77
+ reasoning?: { effort: OpenAI.Chat.ChatCompletionReasoningEffort };
78
+ provider?: OpenRouterProviderRouting;
79
+ };
80
+
81
+ export function createOpenRouterEngine(opts: OpenRouterEngineOptions): ModelClient {
82
+ const apiKey = opts.apiKey ?? process.env.OPENROUTER_API_KEY;
83
+ if (!apiKey) {
84
+ throw new Error(
85
+ "OPENROUTER_API_KEY is not set (or pass apiKey explicitly to createOpenRouterEngine)",
86
+ );
87
+ }
88
+
89
+ const client = new OpenAI({
90
+ apiKey,
91
+ baseURL: OPENROUTER_BASE_URL,
92
+ defaultHeaders: { "X-Title": "Auggy" },
93
+ });
94
+
95
+ const maxContextTokens = opts.maxContextTokens ?? 128_000;
96
+ const maxOutputTokens = opts.maxTokens ?? 4096;
97
+
98
+ // Pricing freshness + availability warning at startup. Fires once at
99
+ // factory time, not per-turn.
100
+ if (!opts.costOverride) {
101
+ const resolved = resolveSlug(opts.model);
102
+ if (!resolved) {
103
+ // eslint-disable-next-line no-console
104
+ console.warn(
105
+ `[engines/openrouter] No pricing entry for slug "${opts.model}" and no costOverride configured. ` +
106
+ `OpenRouter v0 cost estimation is limited to anthropic/* and openai/* slugs. ` +
107
+ `For other providers, configure engine.costOverride in agent.yaml.`,
108
+ );
109
+ } else if (resolved.freshness.stale) {
110
+ // Freshness binds to the resolved provider's verifiedAt, not OpenRouter's own table.
111
+ // eslint-disable-next-line no-console
112
+ console.warn(
113
+ `[engines/openrouter] Pricing table verifiedAt ${resolved.freshness.verifiedAt} is more than 90 days old. ` +
114
+ `Cost estimates may be drifting from actual billing. Verify rates and update src/engines/${resolved.resolvedProvider}/pricing.ts.`,
115
+ );
116
+ }
117
+ } else if (
118
+ opts.costOverride.cacheWriteUsdPerMtok !== undefined ||
119
+ opts.costOverride.cacheReadUsdPerMtok !== undefined
120
+ ) {
121
+ // Operator set cache rates on OpenRouter override. Today's adapter does
122
+ // not parse cache tokens from OpenRouter responses, so cache rates would
123
+ // be silently ignored. Warn loudly rather than silently under-report.
124
+ // eslint-disable-next-line no-console
125
+ console.warn(
126
+ `[engines/openrouter] costOverride.cacheWriteUsdPerMtok/cacheReadUsdPerMtok set but ignored — ` +
127
+ `the OpenRouter adapter does not parse cache tokens from upstream responses. Cache rates will not contribute to costUsd.`,
128
+ );
129
+ }
130
+
131
+ return {
132
+ maxContextTokens,
133
+
134
+ countTokens(text: string): number {
135
+ return Math.ceil(text.length / 4);
136
+ },
137
+
138
+ async complete(
139
+ prompt: AssembledPrompt,
140
+ _opts?: { onDelta?: (delta: ModelDelta) => void },
141
+ ): Promise<ModelResponse> {
142
+ const systemMessage = assembleOpenAISystemMessage(prompt);
143
+ const messages = convertOpenAIMessages(prompt.messages);
144
+ const tools = convertOpenAITools(prompt.tools);
145
+
146
+ const allMessages: OpenAI.Chat.ChatCompletionMessageParam[] = systemMessage
147
+ ? [systemMessage, ...messages]
148
+ : messages;
149
+
150
+ // The TS SDK has no extra_body field; OpenRouter-specific keys
151
+ // (`reasoning`, `provider`) go directly on the params object via a
152
+ // typed extension and are forwarded at runtime. The cast at the
153
+ // boundary is the only place we loosen the SDK's typed contract.
154
+ const params: OpenRouterChatParams = {
155
+ model: opts.model,
156
+ max_completion_tokens: maxOutputTokens,
157
+ messages: allMessages,
158
+ ...(tools.length > 0 ? { tools } : {}),
159
+ ...(opts.reasoningEffort ? { reasoning: { effort: opts.reasoningEffort } } : {}),
160
+ ...(opts.providerRouting ? { provider: opts.providerRouting } : {}),
161
+ };
162
+
163
+ let completion: OpenAI.Chat.ChatCompletion;
164
+ try {
165
+ completion = await client.chat.completions.create(
166
+ params as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming,
167
+ );
168
+ } catch (err) {
169
+ // Wrap with provider+model context. Without this, an OpenRouter
170
+ // upstream error (e.g. provider 502) reads identically to an
171
+ // OpenAI direct-call error in logs.
172
+ const msg = err instanceof Error ? err.message : String(err);
173
+ throw new Error(`OpenRouter engine (${opts.model}) failed: ${msg}`, { cause: err });
174
+ }
175
+ const response = buildOpenAIModelResponse(completion, `openrouter:${opts.model}`);
176
+ const result = priceOpenRouterResponse(opts.model, opts.costOverride, {
177
+ prompt_tokens: response.inputTokens,
178
+ completion_tokens: response.outputTokens,
179
+ });
180
+ return result.priced
181
+ ? { ...response, costUsd: result.costUsd }
182
+ : { ...response, costUsd: undefined, unpricedReason: result.reason };
183
+ },
184
+ };
185
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { z } from "zod";
2
+ import type { Augment, Tool, ToolCategory, ToolExecuteContext, ToolResult } from "./types";
3
+
4
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5
+ export function defineTool<T extends z.ZodType<any, any, any>>(opts: {
6
+ name: string;
7
+ description: string;
8
+ category: ToolCategory;
9
+ input: T;
10
+ execute: (input: z.infer<T>, context?: ToolExecuteContext) => Promise<string | ToolResult>;
11
+ }): Tool<z.infer<T>> {
12
+ return {
13
+ name: opts.name,
14
+ description: opts.description,
15
+ category: opts.category,
16
+ input: opts.input,
17
+ inputJsonSchema: z.toJSONSchema(opts.input) as Record<string, unknown>,
18
+ execute: opts.execute,
19
+ };
20
+ }
21
+
22
+ export function defineAugment(opts: Augment): Augment {
23
+ return opts;
24
+ }
package/src/http.ts ADDED
@@ -0,0 +1,387 @@
1
+ /**
2
+ * HTTP client primitive.
3
+ *
4
+ * Wraps the native `fetch` with a per-request timeout, a bounded redirect
5
+ * loop, and a consistent User-Agent. Built so any augment that needs to
6
+ * hit the network (web_fetch, web_search, MCP HTTP transport, remote
7
+ * skill loading, etc.) can share one configured client.
8
+ *
9
+ * Ported from the reqwest pattern in soongenwong/claudecode
10
+ * (rust/crates/tools/src/lib.rs, build_http_client). The Rust version
11
+ * returned a generic `reqwest::Client`; this version exposes a minimal
12
+ * verb-agnostic wrapper over `fetch` with the same configuration shape.
13
+ *
14
+ * Intentionally does NOT include: retries, exponential backoff, cookie
15
+ * jars, connection pooling, request signing, SSRF allowlists, response
16
+ * caching. Those are concerns for higher-layer wrappers or hook augments.
17
+ */
18
+
19
+ export interface HttpClientOptions {
20
+ /** Total request timeout in milliseconds, including all redirects. Default 20_000. */
21
+ timeoutMs?: number;
22
+ /** Maximum number of 3xx redirects to follow. Default 10. */
23
+ maxRedirects?: number;
24
+ /** User-Agent header sent on every request. Default "auggy-http/0.1". */
25
+ userAgent?: string;
26
+ /** Headers added to every request. Request-level headers override these. */
27
+ defaultHeaders?: Record<string, string>;
28
+ /** Maximum response body size in bytes. Responses exceeding this are truncated. Default 5MB. */
29
+ maxBodyBytes?: number;
30
+ /**
31
+ * Reject URLs that resolve to loopback, RFC 1918 private ranges, link-local,
32
+ * cloud metadata endpoints, and non-http(s) schemes. Applied to the initial
33
+ * URL and every redirect hop. Default: false.
34
+ *
35
+ * Enable on clients that consume model- or peer-supplied URLs (web_fetch).
36
+ * Leave disabled on clients fetching operator-configured endpoints (e.g. the
37
+ * org-context manifest URL, which is often localhost during dev).
38
+ */
39
+ rejectUnsafeUrls?: boolean;
40
+ }
41
+
42
+ /**
43
+ * Reject URLs pointing to loopback, private networks, link-local, cloud
44
+ * metadata endpoints, or non-http(s) schemes. Returns a reason string if
45
+ * the URL should be blocked, or null if it's safe.
46
+ *
47
+ * This is a structural SSRF defense — the check runs before any network
48
+ * I/O, at each hop. It does NOT perform DNS resolution, so DNS-rebinding
49
+ * attacks that resolve a public-looking name to a private IP at fetch
50
+ * time are out of scope for this layer.
51
+ */
52
+ export function rejectUnsafeUrl(url: string): string | null {
53
+ let parsed: URL;
54
+ try {
55
+ parsed = new URL(url);
56
+ } catch {
57
+ return "unparseable URL";
58
+ }
59
+
60
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
61
+ return `blocked scheme: ${parsed.protocol}`;
62
+ }
63
+
64
+ // Strip IPv6 brackets. URL.hostname retains brackets on IPv6 literals
65
+ // (e.g. "[::1]"), so range checks that match raw IPv6 text must unwrap
66
+ // the brackets first.
67
+ let host = parsed.hostname.toLowerCase();
68
+ if (host.startsWith("[") && host.endsWith("]")) {
69
+ host = host.slice(1, -1);
70
+ }
71
+
72
+ // Hostname-style blocks. Trailing dot makes a name "fully qualified"
73
+ // but resolves to the same destination, so block both.
74
+ if (host === "localhost" || host === "localhost.") {
75
+ return "blocked: loopback";
76
+ }
77
+ if (host === "metadata" || host === "metadata.google.internal") {
78
+ return "blocked: cloud metadata endpoint";
79
+ }
80
+
81
+ // IPv6 canonical forms — loopback, unspecified, link-local, unique-local.
82
+ if (host === "::1" || host === "0:0:0:0:0:0:0:1") {
83
+ return "blocked: IPv6 loopback (::1)";
84
+ }
85
+ if (host === "::" || host === "0:0:0:0:0:0:0:0") {
86
+ return "blocked: IPv6 unspecified (::/128)";
87
+ }
88
+ // fe80::/10 spans the first-group range fe80-febf (binary 1111 1110 10xx xxxx).
89
+ if (/^fe[89ab][0-9a-f]:/i.test(host)) {
90
+ return "blocked: IPv6 link-local (fe80::/10)";
91
+ }
92
+ // fc00::/7 spans the first-group range fc00-fdff (binary 1111 110x xxxx xxxx).
93
+ if (/^f[cd][0-9a-f]{2}:/i.test(host)) {
94
+ return "blocked: IPv6 unique-local (fc00::/7)";
95
+ }
96
+
97
+ // IPv4-mapped IPv6 — re-check the embedded IPv4 against the IPv4 rules
98
+ // so an attacker can't bypass by wrapping an internal IP in IPv6 form.
99
+ // The URL parser normalizes dotted-quad forms to hex (::ffff:a.b.c.d →
100
+ // ::ffff:hhhh:hhhh) so we handle both.
101
+ const mappedV4 = extractIPv4Mapped(host);
102
+ if (mappedV4) {
103
+ const reason = checkIpv4Ranges(mappedV4);
104
+ if (reason) return `${reason} (via IPv4-mapped IPv6)`;
105
+ } else {
106
+ const ipv4Reason = checkIpv4Ranges(host);
107
+ if (ipv4Reason) return ipv4Reason;
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ function extractIPv4Mapped(host: string): string | null {
114
+ // Dotted-quad form: ::ffff:10.0.0.1 (rare — most parsers normalize away).
115
+ const dotted = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
116
+ if (dotted) return dotted[1]!;
117
+
118
+ // Hex form emitted by the URL parser: ::ffff:a00:1 (two 16-bit groups).
119
+ const hex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
120
+ if (hex) {
121
+ const high = parseInt(hex[1]!, 16);
122
+ const low = parseInt(hex[2]!, 16);
123
+ return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ function checkIpv4Ranges(host: string): string | null {
129
+ if (host === "127.0.0.1" || /^127\./.test(host)) {
130
+ return "blocked: loopback (127/8)";
131
+ }
132
+ if (/^10\./.test(host)) return "blocked: RFC 1918 (10/8)";
133
+ if (/^192\.168\./.test(host)) return "blocked: RFC 1918 (192.168/16)";
134
+ if (/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(host)) {
135
+ return "blocked: RFC 1918 (172.16/12)";
136
+ }
137
+ if (/^169\.254\./.test(host)) {
138
+ return "blocked: link-local / cloud metadata (169.254/16)";
139
+ }
140
+ if (/^0\./.test(host)) return "blocked: 0.0.0.0/8";
141
+ return null;
142
+ }
143
+
144
+ export interface HttpRequestInit {
145
+ /** HTTP method. Default "GET". */
146
+ method?: string;
147
+ /** Headers for this request. Merged on top of the client's defaultHeaders. */
148
+ headers?: Record<string, string>;
149
+ /** Request body. Not valid for GET / HEAD. */
150
+ body?: string | Uint8Array;
151
+ }
152
+
153
+ export interface HttpResponse {
154
+ /** URL after all redirects were followed. */
155
+ finalUrl: string;
156
+ /** HTTP status code. */
157
+ status: number;
158
+ /** HTTP reason phrase ("OK", "Not Found", ...), or "Unknown" if absent. */
159
+ statusText: string;
160
+ /** Content-Type header value, or "" if absent. Convenience accessor; also in `headers`. */
161
+ contentType: string;
162
+ /** Full response headers. */
163
+ headers: Headers;
164
+ /** Response body as text (UTF-8 best-effort). */
165
+ body: string;
166
+ }
167
+
168
+ export interface HttpClient {
169
+ request: (url: string, init?: HttpRequestInit) => Promise<HttpResponse>;
170
+ get: (url: string, init?: Omit<HttpRequestInit, "method" | "body">) => Promise<HttpResponse>;
171
+ post: (url: string, init?: Omit<HttpRequestInit, "method">) => Promise<HttpResponse>;
172
+ put: (url: string, init?: Omit<HttpRequestInit, "method">) => Promise<HttpResponse>;
173
+ delete: (url: string, init?: Omit<HttpRequestInit, "method">) => Promise<HttpResponse>;
174
+ head: (url: string, init?: Omit<HttpRequestInit, "method" | "body">) => Promise<HttpResponse>;
175
+ }
176
+
177
+ const DEFAULT_TIMEOUT_MS = 20_000;
178
+ const DEFAULT_MAX_REDIRECTS = 10;
179
+ const DEFAULT_USER_AGENT = "auggy-http/0.1";
180
+ const DEFAULT_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5MB
181
+
182
+ /** Headers stripped when a redirect crosses origin boundaries. */
183
+ const SENSITIVE_HEADERS = ["authorization", "cookie", "proxy-authorization"];
184
+
185
+ /**
186
+ * Create an HTTP client with the given defaults.
187
+ *
188
+ * Redirect handling is done manually (fetch is called with
189
+ * `redirect: "manual"`) so the `maxRedirects` cap is actually enforceable
190
+ * — the platform default of `"follow"` has no observable limit.
191
+ *
192
+ * The timeout is a total budget across the entire redirect chain, not
193
+ * a per-hop budget. Matches reqwest's `Client::builder().timeout(..)`.
194
+ */
195
+ export function createHttpClient(opts: HttpClientOptions = {}): HttpClient {
196
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
197
+ const maxRedirects = opts.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
198
+ const userAgent = opts.userAgent ?? DEFAULT_USER_AGENT;
199
+ const defaultHeaders = opts.defaultHeaders ?? {};
200
+ const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
201
+ const ssrfGuard = opts.rejectUnsafeUrls ?? false;
202
+
203
+ const request = async (url: string, init: HttpRequestInit = {}): Promise<HttpResponse> => {
204
+ const method = (init.method ?? "GET").toUpperCase();
205
+ const currentHeaders: Record<string, string> = {
206
+ "user-agent": userAgent,
207
+ ...defaultHeaders,
208
+ ...(init.headers ?? {}),
209
+ };
210
+
211
+ if (ssrfGuard) {
212
+ const reason = rejectUnsafeUrl(url);
213
+ if (reason) {
214
+ throw new Error(`http client: unsafe URL ${url} (${reason})`);
215
+ }
216
+ }
217
+
218
+ const controller = new AbortController();
219
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
220
+ const originalOrigin = new URL(url).origin;
221
+
222
+ try {
223
+ let currentUrl = url;
224
+ let currentMethod = method;
225
+ let currentBody: string | Uint8Array | undefined = init.body;
226
+ let hops = 0;
227
+
228
+ while (true) {
229
+ const response = await fetch(currentUrl, {
230
+ method: currentMethod,
231
+ redirect: "manual",
232
+ signal: controller.signal,
233
+ headers: currentHeaders,
234
+ body: currentMethod === "GET" || currentMethod === "HEAD" ? undefined : currentBody,
235
+ });
236
+
237
+ if (response.status >= 300 && response.status < 400) {
238
+ const location = response.headers.get("location");
239
+ if (location === null) {
240
+ const body = await readBody(response, maxBodyBytes);
241
+ return buildResponse(currentUrl, response, body);
242
+ }
243
+ if (hops >= maxRedirects) {
244
+ await response.body?.cancel();
245
+ throw new Error(
246
+ `http client: exceeded redirect limit (${maxRedirects}) at ${currentUrl}`,
247
+ );
248
+ }
249
+
250
+ // Consume the redirect response body to prevent connection leaks.
251
+ await response.body?.cancel();
252
+
253
+ currentUrl = new URL(location, currentUrl).toString();
254
+
255
+ if (ssrfGuard) {
256
+ const reason = rejectUnsafeUrl(currentUrl);
257
+ if (reason) {
258
+ throw new Error(`http client: unsafe redirect target ${currentUrl} (${reason})`);
259
+ }
260
+ }
261
+
262
+ // Per RFC 7231: 301/302/303 change the method to GET and drop the body.
263
+ // 307/308 preserve the method and body.
264
+ if (response.status === 301 || response.status === 302 || response.status === 303) {
265
+ currentMethod = "GET";
266
+ currentBody = undefined;
267
+ }
268
+
269
+ // Strip sensitive headers on cross-origin redirects to prevent
270
+ // credentials leaking to a different host. We compare against the
271
+ // original origin (not the previous hop) because currentHeaders is
272
+ // mutated — delete is one-way, headers can never be re-added, so
273
+ // a chain like A→B→A correctly has headers stripped at B and they
274
+ // stay stripped for the return to A.
275
+ const redirectOrigin = new URL(currentUrl).origin;
276
+ if (redirectOrigin !== originalOrigin) {
277
+ for (const header of SENSITIVE_HEADERS) {
278
+ delete currentHeaders[header];
279
+ }
280
+ }
281
+
282
+ hops += 1;
283
+ continue;
284
+ }
285
+
286
+ const body = await readBody(response, maxBodyBytes);
287
+ return buildResponse(currentUrl, response, body);
288
+ }
289
+ } finally {
290
+ clearTimeout(timer);
291
+ }
292
+ };
293
+
294
+ const get = (url: string, init: Omit<HttpRequestInit, "method" | "body"> = {}) =>
295
+ request(url, { ...init, method: "GET" });
296
+
297
+ const post = (url: string, init: Omit<HttpRequestInit, "method"> = {}) =>
298
+ request(url, { ...init, method: "POST" });
299
+
300
+ const put = (url: string, init: Omit<HttpRequestInit, "method"> = {}) =>
301
+ request(url, { ...init, method: "PUT" });
302
+
303
+ const del = (url: string, init: Omit<HttpRequestInit, "method"> = {}) =>
304
+ request(url, { ...init, method: "DELETE" });
305
+
306
+ const head = (url: string, init: Omit<HttpRequestInit, "method" | "body"> = {}) =>
307
+ request(url, { ...init, method: "HEAD" });
308
+
309
+ return { request, get, post, put, delete: del, head };
310
+ }
311
+
312
+ /**
313
+ * Read a byte stream with a byte-size cap. Backs off to a UTF-8 character
314
+ * boundary before truncating, preventing U+FFFD replacement characters.
315
+ *
316
+ * Shared by the HTTP client (response bodies) and the bash augment
317
+ * (stdout/stderr capture). Exported for reuse by any stream consumer.
318
+ */
319
+ export async function readStreamWithCap(
320
+ stream: ReadableStream<Uint8Array>,
321
+ maxBytes: number,
322
+ ): Promise<{ text: string; truncated: boolean }> {
323
+ const reader = stream.getReader();
324
+ const chunks: Uint8Array[] = [];
325
+ let totalBytes = 0;
326
+ let truncated = false;
327
+
328
+ while (true) {
329
+ const { done, value } = await reader.read();
330
+ if (done) break;
331
+
332
+ if (totalBytes + value.byteLength > maxBytes) {
333
+ let end = maxBytes - totalBytes;
334
+ while (end > 0 && (value[end]! & 0xc0) === 0x80) {
335
+ end--;
336
+ }
337
+ if (end > 0) {
338
+ chunks.push(value.slice(0, end));
339
+ }
340
+ totalBytes = maxBytes;
341
+ truncated = true;
342
+ await reader.cancel();
343
+ break;
344
+ }
345
+
346
+ chunks.push(value);
347
+ totalBytes += value.byteLength;
348
+ }
349
+
350
+ const decoder = new TextDecoder("utf-8", { fatal: false });
351
+ let text = "";
352
+ for (let i = 0; i < chunks.length; i++) {
353
+ text += decoder.decode(chunks[i], { stream: i < chunks.length - 1 });
354
+ }
355
+
356
+ if (truncated) {
357
+ text += `\n[truncated at ${maxBytes} bytes]`;
358
+ }
359
+
360
+ return { text, truncated };
361
+ }
362
+
363
+ /** Read an HTTP response body with a byte-size cap. */
364
+ async function readBody(response: Response, maxBytes: number): Promise<string> {
365
+ if (!response.body) return "";
366
+ const { text, truncated } = await readStreamWithCap(response.body, maxBytes);
367
+ if (truncated) {
368
+ const contentLength = response.headers.get("content-length");
369
+ const totalSize = contentLength ? ` total size: ${contentLength} bytes` : "";
370
+ return text.replace(
371
+ /\[truncated at \d+ bytes\]$/,
372
+ `[truncated at ${maxBytes} bytes${totalSize}]`,
373
+ );
374
+ }
375
+ return text;
376
+ }
377
+
378
+ function buildResponse(finalUrl: string, response: Response, body: string): HttpResponse {
379
+ return {
380
+ finalUrl,
381
+ status: response.status,
382
+ statusText: response.statusText || "Unknown",
383
+ contentType: response.headers.get("content-type") ?? "",
384
+ headers: response.headers,
385
+ body,
386
+ };
387
+ }