@ouraihub/aether-worker 0.0.0-stage → 0.1.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/LICENSE +789 -0
- package/migrations/0001.sql +22 -0
- package/package.json +37 -3
- package/src/app.ts +327 -0
- package/src/cloudflare.ts +49 -0
- package/src/context.ts +49 -0
- package/src/conversations/action.ts +234 -0
- package/src/conversations/index.ts +477 -0
- package/src/conversations/model.ts +402 -0
- package/src/coordinator.ts +560 -0
- package/src/credentials.ts +153 -0
- package/src/files.ts +105 -0
- package/src/host.ts +17 -0
- package/src/identity.ts +198 -0
- package/src/images/index.ts +46 -0
- package/src/index.ts +30 -0
- package/src/logger.ts +87 -0
- package/src/mcp/auth.ts +350 -0
- package/src/mcp/client.ts +426 -0
- package/src/mcp/common.ts +120 -0
- package/src/mcp/index.ts +239 -0
- package/src/mcp/oauth.ts +284 -0
- package/src/mcp/types.ts +74 -0
- package/src/outbound.ts +305 -0
- package/src/providers/index.ts +505 -0
- package/src/runtime.ts +635 -0
- package/src/search/index.ts +431 -0
- package/src/skills/index.ts +1 -0
- package/src/skills/persistence.ts +140 -0
- package/src/skills/service.ts +203 -0
- package/src/storage.ts +198 -0
- package/README.md +0 -3
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { badRequest, KitFailure } from '@ouraihub/aether-core'
|
|
2
|
+
import type { OutboundHttp } from '@ouraihub/aether-core/ports'
|
|
3
|
+
import type { FetchBinding } from './cloudflare.ts'
|
|
4
|
+
|
|
5
|
+
export interface OutboundLimits {
|
|
6
|
+
readonly firstByteMs: number
|
|
7
|
+
readonly idleMs: number
|
|
8
|
+
readonly totalMs: number
|
|
9
|
+
readonly maxResponseBytes: number
|
|
10
|
+
}
|
|
11
|
+
/** This binding MUST enforce public-only DNS at connect time, including all resolved
|
|
12
|
+
* IPv4/IPv6 addresses and rebinding. A DNS preflight followed by ordinary fetch is
|
|
13
|
+
* not this contract. It must not follow redirects or forward through an open proxy.
|
|
14
|
+
* Deploy a trusted egress service binding; absent that binding remote egress fails.
|
|
15
|
+
*/
|
|
16
|
+
export interface TrustedOutboundGateway {
|
|
17
|
+
readonly security: 'public-addresses-at-connect-time'
|
|
18
|
+
readonly binding: FetchBinding
|
|
19
|
+
}
|
|
20
|
+
/** Separately deployed, target-bound internal gateway. It must enforce exact
|
|
21
|
+
* configured origin/port at connect time, including DNS, with no redirect/proxy. */
|
|
22
|
+
export interface TrustedPrivateOutboundGateway {
|
|
23
|
+
readonly security: 'explicit-private-targets-at-connect-time'
|
|
24
|
+
readonly binding: FetchBinding
|
|
25
|
+
readonly allowedOrigins: readonly string[]
|
|
26
|
+
}
|
|
27
|
+
export interface WorkerOutboundOptions {
|
|
28
|
+
readonly gateway?: TrustedOutboundGateway
|
|
29
|
+
readonly privateGateway?: TrustedPrivateOutboundGateway
|
|
30
|
+
readonly allowedOrigins: readonly string[]
|
|
31
|
+
readonly limits: OutboundLimits
|
|
32
|
+
/** Local integration tests only, literal loopback hosts; never hostname DNS. */
|
|
33
|
+
readonly allowLoopbackForTests?: boolean
|
|
34
|
+
readonly localFetch?: (url: string, init: RequestInit) => Promise<Response>
|
|
35
|
+
}
|
|
36
|
+
function isLoopback(hostname: string): boolean {
|
|
37
|
+
return hostname === '[::1]' || /^127(?:\.(?:\d{1,3})){3}$/u.test(hostname)
|
|
38
|
+
}
|
|
39
|
+
function privateLiteral(host: string): boolean {
|
|
40
|
+
if (host.startsWith('[')) {
|
|
41
|
+
const ip = host.slice(1, -1).toLowerCase()
|
|
42
|
+
// Public IPv6 unicast only; mapped/compatible IPv4, special and local ranges fail closed.
|
|
43
|
+
return (
|
|
44
|
+
!/^[23][0-9a-f]{0,3}:/u.test(ip) ||
|
|
45
|
+
ip.startsWith('2001:db8:') ||
|
|
46
|
+
ip.startsWith('2001:0:') ||
|
|
47
|
+
ip.startsWith('2002:')
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
if (!/^\d+(?:\.\d+){3}$/u.test(host))
|
|
51
|
+
return host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || !host.includes('.')
|
|
52
|
+
const parts = host.split('.').map(Number)
|
|
53
|
+
const a = parts[0],
|
|
54
|
+
b = parts[1],
|
|
55
|
+
c = parts[2]
|
|
56
|
+
return (
|
|
57
|
+
a === undefined ||
|
|
58
|
+
b === undefined ||
|
|
59
|
+
c === undefined ||
|
|
60
|
+
a === 0 ||
|
|
61
|
+
a === 10 ||
|
|
62
|
+
a === 127 ||
|
|
63
|
+
a >= 224 ||
|
|
64
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
65
|
+
(a === 169 && b === 254) ||
|
|
66
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
67
|
+
(a === 192 && (b === 168 || b === 0 || b === 2)) ||
|
|
68
|
+
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
|
|
69
|
+
(a === 203 && b === 0 && c === 113)
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
export function createWorkerOutbound(options: WorkerOutboundOptions): OutboundHttp {
|
|
73
|
+
const limits = Object.freeze({ ...options.limits })
|
|
74
|
+
for (const value of Object.values(limits))
|
|
75
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw badRequest('outboundLimits', 'invalid')
|
|
76
|
+
const origins = new Set(
|
|
77
|
+
options.allowedOrigins.map((origin) => {
|
|
78
|
+
let url: URL
|
|
79
|
+
try {
|
|
80
|
+
url = new URL(origin)
|
|
81
|
+
} catch {
|
|
82
|
+
throw badRequest('allowedOrigins', 'invalid')
|
|
83
|
+
}
|
|
84
|
+
if (url.origin !== origin || url.username || url.password) throw badRequest('allowedOrigins', 'invalid')
|
|
85
|
+
return origin
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
if (origins.size === 0) throw new KitFailure('needs_config', 'Outbound origins must be explicitly configured')
|
|
89
|
+
const privateOrigins = new Set(options.privateGateway?.allowedOrigins ?? [])
|
|
90
|
+
for (const origin of privateOrigins) {
|
|
91
|
+
let target: URL
|
|
92
|
+
try {
|
|
93
|
+
target = new URL(origin)
|
|
94
|
+
} catch {
|
|
95
|
+
throw badRequest('privateOrigins', 'invalid')
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
target.origin !== origin ||
|
|
99
|
+
!origins.has(origin) ||
|
|
100
|
+
(target.protocol !== 'http:' && target.protocol !== 'https:')
|
|
101
|
+
)
|
|
102
|
+
throw badRequest('privateOrigins', 'invalid')
|
|
103
|
+
}
|
|
104
|
+
return Object.freeze({
|
|
105
|
+
async fetch(url: string, init: RequestInit): Promise<Response> {
|
|
106
|
+
let target: URL
|
|
107
|
+
try {
|
|
108
|
+
target = new URL(url)
|
|
109
|
+
} catch {
|
|
110
|
+
throw badRequest('url', 'invalid')
|
|
111
|
+
}
|
|
112
|
+
const local = options.allowLoopbackForTests === true && isLoopback(target.hostname)
|
|
113
|
+
const privateTarget = privateOrigins.has(target.origin)
|
|
114
|
+
if (
|
|
115
|
+
target.username ||
|
|
116
|
+
target.password ||
|
|
117
|
+
target.hash ||
|
|
118
|
+
(!local && !privateTarget && target.protocol !== 'https:') ||
|
|
119
|
+
((local || privateTarget) && target.protocol !== 'https:' && target.protocol !== 'http:') ||
|
|
120
|
+
(!local && !privateTarget && privateLiteral(target.hostname)) ||
|
|
121
|
+
!origins.has(target.origin)
|
|
122
|
+
)
|
|
123
|
+
throw badRequest('url', 'outbound_policy')
|
|
124
|
+
const gateway = privateTarget ? options.privateGateway : options.gateway
|
|
125
|
+
if (
|
|
126
|
+
!local &&
|
|
127
|
+
(!gateway ||
|
|
128
|
+
gateway.security !==
|
|
129
|
+
(privateTarget ? 'explicit-private-targets-at-connect-time' : 'public-addresses-at-connect-time'))
|
|
130
|
+
)
|
|
131
|
+
throw new KitFailure('needs_config', 'Trusted connect-time outbound gateway is required')
|
|
132
|
+
const abort = new AbortController()
|
|
133
|
+
let failure: KitFailure | null = null
|
|
134
|
+
const firstTimer = setTimeout(
|
|
135
|
+
() => fail(new KitFailure('timeout', 'Outbound first-byte deadline exceeded')),
|
|
136
|
+
limits.firstByteMs,
|
|
137
|
+
)
|
|
138
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
|
139
|
+
const totalTimer = setTimeout(
|
|
140
|
+
() => fail(new KitFailure('timeout', 'Outbound total deadline exceeded')),
|
|
141
|
+
limits.totalMs,
|
|
142
|
+
)
|
|
143
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
|
144
|
+
let bodyController: ReadableStreamDefaultController<Uint8Array> | undefined
|
|
145
|
+
let finished = false
|
|
146
|
+
function clean(): void {
|
|
147
|
+
clearTimeout(firstTimer)
|
|
148
|
+
clearTimeout(idleTimer ?? 0)
|
|
149
|
+
clearTimeout(totalTimer)
|
|
150
|
+
init.signal?.removeEventListener('abort', callerAbort)
|
|
151
|
+
abort.signal.removeEventListener('abort', onAbort)
|
|
152
|
+
}
|
|
153
|
+
function fail(error: KitFailure): void {
|
|
154
|
+
if (!abort.signal.aborted) {
|
|
155
|
+
failure = error
|
|
156
|
+
abort.abort()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function callerAbort(): void {
|
|
160
|
+
fail(new KitFailure('cancelled', 'Outbound request cancelled'))
|
|
161
|
+
}
|
|
162
|
+
function onAbort(): void {
|
|
163
|
+
if (finished) return
|
|
164
|
+
finished = true
|
|
165
|
+
bodyController?.error(failure ?? new KitFailure('cancelled', 'Outbound request cancelled'))
|
|
166
|
+
if (reader) void reader.cancel().catch(() => {})
|
|
167
|
+
clean()
|
|
168
|
+
}
|
|
169
|
+
init.signal?.addEventListener('abort', callerAbort, { once: true })
|
|
170
|
+
abort.signal.addEventListener('abort', onAbort, { once: true })
|
|
171
|
+
if (init.signal?.aborted) callerAbort()
|
|
172
|
+
if (abort.signal.aborted) throw failure ?? new KitFailure('cancelled', 'Outbound request cancelled')
|
|
173
|
+
try {
|
|
174
|
+
const headers = new Headers(init.headers)
|
|
175
|
+
headers.set('User-Agent', 'Aether-Worker/1')
|
|
176
|
+
const request: RequestInit & { credentials: 'omit' } = {
|
|
177
|
+
...init,
|
|
178
|
+
headers,
|
|
179
|
+
redirect: 'manual',
|
|
180
|
+
signal: abort.signal,
|
|
181
|
+
credentials: 'omit',
|
|
182
|
+
}
|
|
183
|
+
// Remote fetch is always the trusted binding. Global fetch is only the literal-loopback opt-in.
|
|
184
|
+
const pending = local
|
|
185
|
+
? (options.localFetch ?? fetch)(target.href, request)
|
|
186
|
+
: gateway?.binding.fetch(target.href, request)
|
|
187
|
+
if (!pending) throw new KitFailure('needs_config', 'Trusted connect-time outbound gateway is required')
|
|
188
|
+
void pending.then(
|
|
189
|
+
(response) => {
|
|
190
|
+
if (abort.signal.aborted) void response.body?.cancel().catch(() => {})
|
|
191
|
+
},
|
|
192
|
+
() => {},
|
|
193
|
+
)
|
|
194
|
+
const aborted = Promise.withResolvers<never>()
|
|
195
|
+
const rejectAbort = () => aborted.reject(failure ?? new KitFailure('cancelled', 'Outbound request cancelled'))
|
|
196
|
+
abort.signal.addEventListener('abort', rejectAbort, { once: true })
|
|
197
|
+
let response: Response
|
|
198
|
+
try {
|
|
199
|
+
response = await Promise.race([pending, aborted.promise])
|
|
200
|
+
} finally {
|
|
201
|
+
abort.signal.removeEventListener('abort', rejectAbort)
|
|
202
|
+
}
|
|
203
|
+
if (abort.signal.aborted) {
|
|
204
|
+
void response.body?.cancel().catch(() => {})
|
|
205
|
+
throw failure
|
|
206
|
+
}
|
|
207
|
+
if (response.redirected || (response.url && response.url !== target.href)) {
|
|
208
|
+
void response.body?.cancel().catch(() => {})
|
|
209
|
+
throw badRequest('url', 'redirect_forbidden')
|
|
210
|
+
}
|
|
211
|
+
const length = response.headers.get('content-length')
|
|
212
|
+
if (length !== null && (!/^\d+$/u.test(length) || Number(length) > limits.maxResponseBytes)) {
|
|
213
|
+
void response.body?.cancel().catch(() => {})
|
|
214
|
+
throw badRequest('response', 'too_large', limits.maxResponseBytes)
|
|
215
|
+
}
|
|
216
|
+
if (!response.body) {
|
|
217
|
+
finished = true
|
|
218
|
+
clean()
|
|
219
|
+
return response
|
|
220
|
+
}
|
|
221
|
+
reader = response.body.getReader()
|
|
222
|
+
let received = 0
|
|
223
|
+
const body = new ReadableStream<Uint8Array>({
|
|
224
|
+
start(controller) {
|
|
225
|
+
bodyController = controller
|
|
226
|
+
},
|
|
227
|
+
async pull(controller) {
|
|
228
|
+
if (!reader || finished) return
|
|
229
|
+
idleTimer = setTimeout(
|
|
230
|
+
() => fail(new KitFailure('timeout', 'Outbound idle deadline exceeded')),
|
|
231
|
+
limits.idleMs,
|
|
232
|
+
)
|
|
233
|
+
try {
|
|
234
|
+
const result = await reader.read()
|
|
235
|
+
clearTimeout(idleTimer)
|
|
236
|
+
if (finished) return
|
|
237
|
+
if (result.done) {
|
|
238
|
+
finished = true
|
|
239
|
+
clean()
|
|
240
|
+
controller.close()
|
|
241
|
+
reader.releaseLock()
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
clearTimeout(firstTimer)
|
|
245
|
+
received += result.value.byteLength
|
|
246
|
+
if (received > limits.maxResponseBytes) {
|
|
247
|
+
fail(badRequest('response', 'too_large', limits.maxResponseBytes))
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
controller.enqueue(result.value)
|
|
251
|
+
} catch {
|
|
252
|
+
fail(new KitFailure('internal', 'Outbound response failed'))
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
async cancel() {
|
|
256
|
+
finished = true
|
|
257
|
+
clean()
|
|
258
|
+
abort.abort()
|
|
259
|
+
await reader?.cancel().catch(() => {})
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
return new Response(body, {
|
|
263
|
+
status: response.status,
|
|
264
|
+
statusText: response.statusText,
|
|
265
|
+
headers: response.headers,
|
|
266
|
+
})
|
|
267
|
+
} catch (error) {
|
|
268
|
+
finished = true
|
|
269
|
+
clean()
|
|
270
|
+
abort.abort()
|
|
271
|
+
if (error instanceof KitFailure) throw error
|
|
272
|
+
throw failure ?? new KitFailure('internal', 'Outbound request failed')
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface TrustedTargetChecker {
|
|
279
|
+
readonly security: 'all-dns-addresses-preflight'
|
|
280
|
+
/** Reject if any A/AAAA result is private/special/unknown, or lookup fails.
|
|
281
|
+
* The content proxy must independently enforce its own connect-time address policy. */
|
|
282
|
+
checkTarget(url: URL, signal: AbortSignal): Promise<void>
|
|
283
|
+
}
|
|
284
|
+
export function createWorkerTargetCheck(options: {
|
|
285
|
+
readonly checker: TrustedTargetChecker
|
|
286
|
+
readonly allowLoopbackForTests?: boolean
|
|
287
|
+
}): (url: URL, signal: AbortSignal) => Promise<void> {
|
|
288
|
+
if (!options.checker || options.checker.security !== 'all-dns-addresses-preflight')
|
|
289
|
+
throw new KitFailure('needs_config', 'Trusted DNS target checker is required')
|
|
290
|
+
return async (url: URL, signal: AbortSignal): Promise<void> => {
|
|
291
|
+
if (signal.aborted) throw new KitFailure('cancelled', 'Target check cancelled')
|
|
292
|
+
const local = options.allowLoopbackForTests === true && isLoopback(url.hostname)
|
|
293
|
+
if (
|
|
294
|
+
url.username ||
|
|
295
|
+
url.password ||
|
|
296
|
+
url.hash ||
|
|
297
|
+
(!local && url.protocol !== 'https:') ||
|
|
298
|
+
(local && url.protocol !== 'http:' && url.protocol !== 'https:') ||
|
|
299
|
+
(!local && privateLiteral(url.hostname))
|
|
300
|
+
)
|
|
301
|
+
throw badRequest('url', 'outbound_policy')
|
|
302
|
+
if (!local) await options.checker.checkTarget(new URL(url.href), signal)
|
|
303
|
+
if (signal.aborted) throw new KitFailure('cancelled', 'Target check cancelled')
|
|
304
|
+
}
|
|
305
|
+
}
|