bazilion 0.0.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/.understand-anything/.understandignore +25 -0
- package/.understand-anything/fingerprints.json +14267 -0
- package/.understand-anything/knowledge-graph.json +18128 -0
- package/.understand-anything/meta.json +6 -0
- package/CLAUDE.md +164 -0
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/apps/cli/package.json +21 -0
- package/apps/cli/src/auth-file.ts +18 -0
- package/apps/cli/src/client.ts +37 -0
- package/apps/cli/src/columnize.ts +32 -0
- package/apps/cli/src/commands/agent.ts +574 -0
- package/apps/cli/src/commands/auth.ts +110 -0
- package/apps/cli/src/commands/backup.ts +135 -0
- package/apps/cli/src/commands/completion.ts +155 -0
- package/apps/cli/src/commands/config.ts +95 -0
- package/apps/cli/src/commands/doctor.ts +131 -0
- package/apps/cli/src/commands/group.ts +132 -0
- package/apps/cli/src/commands/inbox.ts +82 -0
- package/apps/cli/src/commands/login.ts +73 -0
- package/apps/cli/src/commands/memory.ts +106 -0
- package/apps/cli/src/commands/profile.ts +259 -0
- package/apps/cli/src/commands/provider.ts +170 -0
- package/apps/cli/src/commands/send.ts +24 -0
- package/apps/cli/src/commands/serve.ts +89 -0
- package/apps/cli/src/commands/skill.ts +120 -0
- package/apps/cli/src/commands/token.ts +148 -0
- package/apps/cli/src/commands/trigger.ts +129 -0
- package/apps/cli/src/commands/uninstall.ts +156 -0
- package/apps/cli/src/index.ts +196 -0
- package/apps/cli/src/paths.ts +12 -0
- package/apps/cli/test/agent.test.ts +213 -0
- package/apps/cli/test/backup.test.ts +95 -0
- package/apps/cli/test/chat.test.ts +539 -0
- package/apps/cli/test/columnize.test.ts +29 -0
- package/apps/cli/test/completion.test.ts +45 -0
- package/apps/cli/test/config-page.test.ts +151 -0
- package/apps/cli/test/group.test.ts +74 -0
- package/apps/cli/test/helpers.ts +70 -0
- package/apps/cli/test/inbox-autodeliver.test.ts +143 -0
- package/apps/cli/test/inbox.test.ts +147 -0
- package/apps/cli/test/memory.test.ts +69 -0
- package/apps/cli/test/profile.test.ts +110 -0
- package/apps/cli/test/send.test.ts +30 -0
- package/apps/cli/test/server-fixture.ts +212 -0
- package/apps/cli/test/session-head.test.ts +45 -0
- package/apps/cli/test/skill.test.ts +128 -0
- package/apps/cli/test/token.test.ts +109 -0
- package/apps/cli/test/trigger.test.ts +245 -0
- package/apps/cli/tsconfig.json +4 -0
- package/apps/daemon/package.json +31 -0
- package/apps/daemon/src/app.ts +41 -0
- package/apps/daemon/src/core/agent/archive.ts +8 -0
- package/apps/daemon/src/core/agent/delete.ts +30 -0
- package/apps/daemon/src/core/agent/resolve.ts +31 -0
- package/apps/daemon/src/core/agent/spawn.ts +95 -0
- package/apps/daemon/src/core/agent/unarchive.ts +11 -0
- package/apps/daemon/src/core/availableModels.ts +58 -0
- package/apps/daemon/src/core/db/client.ts +113 -0
- package/apps/daemon/src/core/db/migrate.ts +41 -0
- package/apps/daemon/src/core/db/migrations/0001_init.sql +179 -0
- package/apps/daemon/src/core/group/delete.ts +21 -0
- package/apps/daemon/src/core/group/register.ts +68 -0
- package/apps/daemon/src/core/index.ts +71 -0
- package/apps/daemon/src/core/paths.ts +56 -0
- package/apps/daemon/src/core/profile/create.ts +78 -0
- package/apps/daemon/src/core/profile/delete.ts +26 -0
- package/apps/daemon/src/core/profile/identity.ts +70 -0
- package/apps/daemon/src/core/profile/load.ts +45 -0
- package/apps/daemon/src/core/profile/seed.ts +74 -0
- package/apps/daemon/src/core/profile/templates.ts +60 -0
- package/apps/daemon/src/core/profile/update.ts +57 -0
- package/apps/daemon/src/core/profile/validate.ts +9 -0
- package/apps/daemon/src/core/repos/agents.ts +202 -0
- package/apps/daemon/src/core/repos/config.ts +78 -0
- package/apps/daemon/src/core/repos/groups.ts +55 -0
- package/apps/daemon/src/core/repos/messages.ts +127 -0
- package/apps/daemon/src/core/repos/profiles.ts +83 -0
- package/apps/daemon/src/core/repos/providerModels.ts +58 -0
- package/apps/daemon/src/core/repos/providerState.ts +37 -0
- package/apps/daemon/src/core/repos/secrets.ts +145 -0
- package/apps/daemon/src/core/repos/skillMeta.ts +49 -0
- package/apps/daemon/src/core/repos/triggers.ts +101 -0
- package/apps/daemon/src/core/repos/webTokens.ts +87 -0
- package/apps/daemon/src/core/secrets.ts +65 -0
- package/apps/daemon/src/core/services.ts +264 -0
- package/apps/daemon/src/core/skills/discover.ts +28 -0
- package/apps/daemon/src/core/skills/import.ts +136 -0
- package/apps/daemon/src/core/skills/parse.ts +52 -0
- package/apps/daemon/src/core/skills/resolve.ts +50 -0
- package/apps/daemon/src/index.ts +45 -0
- package/apps/daemon/src/lib/agent-cancel.ts +48 -0
- package/apps/daemon/src/lib/agent-id.ts +13 -0
- package/apps/daemon/src/lib/agent-turn.ts +56 -0
- package/apps/daemon/src/lib/api-key.ts +56 -0
- package/apps/daemon/src/lib/auth.ts +41 -0
- package/apps/daemon/src/lib/cron.ts +93 -0
- package/apps/daemon/src/lib/ctx.ts +80 -0
- package/apps/daemon/src/lib/messaging-host.ts +34 -0
- package/apps/daemon/src/lib/middleware-auth.ts +52 -0
- package/apps/daemon/src/lib/scheduler.ts +294 -0
- package/apps/daemon/src/routes/agents.ts +772 -0
- package/apps/daemon/src/routes/auth-login.ts +193 -0
- package/apps/daemon/src/routes/config.ts +267 -0
- package/apps/daemon/src/routes/groups.ts +133 -0
- package/apps/daemon/src/routes/messages.ts +29 -0
- package/apps/daemon/src/routes/misc.ts +239 -0
- package/apps/daemon/src/routes/profiles.ts +197 -0
- package/apps/daemon/src/routes/skills.ts +123 -0
- package/apps/daemon/src/routes/triggers.ts +29 -0
- package/apps/daemon/src/runtime/auth/openai-codex.ts +121 -0
- package/apps/daemon/src/runtime/auto-reply/heartbeat.ts +31 -0
- package/apps/daemon/src/runtime/index.ts +77 -0
- package/apps/daemon/src/runtime/memory/files.ts +103 -0
- package/apps/daemon/src/runtime/memory/qmd.ts +152 -0
- package/apps/daemon/src/runtime/memory/types.ts +16 -0
- package/apps/daemon/src/runtime/pi/events.ts +173 -0
- package/apps/daemon/src/runtime/pi/session.ts +536 -0
- package/apps/daemon/src/runtime/pi/tools.ts +85 -0
- package/apps/daemon/src/runtime/providers/catalog.ts +145 -0
- package/apps/daemon/src/runtime/providers/pi-adapter.ts +272 -0
- package/apps/daemon/src/runtime/providers/registry.ts +374 -0
- package/apps/daemon/src/runtime/providers/retry.ts +176 -0
- package/apps/daemon/src/runtime/providers/types.ts +33 -0
- package/apps/daemon/src/runtime/session/prompt.ts +83 -0
- package/apps/daemon/src/runtime/tools/bootstrap.ts +22 -0
- package/apps/daemon/src/runtime/tools/home.ts +114 -0
- package/apps/daemon/src/runtime/tools/memory.ts +81 -0
- package/apps/daemon/src/runtime/tools/messaging.ts +127 -0
- package/apps/daemon/src/runtime/tools/registry.ts +29 -0
- package/apps/daemon/src/runtime/tools/types.ts +13 -0
- package/apps/daemon/src/runtime/tools/web-extract.ts +110 -0
- package/apps/daemon/src/runtime/tools/web-ssrf.ts +245 -0
- package/apps/daemon/src/runtime/tools/web.ts +273 -0
- package/apps/daemon/src/runtime/worker/entry.ts +221 -0
- package/apps/daemon/src/runtime/worker/ipc-protocol.ts +75 -0
- package/apps/daemon/src/runtime/worker/spawn.ts +249 -0
- package/apps/daemon/test/core/agents.test.ts +319 -0
- package/apps/daemon/test/core/available-models.test.ts +63 -0
- package/apps/daemon/test/core/config.test.ts +99 -0
- package/apps/daemon/test/core/groups.test.ts +63 -0
- package/apps/daemon/test/core/helpers.ts +50 -0
- package/apps/daemon/test/core/identity.test.ts +85 -0
- package/apps/daemon/test/core/migrations.test.ts +83 -0
- package/apps/daemon/test/core/profiles.test.ts +182 -0
- package/apps/daemon/test/core/provider-models.test.ts +57 -0
- package/apps/daemon/test/core/provider-state.test.ts +36 -0
- package/apps/daemon/test/core/skill-meta.test.ts +45 -0
- package/apps/daemon/test/core/skills.test.ts +271 -0
- package/apps/daemon/test/core/triggers.test.ts +182 -0
- package/apps/daemon/test/core/web-tokens.test.ts +79 -0
- package/apps/daemon/test/cron.test.ts +90 -0
- package/apps/daemon/test/runtime/heartbeat.test.ts +34 -0
- package/apps/daemon/test/runtime/memory-qmd.test.ts +97 -0
- package/apps/daemon/test/runtime/memory.test.ts +78 -0
- package/apps/daemon/test/runtime/messaging.test.ts +213 -0
- package/apps/daemon/test/runtime/mock-server.ts +65 -0
- package/apps/daemon/test/runtime/openai-codex-auth.test.ts +116 -0
- package/apps/daemon/test/runtime/providers.test.ts +306 -0
- package/apps/daemon/test/runtime/retry.test.ts +191 -0
- package/apps/daemon/test/runtime/session-head.test.ts +90 -0
- package/apps/daemon/test/runtime/tools-home.test.ts +102 -0
- package/apps/daemon/test/runtime/tools-web.test.ts +206 -0
- package/apps/daemon/tsconfig.json +4 -0
- package/apps/mobile/README.md +60 -0
- package/apps/mobile/app/_layout.tsx +58 -0
- package/apps/mobile/app/agents/[id]/chat.tsx +486 -0
- package/apps/mobile/app/agents/[id]/index.tsx +166 -0
- package/apps/mobile/app/agents/index.tsx +212 -0
- package/apps/mobile/app/index.tsx +21 -0
- package/apps/mobile/app/pair.tsx +226 -0
- package/apps/mobile/app/settings.tsx +419 -0
- package/apps/mobile/app.json +49 -0
- package/apps/mobile/assets/adaptive-icon.png +0 -0
- package/apps/mobile/assets/favicon.png +0 -0
- package/apps/mobile/assets/icon.png +0 -0
- package/apps/mobile/assets/splash-icon.png +0 -0
- package/apps/mobile/babel.config.js +6 -0
- package/apps/mobile/metro.config.js +28 -0
- package/apps/mobile/package.json +44 -0
- package/apps/mobile/src/auth.ts +66 -0
- package/apps/mobile/src/pair-url.ts +48 -0
- package/apps/mobile/src/theme-context.tsx +88 -0
- package/apps/mobile/src/theme.ts +135 -0
- package/apps/mobile/test/pair-url.test.ts +46 -0
- package/apps/mobile/tsconfig.json +23 -0
- package/apps/web/components.json +25 -0
- package/apps/web/package.json +39 -0
- package/apps/web/public/baziu.svg +8 -0
- package/apps/web/src/components/AgentTabs.tsx +45 -0
- package/apps/web/src/components/BaziuLogo.tsx +21 -0
- package/apps/web/src/components/ChatPane.tsx +1033 -0
- package/apps/web/src/components/ConfigTabs.tsx +29 -0
- package/apps/web/src/components/CopyButton.tsx +68 -0
- package/apps/web/src/components/CreateGroupDialog.tsx +127 -0
- package/apps/web/src/components/FieldRow.tsx +94 -0
- package/apps/web/src/components/Footer.tsx +10 -0
- package/apps/web/src/components/PawIcon.tsx +15 -0
- package/apps/web/src/components/Sidebar.tsx +287 -0
- package/apps/web/src/components/SpawnDialog.tsx +129 -0
- package/apps/web/src/components/ThemeToggle.tsx +75 -0
- package/apps/web/src/components/TopNav.tsx +34 -0
- package/apps/web/src/components/ui/button.tsx +67 -0
- package/apps/web/src/components/ui/card.tsx +103 -0
- package/apps/web/src/components/ui/checkbox.tsx +31 -0
- package/apps/web/src/components/ui/dialog.tsx +168 -0
- package/apps/web/src/components/ui/input.tsx +19 -0
- package/apps/web/src/components/ui/label.tsx +22 -0
- package/apps/web/src/components/ui/radio-group.tsx +44 -0
- package/apps/web/src/components/ui/select.tsx +192 -0
- package/apps/web/src/components/ui/separator.tsx +26 -0
- package/apps/web/src/components/ui/table.tsx +116 -0
- package/apps/web/src/components/ui/tabs.tsx +88 -0
- package/apps/web/src/components/ui/textarea.tsx +18 -0
- package/apps/web/src/lib/auth.ts +50 -0
- package/apps/web/src/lib/daemon-client.ts +34 -0
- package/apps/web/src/lib/md.ts +45 -0
- package/apps/web/src/lib/utils.ts +6 -0
- package/apps/web/src/lib/wire-constants.ts +27 -0
- package/apps/web/src/routeTree.gen.ts +408 -0
- package/apps/web/src/router.tsx +20 -0
- package/apps/web/src/routes/__root.tsx +123 -0
- package/apps/web/src/routes/agents/$id/inbox.tsx +207 -0
- package/apps/web/src/routes/agents/$id/index.tsx +527 -0
- package/apps/web/src/routes/agents/$id/triggers.tsx +239 -0
- package/apps/web/src/routes/agents/index.tsx +265 -0
- package/apps/web/src/routes/api/$.ts +88 -0
- package/apps/web/src/routes/config/index.tsx +315 -0
- package/apps/web/src/routes/config/services.tsx +49 -0
- package/apps/web/src/routes/config/tokens.tsx +192 -0
- package/apps/web/src/routes/groups/$id/index.tsx +153 -0
- package/apps/web/src/routes/groups/$id/memory.tsx +321 -0
- package/apps/web/src/routes/groups/index.tsx +191 -0
- package/apps/web/src/routes/index.tsx +133 -0
- package/apps/web/src/routes/login.tsx +63 -0
- package/apps/web/src/routes/profiles/$id.tsx +549 -0
- package/apps/web/src/routes/profiles/index.tsx +458 -0
- package/apps/web/src/routes/skills/index.tsx +297 -0
- package/apps/web/src/routes/welcome.tsx +61 -0
- package/apps/web/src/styles.css +449 -0
- package/apps/web/tsconfig.json +25 -0
- package/apps/web/vite.config.ts +25 -0
- package/biome.json +23 -0
- package/docs/agent-engine.md +219 -0
- package/docs/architecture.md +627 -0
- package/docs/backlog/README.md +42 -0
- package/docs/backlog/draft/BAZ-001-a2a-federation-spike.md +125 -0
- package/docs/openclaw-reference.md +210 -0
- package/package.json +38 -0
- package/packages/api-types/package.json +11 -0
- package/packages/api-types/src/entities.ts +146 -0
- package/packages/api-types/src/events.ts +55 -0
- package/packages/api-types/src/index.ts +488 -0
- package/packages/api-types/src/memory.ts +15 -0
- package/packages/client/package.json +13 -0
- package/packages/client/src/index.ts +117 -0
- package/pnpm-workspace.yaml +3 -0
- package/tsconfig.base.json +23 -0
- package/tsconfig.json +11 -0
- package/vitest.config.ts +22 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { lookup as dnsLookupCb, type LookupAddress } from 'node:dns'
|
|
2
|
+
import { lookup as dnsLookup } from 'node:dns/promises'
|
|
3
|
+
import { Agent, type Dispatcher } from 'undici'
|
|
4
|
+
|
|
5
|
+
export class SsrFBlockedError extends Error {
|
|
6
|
+
constructor(message: string) {
|
|
7
|
+
super(message)
|
|
8
|
+
this.name = 'SsrFBlockedError'
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const BLOCKED_HOSTNAMES = new Set(['localhost', 'metadata.google.internal'])
|
|
13
|
+
const PRIVATE_IPV6_PREFIXES = ['fe80:', 'fec0:', 'fc', 'fd']
|
|
14
|
+
|
|
15
|
+
function normalizeHostname(hostname: string): string {
|
|
16
|
+
let h = hostname.trim().toLowerCase().replace(/\.$/, '')
|
|
17
|
+
if (h.startsWith('[') && h.endsWith(']')) h = h.slice(1, -1)
|
|
18
|
+
return h
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseIpv4(address: string): number[] | null {
|
|
22
|
+
const parts = address.split('.')
|
|
23
|
+
if (parts.length !== 4) return null
|
|
24
|
+
const nums = parts.map((p) => Number.parseInt(p, 10))
|
|
25
|
+
if (nums.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null
|
|
26
|
+
return nums
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isPrivateIpv4(parts: number[]): boolean {
|
|
30
|
+
const [a, b] = parts as [number, number, number, number]
|
|
31
|
+
if (a === 0 || a === 10 || a === 127) return true
|
|
32
|
+
if (a === 169 && b === 254) return true
|
|
33
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
34
|
+
if (a === 192 && b === 168) return true
|
|
35
|
+
if (a === 100 && b >= 64 && b <= 127) return true
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isPrivateIpAddress(address: string): boolean {
|
|
40
|
+
let norm = address.trim().toLowerCase()
|
|
41
|
+
if (norm.startsWith('[') && norm.endsWith(']')) norm = norm.slice(1, -1)
|
|
42
|
+
if (!norm) return false
|
|
43
|
+
if (norm.startsWith('::ffff:')) {
|
|
44
|
+
const mapped = norm.slice('::ffff:'.length)
|
|
45
|
+
const ipv4 = parseIpv4(mapped)
|
|
46
|
+
if (ipv4) return isPrivateIpv4(ipv4)
|
|
47
|
+
}
|
|
48
|
+
if (norm.includes(':')) {
|
|
49
|
+
if (norm === '::' || norm === '::1') return true
|
|
50
|
+
return PRIVATE_IPV6_PREFIXES.some((p) => norm.startsWith(p))
|
|
51
|
+
}
|
|
52
|
+
const ipv4 = parseIpv4(norm)
|
|
53
|
+
return ipv4 ? isPrivateIpv4(ipv4) : false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isBlockedHostname(hostname: string): boolean {
|
|
57
|
+
const h = normalizeHostname(hostname)
|
|
58
|
+
if (!h) return false
|
|
59
|
+
if (BLOCKED_HOSTNAMES.has(h)) return true
|
|
60
|
+
return h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type LookupCallback = (
|
|
64
|
+
err: NodeJS.ErrnoException | null,
|
|
65
|
+
address: string | LookupAddress[],
|
|
66
|
+
family?: number,
|
|
67
|
+
) => void
|
|
68
|
+
|
|
69
|
+
function createPinnedLookup(hostname: string, addresses: string[]): typeof dnsLookupCb {
|
|
70
|
+
const normalized = normalizeHostname(hostname)
|
|
71
|
+
const records = addresses.map((address) => ({
|
|
72
|
+
address,
|
|
73
|
+
family: (address.includes(':') ? 6 : 4) as 4 | 6,
|
|
74
|
+
}))
|
|
75
|
+
let index = 0
|
|
76
|
+
return ((host: string, options?: unknown, callback?: unknown) => {
|
|
77
|
+
const cb: LookupCallback =
|
|
78
|
+
typeof options === 'function' ? (options as LookupCallback) : (callback as LookupCallback)
|
|
79
|
+
if (!cb) return
|
|
80
|
+
if (normalizeHostname(host) !== normalized) {
|
|
81
|
+
if (typeof options === 'function' || options === undefined) {
|
|
82
|
+
return (dnsLookupCb as unknown as (h: string, cb: LookupCallback) => void)(host, cb)
|
|
83
|
+
}
|
|
84
|
+
return (dnsLookupCb as unknown as (h: string, o: unknown, cb: LookupCallback) => void)(
|
|
85
|
+
host,
|
|
86
|
+
options,
|
|
87
|
+
cb,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
const opts =
|
|
91
|
+
typeof options === 'object' && options !== null
|
|
92
|
+
? (options as { all?: boolean; family?: number })
|
|
93
|
+
: {}
|
|
94
|
+
const family = typeof options === 'number' ? options : (opts.family ?? 0)
|
|
95
|
+
const candidates =
|
|
96
|
+
family === 4 || family === 6 ? records.filter((r) => r.family === family) : records
|
|
97
|
+
const usable = candidates.length > 0 ? candidates : records
|
|
98
|
+
if (opts.all) {
|
|
99
|
+
cb(null, usable as LookupAddress[])
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
const chosen = usable[index % usable.length]
|
|
103
|
+
if (!chosen) return
|
|
104
|
+
index += 1
|
|
105
|
+
cb(null, chosen.address, chosen.family)
|
|
106
|
+
}) as typeof dnsLookupCb
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function resolveAndCheck(hostname: string): Promise<string[]> {
|
|
110
|
+
const norm = normalizeHostname(hostname)
|
|
111
|
+
if (!norm) throw new SsrFBlockedError('Invalid hostname')
|
|
112
|
+
if (isBlockedHostname(norm)) throw new SsrFBlockedError(`Blocked hostname: ${hostname}`)
|
|
113
|
+
if (isPrivateIpAddress(norm)) throw new SsrFBlockedError('Blocked: private IP literal')
|
|
114
|
+
const results = await dnsLookup(norm, { all: true })
|
|
115
|
+
if (results.length === 0) throw new SsrFBlockedError(`Cannot resolve: ${hostname}`)
|
|
116
|
+
for (const r of results) {
|
|
117
|
+
if (isPrivateIpAddress(r.address)) throw new SsrFBlockedError('Blocked: resolves to private IP')
|
|
118
|
+
}
|
|
119
|
+
return Array.from(new Set(results.map((r) => r.address)))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>
|
|
123
|
+
|
|
124
|
+
export interface GuardedFetchOptions {
|
|
125
|
+
url: string
|
|
126
|
+
fetchImpl?: FetchLike
|
|
127
|
+
init?: RequestInit
|
|
128
|
+
maxRedirects?: number
|
|
129
|
+
timeoutMs?: number
|
|
130
|
+
signal?: AbortSignal
|
|
131
|
+
/** If true, skip all SSRF checks (for tests hitting 127.0.0.1 mocks). */
|
|
132
|
+
allowPrivate?: boolean
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface GuardedFetchResult {
|
|
136
|
+
response: Response
|
|
137
|
+
finalUrl: string
|
|
138
|
+
release: () => Promise<void>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isRedirectStatus(s: number): boolean {
|
|
142
|
+
return s === 301 || s === 302 || s === 303 || s === 307 || s === 308
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function closeDispatcher(d: Dispatcher | null): Promise<void> {
|
|
146
|
+
if (!d) return
|
|
147
|
+
try {
|
|
148
|
+
await d.close()
|
|
149
|
+
} catch {
|
|
150
|
+
// ignore
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function guardedFetch(opts: GuardedFetchOptions): Promise<GuardedFetchResult> {
|
|
155
|
+
const fetcher: FetchLike = opts.fetchImpl ?? (globalThis.fetch as FetchLike)
|
|
156
|
+
const maxRedirects = opts.maxRedirects ?? 3
|
|
157
|
+
const abortController = new AbortController()
|
|
158
|
+
const timeoutId = opts.timeoutMs
|
|
159
|
+
? setTimeout(() => abortController.abort(new Error('timeout')), opts.timeoutMs)
|
|
160
|
+
: null
|
|
161
|
+
if (opts.signal) {
|
|
162
|
+
if (opts.signal.aborted) abortController.abort(opts.signal.reason)
|
|
163
|
+
else
|
|
164
|
+
opts.signal.addEventListener('abort', () => abortController.abort(opts.signal?.reason), {
|
|
165
|
+
once: true,
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let current = opts.url
|
|
170
|
+
const visited = new Set<string>()
|
|
171
|
+
let redirects = 0
|
|
172
|
+
let dispatcher: Dispatcher | null = null
|
|
173
|
+
|
|
174
|
+
const release = async (): Promise<void> => {
|
|
175
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
176
|
+
await closeDispatcher(dispatcher)
|
|
177
|
+
dispatcher = null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
while (true) {
|
|
181
|
+
let parsed: URL
|
|
182
|
+
try {
|
|
183
|
+
parsed = new URL(current)
|
|
184
|
+
} catch {
|
|
185
|
+
await release()
|
|
186
|
+
throw new Error('Invalid URL')
|
|
187
|
+
}
|
|
188
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
189
|
+
await release()
|
|
190
|
+
throw new Error('Invalid URL: must be http or https')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Pin DNS only when we control the dispatcher (native fetch path).
|
|
194
|
+
const canPin = !opts.fetchImpl
|
|
195
|
+
if (!opts.allowPrivate) {
|
|
196
|
+
const addrs = await resolveAndCheck(parsed.hostname)
|
|
197
|
+
await closeDispatcher(dispatcher)
|
|
198
|
+
dispatcher = canPin
|
|
199
|
+
? new Agent({ connect: { lookup: createPinnedLookup(parsed.hostname, addrs) } })
|
|
200
|
+
: null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const init: RequestInit = {
|
|
204
|
+
...(opts.init ?? {}),
|
|
205
|
+
redirect: 'manual',
|
|
206
|
+
signal: abortController.signal,
|
|
207
|
+
}
|
|
208
|
+
// `dispatcher` is a Node fetch extension not reflected in the DOM
|
|
209
|
+
// RequestInit type — and the undici Dispatcher type brand can diverge
|
|
210
|
+
// from the one @types/node bundles. Stamp it on via a cast.
|
|
211
|
+
if (dispatcher) (init as unknown as { dispatcher: Dispatcher }).dispatcher = dispatcher
|
|
212
|
+
|
|
213
|
+
let res: Response
|
|
214
|
+
try {
|
|
215
|
+
res = await fetcher(parsed.toString(), init)
|
|
216
|
+
} catch (err) {
|
|
217
|
+
await release()
|
|
218
|
+
throw err
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (isRedirectStatus(res.status)) {
|
|
222
|
+
const loc = res.headers.get('location')
|
|
223
|
+
if (!loc) {
|
|
224
|
+
await release()
|
|
225
|
+
throw new Error(`Redirect ${res.status} missing Location header`)
|
|
226
|
+
}
|
|
227
|
+
redirects += 1
|
|
228
|
+
if (redirects > maxRedirects) {
|
|
229
|
+
await release()
|
|
230
|
+
throw new Error(`Too many redirects (> ${maxRedirects})`)
|
|
231
|
+
}
|
|
232
|
+
const next = new URL(loc, parsed).toString()
|
|
233
|
+
if (visited.has(next)) {
|
|
234
|
+
await release()
|
|
235
|
+
throw new Error('Redirect loop')
|
|
236
|
+
}
|
|
237
|
+
visited.add(next)
|
|
238
|
+
void res.body?.cancel()
|
|
239
|
+
current = next
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { response: res, finalUrl: parsed.toString(), release }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type { ToolHandler } from './types.ts'
|
|
2
|
+
import { type ExtractMode, type ExtractResult, extractReadable } from './web-extract.ts'
|
|
3
|
+
import { guardedFetch, SsrFBlockedError } from './web-ssrf.ts'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_USER_AGENT =
|
|
6
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
|
|
7
|
+
const DEFAULT_CACHE_TTL_MS = 15 * 60_000
|
|
8
|
+
const DEFAULT_CACHE_MAX = 100
|
|
9
|
+
const DEFAULT_MAX_LENGTH = 20_000
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 20_000
|
|
11
|
+
|
|
12
|
+
interface SearchResult {
|
|
13
|
+
title: string
|
|
14
|
+
url: string
|
|
15
|
+
snippet: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stripHtml(s: string): string {
|
|
19
|
+
return s
|
|
20
|
+
.replace(/<[^>]*>/g, '')
|
|
21
|
+
.replace(/&/g, '&')
|
|
22
|
+
.replace(/</g, '<')
|
|
23
|
+
.replace(/>/g, '>')
|
|
24
|
+
.replace(/"/g, '"')
|
|
25
|
+
.replace(/'/g, "'")
|
|
26
|
+
.replace(/ /g, ' ')
|
|
27
|
+
.trim()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// --- search backends ---
|
|
31
|
+
|
|
32
|
+
async function braveSearch(
|
|
33
|
+
query: string,
|
|
34
|
+
limit: number,
|
|
35
|
+
apiKey: string,
|
|
36
|
+
fetchFn: typeof fetch,
|
|
37
|
+
): Promise<SearchResult[]> {
|
|
38
|
+
const params = new URLSearchParams({ q: query, count: String(limit) })
|
|
39
|
+
const res = await fetchFn(`https://api.search.brave.com/res/v1/web/search?${params}`, {
|
|
40
|
+
headers: {
|
|
41
|
+
accept: 'application/json',
|
|
42
|
+
'accept-encoding': 'gzip',
|
|
43
|
+
'x-subscription-token': apiKey,
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
if (!res.ok) throw new Error(`Brave Search: ${res.status} ${await res.text()}`)
|
|
47
|
+
const data = (await res.json()) as {
|
|
48
|
+
web?: { results?: { title?: string; url?: string; description?: string }[] }
|
|
49
|
+
}
|
|
50
|
+
return (data.web?.results ?? []).slice(0, limit).map((r) => ({
|
|
51
|
+
title: r.title ?? '',
|
|
52
|
+
url: r.url ?? '',
|
|
53
|
+
snippet: r.description ? stripHtml(r.description) : '',
|
|
54
|
+
}))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function searxngSearch(
|
|
58
|
+
query: string,
|
|
59
|
+
limit: number,
|
|
60
|
+
baseURL: string,
|
|
61
|
+
fetchFn: typeof fetch,
|
|
62
|
+
): Promise<SearchResult[]> {
|
|
63
|
+
const params = new URLSearchParams({ q: query, format: 'json' })
|
|
64
|
+
const res = await fetchFn(`${baseURL}/search?${params}`, {
|
|
65
|
+
headers: { accept: 'application/json' },
|
|
66
|
+
})
|
|
67
|
+
if (!res.ok) throw new Error(`SearXNG: ${res.status} ${await res.text()}`)
|
|
68
|
+
const data = (await res.json()) as {
|
|
69
|
+
results?: { title?: string; url?: string; content?: string }[]
|
|
70
|
+
}
|
|
71
|
+
return (data.results ?? []).slice(0, limit).map((r) => ({
|
|
72
|
+
title: r.title ?? '',
|
|
73
|
+
url: r.url ?? '',
|
|
74
|
+
snippet: r.content ?? '',
|
|
75
|
+
}))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- per-URL cache ---
|
|
79
|
+
|
|
80
|
+
interface CacheEntry {
|
|
81
|
+
value: ExtractResult
|
|
82
|
+
expiresAt: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cacheGet(cache: Map<string, CacheEntry>, key: string): ExtractResult | null {
|
|
86
|
+
const entry = cache.get(key)
|
|
87
|
+
if (!entry) return null
|
|
88
|
+
if (entry.expiresAt < Date.now()) {
|
|
89
|
+
cache.delete(key)
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
// Refresh LRU position
|
|
93
|
+
cache.delete(key)
|
|
94
|
+
cache.set(key, entry)
|
|
95
|
+
return entry.value
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function cacheSet(
|
|
99
|
+
cache: Map<string, CacheEntry>,
|
|
100
|
+
key: string,
|
|
101
|
+
value: ExtractResult,
|
|
102
|
+
ttlMs: number,
|
|
103
|
+
maxEntries: number,
|
|
104
|
+
): void {
|
|
105
|
+
cache.set(key, { value, expiresAt: Date.now() + ttlMs })
|
|
106
|
+
while (cache.size > maxEntries) {
|
|
107
|
+
const oldest = cache.keys().next().value
|
|
108
|
+
if (!oldest) break
|
|
109
|
+
cache.delete(oldest)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- tool factory ---
|
|
114
|
+
|
|
115
|
+
export interface WebToolsOpts {
|
|
116
|
+
fetchImpl?: typeof fetch
|
|
117
|
+
env?: NodeJS.ProcessEnv
|
|
118
|
+
/** Disable SSRF checks (tests against 127.0.0.1 mock servers). Default false. */
|
|
119
|
+
allowPrivate?: boolean
|
|
120
|
+
/** Cache TTL in ms. Default 15 min. */
|
|
121
|
+
cacheTtlMs?: number
|
|
122
|
+
/** Max cache entries. Default 100. */
|
|
123
|
+
cacheMax?: number
|
|
124
|
+
/** Fetch timeout in ms. Default 20s. */
|
|
125
|
+
timeoutMs?: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* web_search backends (tried in order):
|
|
130
|
+
* 1. Brave Search (env: BRAVE_API_KEY) — free tier, 2000 req/month
|
|
131
|
+
* 2. SearXNG (env: SEARXNG_URL) — self-hosted, unlimited
|
|
132
|
+
* 3. Error with setup instructions if neither is configured
|
|
133
|
+
*
|
|
134
|
+
* web_fetch: guarded fetch (SSRF-blocked private IPs) + Readability + markdown,
|
|
135
|
+
* with per-URL in-memory cache (15 min TTL).
|
|
136
|
+
*/
|
|
137
|
+
export function webTools(opts?: WebToolsOpts): ToolHandler[] {
|
|
138
|
+
const fetchFn = opts?.fetchImpl ?? fetch
|
|
139
|
+
const env = opts?.env ?? process.env
|
|
140
|
+
const allowPrivate = opts?.allowPrivate ?? false
|
|
141
|
+
const cacheTtlMs = opts?.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS
|
|
142
|
+
const cacheMax = opts?.cacheMax ?? DEFAULT_CACHE_MAX
|
|
143
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
144
|
+
const cache = new Map<string, CacheEntry>()
|
|
145
|
+
|
|
146
|
+
return [
|
|
147
|
+
{
|
|
148
|
+
def: {
|
|
149
|
+
name: 'web_search',
|
|
150
|
+
description:
|
|
151
|
+
'Search the internet. Returns a list of titles, URLs, and snippets. Requires BRAVE_API_KEY or SEARXNG_URL to be configured.',
|
|
152
|
+
parameters: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
query: { type: 'string', description: 'Search query' },
|
|
156
|
+
limit: { type: 'number', description: 'Max results to return (default 5)' },
|
|
157
|
+
},
|
|
158
|
+
required: ['query'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
async invoke(args) {
|
|
162
|
+
const query = String(args.query ?? '')
|
|
163
|
+
if (!query) throw new Error('web_search: query is required')
|
|
164
|
+
const limit = typeof args.limit === 'number' ? args.limit : 5
|
|
165
|
+
|
|
166
|
+
const braveKey = env.BRAVE_API_KEY
|
|
167
|
+
if (braveKey) {
|
|
168
|
+
const results = await braveSearch(query, limit, braveKey, fetchFn)
|
|
169
|
+
return results.length === 0 ? 'no results' : formatResults(results)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const searxngUrl = env.SEARXNG_URL
|
|
173
|
+
if (searxngUrl) {
|
|
174
|
+
const results = await searxngSearch(query, limit, searxngUrl, fetchFn)
|
|
175
|
+
return results.length === 0 ? 'no results' : formatResults(results)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
throw new Error(
|
|
179
|
+
'web_search: no search backend configured. Set BRAVE_API_KEY (free at https://brave.com/search/api/) or SEARXNG_URL.',
|
|
180
|
+
)
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
def: {
|
|
185
|
+
name: 'web_fetch',
|
|
186
|
+
description:
|
|
187
|
+
'Fetch a URL and return its readable content. HTML is extracted via Readability and converted to markdown. Results are cached for 15 minutes.',
|
|
188
|
+
parameters: {
|
|
189
|
+
type: 'object',
|
|
190
|
+
properties: {
|
|
191
|
+
url: { type: 'string', description: 'The URL to fetch (http or https)' },
|
|
192
|
+
max_length: {
|
|
193
|
+
type: 'number',
|
|
194
|
+
description: 'Max characters to return (default 20000)',
|
|
195
|
+
},
|
|
196
|
+
extract_mode: {
|
|
197
|
+
type: 'string',
|
|
198
|
+
enum: ['markdown', 'text'],
|
|
199
|
+
description: 'Output format for HTML pages. Default "markdown".',
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
required: ['url'],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
async invoke(args) {
|
|
206
|
+
const url = String(args.url ?? '')
|
|
207
|
+
if (!url) throw new Error('web_fetch: url is required')
|
|
208
|
+
const maxLen = typeof args.max_length === 'number' ? args.max_length : DEFAULT_MAX_LENGTH
|
|
209
|
+
const mode: ExtractMode = args.extract_mode === 'text' ? 'text' : 'markdown'
|
|
210
|
+
const cacheKey = `${mode}|${url}`
|
|
211
|
+
|
|
212
|
+
const cached = cacheGet(cache, cacheKey)
|
|
213
|
+
if (cached) return formatOutput(cached, maxLen)
|
|
214
|
+
|
|
215
|
+
let result: GuardedFetchResultShape
|
|
216
|
+
try {
|
|
217
|
+
result = await guardedFetch({
|
|
218
|
+
url,
|
|
219
|
+
fetchImpl: opts?.fetchImpl,
|
|
220
|
+
allowPrivate,
|
|
221
|
+
timeoutMs,
|
|
222
|
+
init: {
|
|
223
|
+
headers: {
|
|
224
|
+
'user-agent': DEFAULT_USER_AGENT,
|
|
225
|
+
accept: 'text/html,application/xhtml+xml,application/json,text/plain,*/*',
|
|
226
|
+
'accept-language': 'en-US,en;q=0.9',
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (err instanceof SsrFBlockedError) throw new Error(`web_fetch: ${err.message}`)
|
|
232
|
+
throw err
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
if (!result.response.ok) {
|
|
237
|
+
throw new Error(`web_fetch: ${result.response.status} ${result.response.statusText}`)
|
|
238
|
+
}
|
|
239
|
+
const ct = result.response.headers.get('content-type') ?? ''
|
|
240
|
+
const body = await result.response.text()
|
|
241
|
+
let extracted: ExtractResult
|
|
242
|
+
if (ct.includes('text/html') || ct.includes('xhtml')) {
|
|
243
|
+
extracted = extractReadable(body, result.finalUrl, mode)
|
|
244
|
+
} else if (ct.includes('application/json')) {
|
|
245
|
+
try {
|
|
246
|
+
extracted = { text: JSON.stringify(JSON.parse(body), null, 2) }
|
|
247
|
+
} catch {
|
|
248
|
+
extracted = { text: body }
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
extracted = { text: body }
|
|
252
|
+
}
|
|
253
|
+
cacheSet(cache, cacheKey, extracted, cacheTtlMs, cacheMax)
|
|
254
|
+
return formatOutput(extracted, maxLen)
|
|
255
|
+
} finally {
|
|
256
|
+
await result.release()
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
]
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
type GuardedFetchResultShape = Awaited<ReturnType<typeof guardedFetch>>
|
|
264
|
+
|
|
265
|
+
function formatOutput(r: ExtractResult, maxLen: number): string {
|
|
266
|
+
const body = r.title ? `# ${r.title}\n\n${r.text}` : r.text
|
|
267
|
+
if (body.length > maxLen) return `${body.slice(0, maxLen)}\n\n[truncated at ${maxLen} chars]`
|
|
268
|
+
return body
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function formatResults(results: SearchResult[]): string {
|
|
272
|
+
return results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet}`).join('\n\n')
|
|
273
|
+
}
|