@try-works/dsh-righthand 0.1.5 → 0.1.7

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.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * weather-tools — the worked example of the keyless data-adapter recipe
3
+ * (blueprint/data-adapter). Two tools over Open-Meteo, a keyless API:
4
+ * rh_weather_forecast — current conditions + 3-day min/max
5
+ * rh_weather_air — PM2.5 / PM10 / European AQI
6
+ *
7
+ * Every request goes through guardedFetch, the SSRF checklist from Mu's
8
+ * safefetch: public destinations only (loopback, private, link-local incl.
9
+ * the metadata address, multicast and unspecified are refused), every
10
+ * redirect hop revalidated, response size and time capped.
11
+ * @module dsh-righthand/weather-tools
12
+ */
13
+
14
+ import type { Context } from '@deepseek-ai/cordis'
15
+ import { defineTool } from '@deepseek-ai/dsh-tools'
16
+ import { lookup } from 'node:dns/promises'
17
+ import { isIP } from 'node:net'
18
+
19
+ export const name = 'righthand-weather'
20
+ export const inject = ['tools']
21
+
22
+ export interface WeatherConfig {
23
+ /** Endpoint URLs; defaults are Open-Meteo (keyless). Tests override these. */
24
+ forecastUrl?: string
25
+ airUrl?: string
26
+ }
27
+
28
+ const DEFAULTS: Required<WeatherConfig> = {
29
+ forecastUrl: 'https://api.open-meteo.com/v1/forecast',
30
+ airUrl: 'https://air-quality-api.open-meteo.com/v1/air-quality',
31
+ }
32
+
33
+ const MAX_BODY_BYTES = 2 * 1024 * 1024
34
+ const MAX_REDIRECTS = 5
35
+ const FETCH_TIMEOUT_MS = 10000
36
+
37
+ /**
38
+ * Whether an IP must not be reached: the Mu safefetch set — loopback,
39
+ * RFC1918 private, ULA, link-local (169.254/16, fe80::/10 — which includes
40
+ * the 169.254.169.254 metadata address), multicast, and unspecified.
41
+ */
42
+ export function isBlockedIP(ip: string): boolean {
43
+ if (ip === '') return true
44
+ if (isIP(ip) === 4) {
45
+ const [a, b] = ip.split('.').map(Number)
46
+ if (a === 0 || a === 10 || a === 127) return true
47
+ if (a === 169 && b === 254) return true
48
+ if (a === 172 && b >= 16 && b <= 31) return true
49
+ if (a === 192 && b === 168) return true
50
+ if (a >= 224 && a <= 239) return true
51
+ return false
52
+ }
53
+ if (isIP(ip) === 6) {
54
+ const lower = ip.toLowerCase()
55
+ if (lower === '::' || lower === '::1') return true
56
+ if (lower.startsWith('fc') || lower.startsWith('fd')) return true
57
+ if (lower.startsWith('fe8') || lower.startsWith('fe9') || lower.startsWith('fea') || lower.startsWith('feb')) return true
58
+ if (lower.startsWith('ff')) return true
59
+ return false
60
+ }
61
+ return true
62
+ }
63
+
64
+ /**
65
+ * Fetch one URL with the SSRF checklist. `fetchImpl` and `resolve` are
66
+ * injectable for tests; production uses the globals.
67
+ */
68
+ export async function guardedFetch(
69
+ url: string,
70
+ deps: { fetchImpl?: typeof fetch; resolve?: (host: string) => Promise<string[]> } = {},
71
+ ): Promise<string> {
72
+ const fetchImpl = deps.fetchImpl ?? fetch
73
+ const resolve = deps.resolve ?? (async (host: string) => (await lookup(host, { all: true })).map(a => a.address))
74
+
75
+ let current = url
76
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
77
+ const parsed = new URL(current)
78
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error('blocked: non-http(s) URL')
79
+ const ips = await resolve(parsed.hostname)
80
+ if (ips.length === 0 || ips.some(isBlockedIP)) throw new Error('blocked: destination is not a public host')
81
+ const res = await fetchImpl(current, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'manual' })
82
+ if (res.status >= 300 && res.status < 400) {
83
+ const next = res.headers.get('location')
84
+ if (next === null) throw new Error('blocked: redirect without location')
85
+ current = new URL(next, current).toString()
86
+ continue
87
+ }
88
+ if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + current)
89
+ const reader = res.body?.getReader()
90
+ if (reader === undefined) return ''
91
+ const chunks: Uint8Array[] = []
92
+ let bytes = 0
93
+ for (;;) {
94
+ const { done, value } = await reader.read()
95
+ if (done) break
96
+ bytes += value.length
97
+ if (bytes > MAX_BODY_BYTES) throw new Error('response exceeds ' + MAX_BODY_BYTES + ' bytes')
98
+ chunks.push(value)
99
+ }
100
+ return Buffer.concat(chunks).toString('utf8')
101
+ }
102
+ throw new Error('too many redirects')
103
+ }
104
+
105
+ /**
106
+ * Normalize the Open-Meteo forecast response into a stable digest shape:
107
+ * what changed is the wire JSON; what callers see is this.
108
+ */
109
+ export interface ForecastOut {
110
+ latitude: number
111
+ longitude: number
112
+ timezone: string
113
+ current: { time: string; temperature: number; windspeed: number; winddirection: number; weathercode: number } | null
114
+ daily: { time: string[]; temperatureMax: number[]; temperatureMin: number[] }
115
+ }
116
+
117
+ export function normalizeForecast(json: any): ForecastOut {
118
+ const cw = json.current_weather
119
+ return {
120
+ latitude: Number(json.latitude ?? 0),
121
+ longitude: Number(json.longitude ?? 0),
122
+ timezone: json.timezone ?? '',
123
+ current: cw ? {
124
+ time: String(cw.time ?? ''),
125
+ temperature: Number(cw.temperature ?? 0),
126
+ windspeed: Number(cw.windspeed ?? 0),
127
+ winddirection: Number(cw.winddirection ?? 0),
128
+ weathercode: Number(cw.weathercode ?? 0),
129
+ } : null,
130
+ daily: {
131
+ time: (json.daily?.time ?? []).map(String),
132
+ temperatureMax: (json.daily?.temperature_2m_max ?? []).map(Number),
133
+ temperatureMin: (json.daily?.temperature_2m_min ?? []).map(Number),
134
+ },
135
+ }
136
+ }
137
+
138
+ /** Normalize the Open-Meteo air-quality response. */
139
+ export interface AirOut {
140
+ latitude: number
141
+ longitude: number
142
+ time: string
143
+ pm2_5: number | null
144
+ pm10: number | null
145
+ aqi: number | null
146
+ }
147
+
148
+ export function normalizeAir(json: any): AirOut {
149
+ const c = json.current ?? {}
150
+ const num = (v: unknown): number | null => (v === undefined || v === null ? null : Number(v))
151
+ return {
152
+ latitude: Number(json.latitude ?? 0),
153
+ longitude: Number(json.longitude ?? 0),
154
+ time: String(c.time ?? ''),
155
+ pm2_5: num(c.pm2_5),
156
+ pm10: num(c.pm10),
157
+ aqi: num(c.european_aqi),
158
+ }
159
+ }
160
+
161
+ export function apply(ctx: Context, config: WeatherConfig = {}): void {
162
+ const cfg: Required<WeatherConfig> = { ...DEFAULTS, ...config }
163
+
164
+ ctx.tools.register(defineTool({
165
+ name: 'rh_weather_forecast',
166
+ description: 'Weather forecast for a location from Open-Meteo (keyless): current conditions plus 3 days of min/max temperatures.',
167
+ parameters: {
168
+ latitude: { type: 'number', required: true, description: 'Latitude, e.g. 51.5074 for London.' },
169
+ longitude: { type: 'number', required: true, description: 'Longitude, e.g. -0.1278 for London.' },
170
+ },
171
+ output: {
172
+ schema: {
173
+ type: 'object',
174
+ additionalProperties: false,
175
+ properties: {
176
+ latitude: { type: 'number', required: true },
177
+ longitude: { type: 'number', required: true },
178
+ timezone: { type: 'string', required: true },
179
+ current: { type: 'json' },
180
+ daily: { type: 'json', required: true },
181
+ },
182
+ },
183
+ render: (_args, value) => { const c = value.current as any; return [{ type: 'text', text: c ? (value.timezone + ': ' + c.temperature + ' C, wind ' + c.windspeed + ' km/h') : value.timezone + ': current conditions unavailable' }] },
184
+ },
185
+ async execute(args) {
186
+ const url = cfg.forecastUrl
187
+ + '?latitude=' + args.latitude
188
+ + '&longitude=' + args.longitude
189
+ + '&current_weather=true&daily=temperature_2m_max,temperature_2m_min&forecast_days=3'
190
+ const body = await guardedFetch(url)
191
+ return normalizeForecast(JSON.parse(body))
192
+ },
193
+ }))
194
+
195
+ ctx.tools.register(defineTool({
196
+ name: 'rh_weather_air',
197
+ description: 'Air quality at a location from Open-Meteo (keyless): PM2.5, PM10 and the European AQI.',
198
+ parameters: {
199
+ latitude: { type: 'number', required: true, description: 'Latitude.' },
200
+ longitude: { type: 'number', required: true, description: 'Longitude.' },
201
+ },
202
+ output: {
203
+ schema: {
204
+ type: 'object',
205
+ additionalProperties: false,
206
+ properties: {
207
+ latitude: { type: 'number', required: true },
208
+ longitude: { type: 'number', required: true },
209
+ time: { type: 'string', required: true },
210
+ pm2_5: { type: 'number' },
211
+ pm10: { type: 'number' },
212
+ aqi: { type: 'number' },
213
+ },
214
+ },
215
+ render: (_args, value) => [{ type: 'text', text: 'PM2.5 ' + value.pm2_5 + ', PM10 ' + value.pm10 + ', AQI ' + value.aqi }],
216
+ },
217
+ async execute(args) {
218
+ const url = cfg.airUrl
219
+ + '?latitude=' + args.latitude
220
+ + '&longitude=' + args.longitude
221
+ + '&current=pm2_5,pm10,european_aqi'
222
+ const body = await guardedFetch(url)
223
+ const out = normalizeAir(JSON.parse(body))
224
+ return {
225
+ latitude: out.latitude,
226
+ longitude: out.longitude,
227
+ time: out.time,
228
+ ...out.pm2_5 !== null ? { pm2_5: out.pm2_5 } : {},
229
+ ...out.pm10 !== null ? { pm10: out.pm10 } : {},
230
+ ...out.aqi !== null ? { aqi: out.aqi } : {},
231
+ }
232
+ },
233
+ }))
234
+ }
235
+
236
+