@ouraihub/aether-worker 0.1.0 → 0.1.2
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/package.json +5 -4
- package/src/app.ts +52 -16
- package/src/context.ts +9 -3
- package/src/conversations/action.ts +31 -8
- package/src/conversations/index.ts +144 -141
- package/src/conversations/model.ts +36 -11
- package/src/conversations/routes.ts +101 -0
- package/src/coordinator-helpers.ts +131 -0
- package/src/coordinator.ts +54 -126
- package/src/credentials.ts +22 -4
- package/src/files.ts +17 -5
- package/src/host.ts +10 -2
- package/src/identity.ts +54 -25
- package/src/images/index.ts +39 -12
- package/src/logger.ts +15 -4
- package/src/mcp/auth.ts +65 -17
- package/src/mcp/client.ts +89 -24
- package/src/mcp/common.ts +7 -3
- package/src/mcp/index.ts +74 -21
- package/src/mcp/oauth.ts +82 -23
- package/src/mcp/types.ts +9 -2
- package/src/outbound.ts +23 -8
- package/src/providers/catalog.ts +63 -0
- package/src/providers/http.ts +88 -0
- package/src/providers/index.ts +103 -163
- package/src/providers/model-options.ts +28 -0
- package/src/runtime-input.ts +44 -0
- package/src/runtime-tools.ts +298 -0
- package/src/runtime.ts +105 -264
- package/src/search/index.ts +89 -23
- package/src/skills/persistence.ts +18 -5
- package/src/skills/service.ts +60 -13
- package/src/storage.ts +108 -13
- package/src/testing/fakes.ts +97 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isContract,
|
|
3
|
+
type EndStatus,
|
|
4
|
+
type KitError,
|
|
5
|
+
type RunEvent,
|
|
6
|
+
type RunSnapshot,
|
|
7
|
+
} from '@ouraihub/aether-contracts'
|
|
8
|
+
import { KitFailure } from '@ouraihub/aether-core'
|
|
9
|
+
import { immutableActor, isActor, type Actor } from './context.js'
|
|
10
|
+
import type { AcceptedHandle } from './conversations/index.js'
|
|
11
|
+
|
|
12
|
+
export type Phase = 'creating' | 'accepted' | 'running' | EndStatus
|
|
13
|
+
export interface Metadata {
|
|
14
|
+
phase: Phase
|
|
15
|
+
runId: string
|
|
16
|
+
actor: Actor
|
|
17
|
+
handle: AcceptedHandle | null
|
|
18
|
+
snapshot: RunSnapshot
|
|
19
|
+
revision: number
|
|
20
|
+
acceptedUntil: number | null
|
|
21
|
+
pending: { callId: string; revision: number } | null
|
|
22
|
+
}
|
|
23
|
+
export interface Pending {
|
|
24
|
+
callId: string
|
|
25
|
+
revision: number
|
|
26
|
+
decision: boolean | undefined
|
|
27
|
+
resolve: ((decision: boolean) => void) | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const storageKey = 'run'
|
|
31
|
+
export const encoder = new TextEncoder()
|
|
32
|
+
export const conflict = (reason: string): KitFailure =>
|
|
33
|
+
new KitFailure('conflict', 'Run state conflict', { reason })
|
|
34
|
+
export const missing = (): KitFailure => new KitFailure('not_found', 'Run not found')
|
|
35
|
+
export const interrupted = (newId: () => string): KitError => ({
|
|
36
|
+
code: 'internal',
|
|
37
|
+
message: 'Run interrupted',
|
|
38
|
+
details: { reason: 'interrupted', errorId: newId() },
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** No arbitrary messages/details cross this boundary, even from host KitFailures. */
|
|
42
|
+
export function publicError(error: unknown, newId: () => string): KitError {
|
|
43
|
+
const code =
|
|
44
|
+
error instanceof KitFailure
|
|
45
|
+
? error.code
|
|
46
|
+
: isContract('KitError', error)
|
|
47
|
+
? error.code
|
|
48
|
+
: 'internal'
|
|
49
|
+
const messages: Record<KitError['code'], string> = {
|
|
50
|
+
bad_request: 'Invalid request',
|
|
51
|
+
not_found: 'Run not found',
|
|
52
|
+
conflict: 'Run state conflict',
|
|
53
|
+
unsupported: 'Unsupported operation',
|
|
54
|
+
cancelled: 'Cancelled',
|
|
55
|
+
unauthorized: 'Authentication required',
|
|
56
|
+
forbidden: 'Access denied',
|
|
57
|
+
rate_limited: 'Rate limit exceeded',
|
|
58
|
+
upstream: 'Upstream execution failed',
|
|
59
|
+
timeout: 'Execution timed out',
|
|
60
|
+
network: 'Network execution failed',
|
|
61
|
+
needs_config: 'Configuration required',
|
|
62
|
+
budget_exceeded: 'Execution budget exceeded',
|
|
63
|
+
incompatible_db: 'Incompatible database',
|
|
64
|
+
internal: 'Internal execution failure',
|
|
65
|
+
}
|
|
66
|
+
// Error IDs are generated here, never copied from a potentially secret-bearing host error.
|
|
67
|
+
const reason = error instanceof KitFailure ? error.details?.reason : undefined
|
|
68
|
+
const safeReason =
|
|
69
|
+
reason === 'event_replay_unavailable' ||
|
|
70
|
+
reason === 'subscriber_already_active' ||
|
|
71
|
+
reason === 'approval_not_pending'
|
|
72
|
+
? reason
|
|
73
|
+
: undefined
|
|
74
|
+
return {
|
|
75
|
+
code,
|
|
76
|
+
message: messages[code],
|
|
77
|
+
...(code === 'internal' || safeReason
|
|
78
|
+
? {
|
|
79
|
+
details: {
|
|
80
|
+
...(safeReason ? { reason: safeReason } : {}),
|
|
81
|
+
...(code === 'internal' ? { errorId: newId() } : {}),
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
: {}),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function statusCode(error: KitError): number {
|
|
88
|
+
switch (error.code) {
|
|
89
|
+
case 'bad_request':
|
|
90
|
+
return 400
|
|
91
|
+
case 'unauthorized':
|
|
92
|
+
return 401
|
|
93
|
+
case 'forbidden':
|
|
94
|
+
return 403
|
|
95
|
+
case 'not_found':
|
|
96
|
+
return 404
|
|
97
|
+
case 'conflict':
|
|
98
|
+
return 409
|
|
99
|
+
case 'rate_limited':
|
|
100
|
+
return 429
|
|
101
|
+
case 'internal':
|
|
102
|
+
case 'incompatible_db':
|
|
103
|
+
return 500
|
|
104
|
+
default:
|
|
105
|
+
return 422
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export function actorHeader(request: Request): Actor {
|
|
109
|
+
try {
|
|
110
|
+
const encoded = request.headers.get('x-aether-actor')
|
|
111
|
+
if (!encoded || encoded.length > 131072) throw missing()
|
|
112
|
+
const bytes = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0))
|
|
113
|
+
const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
|
|
114
|
+
if (!isActor(value)) throw missing()
|
|
115
|
+
return immutableActor(value)
|
|
116
|
+
} catch {
|
|
117
|
+
throw missing()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function terminal(phase: Phase): phase is EndStatus {
|
|
121
|
+
return phase === 'completed' || phase === 'failed' || phase === 'cancelled'
|
|
122
|
+
}
|
|
123
|
+
export function endEvent(snapshot: RunSnapshot): RunEvent {
|
|
124
|
+
if (snapshot.status === 'running') throw conflict('run_not_terminal')
|
|
125
|
+
return {
|
|
126
|
+
kind: 'end',
|
|
127
|
+
status: snapshot.status,
|
|
128
|
+
...(snapshot.error ? { error: snapshot.error } : {}),
|
|
129
|
+
...(snapshot.exhausted === undefined ? {} : { exhausted: snapshot.exhausted }),
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/coordinator.ts
CHANGED
|
@@ -1,125 +1,38 @@
|
|
|
1
|
-
import { isContract, type
|
|
1
|
+
import { isContract, type RunEvent, type RunSnapshot } from '@ouraihub/aether-contracts'
|
|
2
2
|
import { KitFailure, type Approver } from '@ouraihub/aether-core'
|
|
3
3
|
import type { DurableObjectState } from './cloudflare.js'
|
|
4
4
|
import { immutableActor, isActor, ownerKey, type Actor, type WorkerContext } from './context.js'
|
|
5
|
-
import type { AcceptedHandle } from './conversations/index.js'
|
|
6
5
|
import type { PreparedExecution, WorkerRuntime } from './runtime.js'
|
|
6
|
+
import {
|
|
7
|
+
actorHeader,
|
|
8
|
+
conflict,
|
|
9
|
+
encoder,
|
|
10
|
+
endEvent,
|
|
11
|
+
interrupted,
|
|
12
|
+
missing,
|
|
13
|
+
publicError,
|
|
14
|
+
statusCode,
|
|
15
|
+
storageKey,
|
|
16
|
+
terminal,
|
|
17
|
+
type Metadata,
|
|
18
|
+
type Pending,
|
|
19
|
+
} from './coordinator-helpers.js'
|
|
7
20
|
|
|
8
21
|
type Runtime = Pick<WorkerRuntime, 'prepare' | 'execute' | 'abandon' | 'recover'>
|
|
9
|
-
|
|
10
|
-
interface
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
handle: AcceptedHandle | null
|
|
15
|
-
snapshot: RunSnapshot
|
|
16
|
-
revision: number
|
|
17
|
-
acceptedUntil: number | null
|
|
18
|
-
pending: { callId: string; revision: number } | null
|
|
19
|
-
}
|
|
20
|
-
interface Pending {
|
|
21
|
-
callId: string
|
|
22
|
-
revision: number
|
|
23
|
-
decision: boolean | undefined
|
|
24
|
-
resolve: ((decision: boolean) => void) | null
|
|
25
|
-
}
|
|
26
|
-
const storageKey = 'run'
|
|
27
|
-
const encoder = new TextEncoder()
|
|
28
|
-
const conflict = (reason: string): KitFailure => new KitFailure('conflict', 'Run state conflict', { reason })
|
|
29
|
-
const missing = (): KitFailure => new KitFailure('not_found', 'Run not found')
|
|
30
|
-
const interrupted = (): KitError => ({
|
|
31
|
-
code: 'internal',
|
|
32
|
-
message: 'Run interrupted',
|
|
33
|
-
details: { reason: 'interrupted', errorId: crypto.randomUUID() },
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
/** No arbitrary messages/details cross this boundary, even from host KitFailures. */
|
|
37
|
-
function publicError(error: unknown): KitError {
|
|
38
|
-
const code = error instanceof KitFailure ? error.code : isContract('KitError', error) ? error.code : 'internal'
|
|
39
|
-
const messages: Record<KitError['code'], string> = {
|
|
40
|
-
bad_request: 'Invalid request',
|
|
41
|
-
not_found: 'Run not found',
|
|
42
|
-
conflict: 'Run state conflict',
|
|
43
|
-
unsupported: 'Unsupported operation',
|
|
44
|
-
cancelled: 'Cancelled',
|
|
45
|
-
unauthorized: 'Authentication required',
|
|
46
|
-
forbidden: 'Access denied',
|
|
47
|
-
rate_limited: 'Rate limit exceeded',
|
|
48
|
-
upstream: 'Upstream execution failed',
|
|
49
|
-
timeout: 'Execution timed out',
|
|
50
|
-
network: 'Network execution failed',
|
|
51
|
-
needs_config: 'Configuration required',
|
|
52
|
-
budget_exceeded: 'Execution budget exceeded',
|
|
53
|
-
incompatible_db: 'Incompatible database',
|
|
54
|
-
internal: 'Internal execution failure',
|
|
55
|
-
}
|
|
56
|
-
// Error IDs are generated here, never copied from a potentially secret-bearing host error.
|
|
57
|
-
const reason = error instanceof KitFailure ? error.details?.reason : undefined
|
|
58
|
-
const safeReason =
|
|
59
|
-
reason === 'event_replay_unavailable' || reason === 'subscriber_already_active' || reason === 'approval_not_pending'
|
|
60
|
-
? reason
|
|
61
|
-
: undefined
|
|
62
|
-
return {
|
|
63
|
-
code,
|
|
64
|
-
message: messages[code],
|
|
65
|
-
...(code === 'internal' || safeReason
|
|
66
|
-
? {
|
|
67
|
-
details: {
|
|
68
|
-
...(safeReason ? { reason: safeReason } : {}),
|
|
69
|
-
...(code === 'internal' ? { errorId: crypto.randomUUID() } : {}),
|
|
70
|
-
},
|
|
71
|
-
}
|
|
72
|
-
: {}),
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
function statusCode(error: KitError): number {
|
|
76
|
-
switch (error.code) {
|
|
77
|
-
case 'bad_request':
|
|
78
|
-
return 400
|
|
79
|
-
case 'unauthorized':
|
|
80
|
-
return 401
|
|
81
|
-
case 'forbidden':
|
|
82
|
-
return 403
|
|
83
|
-
case 'not_found':
|
|
84
|
-
return 404
|
|
85
|
-
case 'conflict':
|
|
86
|
-
return 409
|
|
87
|
-
case 'rate_limited':
|
|
88
|
-
return 429
|
|
89
|
-
case 'internal':
|
|
90
|
-
case 'incompatible_db':
|
|
91
|
-
return 500
|
|
92
|
-
default:
|
|
93
|
-
return 422
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
function actorHeader(request: Request): Actor {
|
|
97
|
-
try {
|
|
98
|
-
const encoded = request.headers.get('x-aether-actor')
|
|
99
|
-
if (!encoded || encoded.length > 131072) throw missing()
|
|
100
|
-
const bytes = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0))
|
|
101
|
-
const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
|
|
102
|
-
if (!isActor(value)) throw missing()
|
|
103
|
-
return immutableActor(value)
|
|
104
|
-
} catch {
|
|
105
|
-
throw missing()
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
function terminal(phase: Phase): phase is EndStatus {
|
|
109
|
-
return phase === 'completed' || phase === 'failed' || phase === 'cancelled'
|
|
110
|
-
}
|
|
111
|
-
function endEvent(snapshot: RunSnapshot): RunEvent {
|
|
112
|
-
if (snapshot.status === 'running') throw conflict('run_not_terminal')
|
|
113
|
-
return {
|
|
114
|
-
kind: 'end',
|
|
115
|
-
status: snapshot.status,
|
|
116
|
-
...(snapshot.error ? { error: snapshot.error } : {}),
|
|
117
|
-
...(snapshot.exhausted === undefined ? {} : { exhausted: snapshot.exhausted }),
|
|
118
|
-
}
|
|
22
|
+
/** Time and identifiers the coordinator uses for leases, request ids and error ids. */
|
|
23
|
+
export interface CoordinatorOptions {
|
|
24
|
+
/** Milliseconds since the epoch; drives the 30 s subscription lease and alarms. */
|
|
25
|
+
readonly now?: () => number
|
|
26
|
+
readonly newId?: () => string
|
|
119
27
|
}
|
|
120
28
|
|
|
121
29
|
/** The router alone constructs this private protocol after authentication. */
|
|
122
|
-
export function createRunCoordinator<Env>(
|
|
30
|
+
export function createRunCoordinator<Env>(
|
|
31
|
+
createRuntime: (env: Env) => Runtime,
|
|
32
|
+
options: CoordinatorOptions = {},
|
|
33
|
+
) {
|
|
34
|
+
const now = options.now ?? Date.now
|
|
35
|
+
const newId = options.newId ?? (() => crypto.randomUUID())
|
|
123
36
|
return class RunCoordinator {
|
|
124
37
|
private readonly state: DurableObjectState
|
|
125
38
|
private readonly runtime: Runtime
|
|
@@ -147,12 +60,16 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
|
|
|
147
60
|
try {
|
|
148
61
|
await this.runtime.recover(record.handle, this.context(record.actor))
|
|
149
62
|
} catch (error) {
|
|
150
|
-
if (!(
|
|
63
|
+
if (!(
|
|
64
|
+
record.phase === 'creating' &&
|
|
65
|
+
error instanceof KitFailure &&
|
|
66
|
+
error.code === 'not_found'
|
|
67
|
+
))
|
|
151
68
|
recoveryFailure = error
|
|
152
69
|
}
|
|
153
70
|
}
|
|
154
71
|
record.phase = 'failed'
|
|
155
|
-
record.snapshot = { ...record.snapshot, status: 'failed', error: interrupted() }
|
|
72
|
+
record.snapshot = { ...record.snapshot, status: 'failed', error: interrupted(newId) }
|
|
156
73
|
record.pending = null
|
|
157
74
|
record.acceptedUntil = null
|
|
158
75
|
await this.persist()
|
|
@@ -162,7 +79,7 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
|
|
|
162
79
|
}
|
|
163
80
|
|
|
164
81
|
private context(actor: Actor): WorkerContext {
|
|
165
|
-
return { actor, signal: this.abort.signal, requestId:
|
|
82
|
+
return { actor, signal: this.abort.signal, requestId: newId(), now: now() }
|
|
166
83
|
}
|
|
167
84
|
private serialize<T>(action: () => Promise<T>): Promise<T> {
|
|
168
85
|
const result = this.queue.then(action)
|
|
@@ -189,9 +106,12 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
|
|
|
189
106
|
if (!controller) return
|
|
190
107
|
try {
|
|
191
108
|
// Bound an unread subscriber rather than retaining an unbounded transcript in memory.
|
|
192
|
-
if (controller.desiredSize !== null && controller.desiredSize <= 0)
|
|
109
|
+
if (controller.desiredSize !== null && controller.desiredSize <= 0)
|
|
110
|
+
throw new Error('Subscriber stalled')
|
|
193
111
|
controller.enqueue(
|
|
194
|
-
encoder.encode(
|
|
112
|
+
encoder.encode(
|
|
113
|
+
`event: run_event\nid: ${++this.sequence}\ndata: ${JSON.stringify(event)}\n\n`,
|
|
114
|
+
),
|
|
195
115
|
)
|
|
196
116
|
} catch {
|
|
197
117
|
this.close()
|
|
@@ -276,7 +196,7 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
|
|
|
276
196
|
turnId: record.snapshot.turnId,
|
|
277
197
|
status: snapshot.status,
|
|
278
198
|
cancelRequested: record.snapshot.cancelRequested || snapshot.cancelRequested,
|
|
279
|
-
...(snapshot.error ? { error: publicError(snapshot.error) } : {}),
|
|
199
|
+
...(snapshot.error ? { error: publicError(snapshot.error, newId) } : {}),
|
|
280
200
|
...(snapshot.exhausted === undefined ? {} : { exhausted: snapshot.exhausted }),
|
|
281
201
|
}
|
|
282
202
|
record.phase = snapshot.status
|
|
@@ -306,7 +226,7 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
|
|
|
306
226
|
throw recoveryError
|
|
307
227
|
}
|
|
308
228
|
}
|
|
309
|
-
await this.finish({ ...record.snapshot, status: 'failed', error: publicError(error) })
|
|
229
|
+
await this.finish({ ...record.snapshot, status: 'failed', error: publicError(error, newId) })
|
|
310
230
|
}
|
|
311
231
|
private async run(prepared: PreparedExecution, execute: boolean): Promise<void> {
|
|
312
232
|
try {
|
|
@@ -369,7 +289,13 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
|
|
|
369
289
|
revision: 0,
|
|
370
290
|
pending: null,
|
|
371
291
|
acceptedUntil: null,
|
|
372
|
-
snapshot: {
|
|
292
|
+
snapshot: {
|
|
293
|
+
runId,
|
|
294
|
+
conversationId: null,
|
|
295
|
+
turnId: null,
|
|
296
|
+
status: 'running',
|
|
297
|
+
cancelRequested: false,
|
|
298
|
+
},
|
|
373
299
|
}
|
|
374
300
|
await this.persist()
|
|
375
301
|
})
|
|
@@ -383,7 +309,8 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
|
|
|
383
309
|
try {
|
|
384
310
|
const prepared = await this.runtime.prepare(runId, input, this.context(actor), (handle) =>
|
|
385
311
|
this.serialize(async () => {
|
|
386
|
-
if (!this.metadata || this.abort.signal.aborted)
|
|
312
|
+
if (!this.metadata || this.abort.signal.aborted)
|
|
313
|
+
throw new KitFailure('cancelled', 'Cancelled')
|
|
387
314
|
this.metadata.handle = handle
|
|
388
315
|
this.metadata.snapshot.conversationId = handle?.conversationId ?? null
|
|
389
316
|
this.metadata.snapshot.turnId = handle?.turnId ?? null
|
|
@@ -401,7 +328,7 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
|
|
|
401
328
|
this.metadata.phase = 'accepted'
|
|
402
329
|
this.metadata.snapshot.conversationId = prepared.accepted.conversationId
|
|
403
330
|
this.metadata.snapshot.turnId = prepared.accepted.turnId
|
|
404
|
-
this.metadata.acceptedUntil =
|
|
331
|
+
this.metadata.acceptedUntil = now() + 30000
|
|
405
332
|
await this.state.storage.setAlarm(this.metadata.acceptedUntil)
|
|
406
333
|
await this.persist()
|
|
407
334
|
})
|
|
@@ -477,7 +404,8 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
|
|
|
477
404
|
// A rejected competing subscriber must never cancel the actual owner stream.
|
|
478
405
|
if (
|
|
479
406
|
this.owns(actor) &&
|
|
480
|
-
((this.subscribed && !this.operation) ||
|
|
407
|
+
((this.subscribed && !this.operation) ||
|
|
408
|
+
(this.metadata?.phase === 'accepted' && request.signal.aborted))
|
|
481
409
|
) {
|
|
482
410
|
this.close()
|
|
483
411
|
await this.cancelRun()
|
|
@@ -540,7 +468,7 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
|
|
|
540
468
|
}
|
|
541
469
|
throw missing()
|
|
542
470
|
} catch (error) {
|
|
543
|
-
const safe = publicError(error)
|
|
471
|
+
const safe = publicError(error, newId)
|
|
544
472
|
return Response.json(safe, { status: statusCode(safe) })
|
|
545
473
|
}
|
|
546
474
|
}
|
|
@@ -548,7 +476,7 @@ export function createRunCoordinator<Env>(createRuntime: (env: Env) => Runtime)
|
|
|
548
476
|
await this.ready
|
|
549
477
|
const record = this.metadata
|
|
550
478
|
if (record?.phase === 'accepted' && record.acceptedUntil !== null) {
|
|
551
|
-
if (
|
|
479
|
+
if (now() >= record.acceptedUntil) await this.cancelRun()
|
|
552
480
|
else
|
|
553
481
|
await this.serialize(async () => {
|
|
554
482
|
if (this.metadata?.phase === 'accepted' && this.metadata.acceptedUntil !== null)
|
package/src/credentials.ts
CHANGED
|
@@ -28,7 +28,8 @@ function base64(bytes: Uint8Array): string {
|
|
|
28
28
|
return btoa(text)
|
|
29
29
|
}
|
|
30
30
|
function unbase64(text: string): Uint8Array<ArrayBuffer> {
|
|
31
|
-
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(text))
|
|
31
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(text))
|
|
32
|
+
throw unreadable()
|
|
32
33
|
const bytes = Uint8Array.from(atob(text), (character) => character.charCodeAt(0))
|
|
33
34
|
if (base64(bytes) !== text) throw unreadable()
|
|
34
35
|
return bytes
|
|
@@ -64,7 +65,15 @@ function aad(scope: CredentialScope, keyId: string): Uint8Array<ArrayBuffer> {
|
|
|
64
65
|
)
|
|
65
66
|
throw badRequest('credentialScope', 'invalid')
|
|
66
67
|
return new TextEncoder().encode(
|
|
67
|
-
JSON.stringify([
|
|
68
|
+
JSON.stringify([
|
|
69
|
+
1,
|
|
70
|
+
keyId,
|
|
71
|
+
scope.applicationId,
|
|
72
|
+
scope.tenantId,
|
|
73
|
+
scope.subjectId,
|
|
74
|
+
scope.purpose,
|
|
75
|
+
scope.recordId,
|
|
76
|
+
]),
|
|
68
77
|
)
|
|
69
78
|
}
|
|
70
79
|
/** Supply non-extractable AES-GCM keys from deployment secrets/KMS; old keys are read-only until explicit rotation. */
|
|
@@ -100,7 +109,12 @@ export function createCredentialPort(options: {
|
|
|
100
109
|
key,
|
|
101
110
|
new Uint8Array(value),
|
|
102
111
|
)
|
|
103
|
-
return {
|
|
112
|
+
return {
|
|
113
|
+
version: 1,
|
|
114
|
+
keyId: currentKeyId,
|
|
115
|
+
nonce: base64(nonce),
|
|
116
|
+
ciphertext: base64(new Uint8Array(ciphertext)),
|
|
117
|
+
}
|
|
104
118
|
} catch {
|
|
105
119
|
throw unreadable()
|
|
106
120
|
}
|
|
@@ -112,7 +126,11 @@ export function createCredentialPort(options: {
|
|
|
112
126
|
if (!key) throw unreadable()
|
|
113
127
|
const nonce = unbase64(sealed.nonce)
|
|
114
128
|
const ciphertext = unbase64(sealed.ciphertext)
|
|
115
|
-
if (
|
|
129
|
+
if (
|
|
130
|
+
nonce.byteLength !== 12 ||
|
|
131
|
+
ciphertext.byteLength < 16 ||
|
|
132
|
+
ciphertext.byteLength > MAX_BYTES + 16
|
|
133
|
+
)
|
|
116
134
|
throw unreadable()
|
|
117
135
|
return new Uint8Array(
|
|
118
136
|
await crypto.subtle.decrypt(
|
package/src/files.ts
CHANGED
|
@@ -9,7 +9,11 @@ export interface StoredFile {
|
|
|
9
9
|
readonly data: Uint8Array
|
|
10
10
|
}
|
|
11
11
|
export interface FileStore {
|
|
12
|
-
put(
|
|
12
|
+
put(
|
|
13
|
+
actor: Actor,
|
|
14
|
+
id: string,
|
|
15
|
+
file: { readonly mime: string; readonly data: Uint8Array },
|
|
16
|
+
): Promise<StoredFile>
|
|
13
17
|
get(actor: Actor, id: string): Promise<StoredFile | null>
|
|
14
18
|
remove(actor: Actor, id: string): Promise<void>
|
|
15
19
|
/** An authenticated application route, never an R2 public/presigned secret URL. */
|
|
@@ -55,18 +59,26 @@ export class R2FileStore implements FileStore {
|
|
|
55
59
|
readonly #bucket: R2Bucket
|
|
56
60
|
readonly #maxBytes: number
|
|
57
61
|
constructor(bucket: R2Bucket, options: { readonly maxBytes: number }) {
|
|
58
|
-
if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0)
|
|
62
|
+
if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0)
|
|
63
|
+
throw badRequest('fileLimit', 'invalid')
|
|
59
64
|
this.#bucket = bucket
|
|
60
65
|
this.#maxBytes = options.maxBytes
|
|
61
66
|
}
|
|
62
67
|
async #key(actor: Actor, id: string): Promise<string> {
|
|
63
68
|
fileId(id)
|
|
64
69
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ownerKey(actor)))
|
|
65
|
-
const ownerHash = Array.from(new Uint8Array(digest), (byte) =>
|
|
70
|
+
const ownerHash = Array.from(new Uint8Array(digest), (byte) =>
|
|
71
|
+
byte.toString(16).padStart(2, '0'),
|
|
72
|
+
).join('')
|
|
66
73
|
return `aether/files/v1/${ownerHash}/${id}`
|
|
67
74
|
}
|
|
68
|
-
async put(
|
|
69
|
-
|
|
75
|
+
async put(
|
|
76
|
+
actor: Actor,
|
|
77
|
+
id: string,
|
|
78
|
+
file: { readonly mime: string; readonly data: Uint8Array },
|
|
79
|
+
): Promise<StoredFile> {
|
|
80
|
+
if (!MIME_TYPES[file.mime] || !matchesMime(file.data, file.mime))
|
|
81
|
+
throw badRequest('mime', 'unsupported')
|
|
70
82
|
if (file.data.byteLength === 0 || file.data.byteLength > this.#maxBytes)
|
|
71
83
|
throw badRequest('file', 'too_large', this.#maxBytes)
|
|
72
84
|
const key = await this.#key(actor, id)
|
package/src/host.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import type { Approval, ModelRef, PermissionMode, RunRequest } from '@ouraihub/aether-contracts'
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AuthorizeContext,
|
|
4
|
+
ToolCall,
|
|
5
|
+
ToolExecution,
|
|
6
|
+
ToolSpec,
|
|
7
|
+
} from '@ouraihub/aether-core/types'
|
|
3
8
|
import type { WorkerContext } from './context.js'
|
|
4
9
|
|
|
5
10
|
/** Business authorization remains the host's responsibility for every actor and resource. */
|
|
6
11
|
export interface WorkerHost {
|
|
7
12
|
selectModel(request: RunRequest, context: WorkerContext): Promise<ModelRef>
|
|
8
13
|
systemPrompt(request: RunRequest, context: WorkerContext): Promise<string | null>
|
|
9
|
-
context(
|
|
14
|
+
context(
|
|
15
|
+
request: RunRequest,
|
|
16
|
+
context: WorkerContext,
|
|
17
|
+
): Promise<{ source: string; text: string } | null>
|
|
10
18
|
tools(request: RunRequest, runId: string, context: WorkerContext): Promise<readonly ToolSpec[]>
|
|
11
19
|
permissionModes(request: RunRequest, context: WorkerContext): readonly PermissionMode[]
|
|
12
20
|
authorize(call: ToolCall, authorization: AuthorizeContext, context: WorkerContext): Approval
|
package/src/identity.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { badRequest, KitFailure } from '@ouraihub/aether-core'
|
|
2
2
|
import { immutableActor, isActor, ownerKey, type Actor } from './context.ts'
|
|
3
|
+
import { retryCas } from './storage.ts'
|
|
3
4
|
import type { DocumentStore, StoredDocument } from './storage.ts'
|
|
4
5
|
|
|
5
6
|
export interface IdentityPort {
|
|
@@ -12,11 +13,14 @@ export function createIdentityPort(options: {
|
|
|
12
13
|
readonly resolve: (request: Request) => Promise<Actor | null>
|
|
13
14
|
readonly verifyFresh: (request: Request, actor: Actor) => Promise<boolean>
|
|
14
15
|
}): IdentityPort {
|
|
15
|
-
if (!options.applicationId)
|
|
16
|
+
if (!options.applicationId)
|
|
17
|
+
throw new KitFailure('needs_config', 'Identity application is required')
|
|
16
18
|
return Object.freeze({
|
|
17
19
|
async resolve(request: Request): Promise<Actor | null> {
|
|
18
20
|
const actor = await options.resolve(request)
|
|
19
|
-
return isActor(actor) && actor.applicationId === options.applicationId
|
|
21
|
+
return isActor(actor) && actor.applicationId === options.applicationId
|
|
22
|
+
? immutableActor(actor)
|
|
23
|
+
: null
|
|
20
24
|
},
|
|
21
25
|
async verifyFresh(request: Request, actor: Actor): Promise<boolean> {
|
|
22
26
|
if (!isActor(actor) || actor.applicationId !== options.applicationId) return false
|
|
@@ -61,10 +65,17 @@ function parseSession(value: unknown): SessionRecord | null {
|
|
|
61
65
|
value.expiresAt <= value.lastSeenAt
|
|
62
66
|
)
|
|
63
67
|
return null
|
|
64
|
-
return {
|
|
68
|
+
return {
|
|
69
|
+
actor: value.actor,
|
|
70
|
+
createdAt: value.createdAt,
|
|
71
|
+
lastSeenAt: value.lastSeenAt,
|
|
72
|
+
expiresAt: value.expiresAt,
|
|
73
|
+
}
|
|
65
74
|
}
|
|
66
75
|
function opaqueToken(): string {
|
|
67
|
-
return Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) =>
|
|
76
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) =>
|
|
77
|
+
byte.toString(16).padStart(2, '0'),
|
|
78
|
+
).join('')
|
|
68
79
|
}
|
|
69
80
|
async function tokenKey(token: string): Promise<string | null> {
|
|
70
81
|
if (!/^[a-f0-9]{64}$/u.test(token)) return null
|
|
@@ -106,8 +117,14 @@ export class OpaqueSessionStore {
|
|
|
106
117
|
this.#absoluteMs = options.absoluteMs
|
|
107
118
|
this.#now = options.now ?? Date.now
|
|
108
119
|
}
|
|
109
|
-
async issue(
|
|
110
|
-
|
|
120
|
+
async issue(
|
|
121
|
+
actor: Actor,
|
|
122
|
+
): Promise<{ readonly token: string; readonly actor: Actor; readonly expiresAt: number }> {
|
|
123
|
+
if (
|
|
124
|
+
!isActor(actor) ||
|
|
125
|
+
actor.kind !== 'user' ||
|
|
126
|
+
actor.applicationId !== this.#registry.applicationId
|
|
127
|
+
)
|
|
111
128
|
throw badRequest('actor', 'invalid_session_owner')
|
|
112
129
|
const token = opaqueToken()
|
|
113
130
|
const key = await tokenKey(token)
|
|
@@ -123,9 +140,11 @@ export class OpaqueSessionStore {
|
|
|
123
140
|
})
|
|
124
141
|
return { token, actor: sessionActor, expiresAt }
|
|
125
142
|
}
|
|
126
|
-
async #load(
|
|
127
|
-
|
|
128
|
-
|
|
143
|
+
async #load(token: string): Promise<{
|
|
144
|
+
readonly key: string
|
|
145
|
+
readonly document: StoredDocument
|
|
146
|
+
readonly session: SessionRecord
|
|
147
|
+
} | null> {
|
|
129
148
|
const key = await tokenKey(token)
|
|
130
149
|
if (key === null) return null
|
|
131
150
|
const document = await this.#store.get(this.#registry, 'opaque_sessions', key)
|
|
@@ -146,10 +165,16 @@ export class OpaqueSessionStore {
|
|
|
146
165
|
const found = await this.#load(token)
|
|
147
166
|
if (!found) return null
|
|
148
167
|
try {
|
|
149
|
-
await this.#store.compareAndSet(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
168
|
+
await this.#store.compareAndSet(
|
|
169
|
+
this.#registry,
|
|
170
|
+
'opaque_sessions',
|
|
171
|
+
found.key,
|
|
172
|
+
found.document.revision,
|
|
173
|
+
{
|
|
174
|
+
...found.session,
|
|
175
|
+
lastSeenAt: this.#now(),
|
|
176
|
+
},
|
|
177
|
+
)
|
|
153
178
|
return immutableActor(found.session.actor)
|
|
154
179
|
} catch (error) {
|
|
155
180
|
if (error instanceof KitFailure && error.code === 'conflict') return null
|
|
@@ -160,17 +185,16 @@ export class OpaqueSessionStore {
|
|
|
160
185
|
const key = await tokenKey(token)
|
|
161
186
|
if (key === null) return
|
|
162
187
|
// Retry only metadata CAS, never authentication or external side effects.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
throw new KitFailure('conflict', 'Session revocation conflicted')
|
|
188
|
+
await retryCas(
|
|
189
|
+
async (cas) => {
|
|
190
|
+
const document = await this.#store.get(this.#registry, 'opaque_sessions', key)
|
|
191
|
+
if (document)
|
|
192
|
+
await cas(() =>
|
|
193
|
+
this.#store.remove(this.#registry, 'opaque_sessions', key, document.revision),
|
|
194
|
+
)
|
|
195
|
+
},
|
|
196
|
+
() => new KitFailure('conflict', 'Session revocation conflicted'),
|
|
197
|
+
)
|
|
174
198
|
}
|
|
175
199
|
async rotate(
|
|
176
200
|
token: string,
|
|
@@ -178,7 +202,12 @@ export class OpaqueSessionStore {
|
|
|
178
202
|
const found = await this.#load(token)
|
|
179
203
|
if (!found) return null
|
|
180
204
|
try {
|
|
181
|
-
await this.#store.remove(
|
|
205
|
+
await this.#store.remove(
|
|
206
|
+
this.#registry,
|
|
207
|
+
'opaque_sessions',
|
|
208
|
+
found.key,
|
|
209
|
+
found.document.revision,
|
|
210
|
+
)
|
|
182
211
|
} catch (error) {
|
|
183
212
|
if (error instanceof KitFailure && error.code === 'conflict') return null
|
|
184
213
|
throw error
|