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,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
|
+
}
|