clanka 0.0.1
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/README.md +3 -0
- package/dist/Agent.d.ts +119 -0
- package/dist/Agent.d.ts.map +1 -0
- package/dist/Agent.js +240 -0
- package/dist/Agent.js.map +1 -0
- package/dist/AgentTools.d.ts +246 -0
- package/dist/AgentTools.d.ts.map +1 -0
- package/dist/AgentTools.js +374 -0
- package/dist/AgentTools.js.map +1 -0
- package/dist/AgentTools.test.d.ts +2 -0
- package/dist/AgentTools.test.d.ts.map +1 -0
- package/dist/AgentTools.test.js +147 -0
- package/dist/AgentTools.test.js.map +1 -0
- package/dist/ApplyPatch.d.ts +27 -0
- package/dist/ApplyPatch.d.ts.map +1 -0
- package/dist/ApplyPatch.js +343 -0
- package/dist/ApplyPatch.js.map +1 -0
- package/dist/ApplyPatch.test.d.ts +2 -0
- package/dist/ApplyPatch.test.d.ts.map +1 -0
- package/dist/ApplyPatch.test.js +99 -0
- package/dist/ApplyPatch.test.js.map +1 -0
- package/dist/Codex.d.ts +11 -0
- package/dist/Codex.d.ts.map +1 -0
- package/dist/Codex.js +14 -0
- package/dist/Codex.js.map +1 -0
- package/dist/CodexAuth.d.ts +68 -0
- package/dist/CodexAuth.d.ts.map +1 -0
- package/dist/CodexAuth.js +270 -0
- package/dist/CodexAuth.js.map +1 -0
- package/dist/CodexAuth.test.d.ts +2 -0
- package/dist/CodexAuth.test.d.ts.map +1 -0
- package/dist/CodexAuth.test.js +425 -0
- package/dist/CodexAuth.test.js.map +1 -0
- package/dist/Executor.d.ts +20 -0
- package/dist/Executor.d.ts.map +1 -0
- package/dist/Executor.js +76 -0
- package/dist/Executor.js.map +1 -0
- package/dist/OutputFormatter.d.ts +11 -0
- package/dist/OutputFormatter.d.ts.map +1 -0
- package/dist/OutputFormatter.js +5 -0
- package/dist/OutputFormatter.js.map +1 -0
- package/dist/ToolkitRenderer.d.ts +17 -0
- package/dist/ToolkitRenderer.d.ts.map +1 -0
- package/dist/ToolkitRenderer.js +25 -0
- package/dist/ToolkitRenderer.js.map +1 -0
- package/dist/TypeBuilder.d.ts +11 -0
- package/dist/TypeBuilder.d.ts.map +1 -0
- package/dist/TypeBuilder.js +383 -0
- package/dist/TypeBuilder.js.map +1 -0
- package/dist/TypeBuilder.test.d.ts +2 -0
- package/dist/TypeBuilder.test.d.ts.map +1 -0
- package/dist/TypeBuilder.test.js +243 -0
- package/dist/TypeBuilder.test.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
- package/src/Agent.ts +398 -0
- package/src/AgentTools.test.ts +215 -0
- package/src/AgentTools.ts +507 -0
- package/src/ApplyPatch.test.ts +154 -0
- package/src/ApplyPatch.ts +473 -0
- package/src/Codex.ts +14 -0
- package/src/CodexAuth.test.ts +729 -0
- package/src/CodexAuth.ts +571 -0
- package/src/Executor.ts +129 -0
- package/src/OutputFormatter.ts +17 -0
- package/src/ToolkitRenderer.ts +39 -0
- package/src/TypeBuilder.test.ts +508 -0
- package/src/TypeBuilder.ts +670 -0
- package/src/index.ts +29 -0
package/src/CodexAuth.ts
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @since 1.0.0
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
Console,
|
|
6
|
+
Effect,
|
|
7
|
+
Encoding,
|
|
8
|
+
flow,
|
|
9
|
+
Layer,
|
|
10
|
+
Option,
|
|
11
|
+
Result,
|
|
12
|
+
Schedule,
|
|
13
|
+
Schema,
|
|
14
|
+
Semaphore,
|
|
15
|
+
ServiceMap,
|
|
16
|
+
} from "effect"
|
|
17
|
+
import {
|
|
18
|
+
HttpClient,
|
|
19
|
+
HttpClientRequest,
|
|
20
|
+
HttpClientResponse,
|
|
21
|
+
} from "effect/unstable/http"
|
|
22
|
+
import { KeyValueStore } from "effect/unstable/persistence"
|
|
23
|
+
|
|
24
|
+
export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
25
|
+
export const ISSUER = "https://auth.openai.com"
|
|
26
|
+
export const POLLING_SAFETY_MARGIN_MS = 3000
|
|
27
|
+
export const TOKEN_EXPIRY_BUFFER_MS = 30_000
|
|
28
|
+
export const STORE_PREFIX = "codex.auth/"
|
|
29
|
+
export const STORE_TOKEN_KEY = "token"
|
|
30
|
+
|
|
31
|
+
const DEVICE_CODE_URL = `/api/accounts/deviceauth/usercode`
|
|
32
|
+
const DEVICE_TOKEN_URL = `/api/accounts/deviceauth/token`
|
|
33
|
+
const TOKEN_URL = `/oauth/token`
|
|
34
|
+
const DEVICE_REDIRECT_URI = `${ISSUER}/deviceauth/callback`
|
|
35
|
+
const DEVICE_VERIFICATION_URL = `/codex/device`
|
|
36
|
+
const DEFAULT_DEVICE_POLL_INTERVAL_SECONDS = 5
|
|
37
|
+
const DEFAULT_TOKEN_EXPIRY_SECONDS = 3600
|
|
38
|
+
const ACCOUNT_ID_HEADER = "ChatGPT-Account-Id"
|
|
39
|
+
|
|
40
|
+
export class TokenData extends Schema.Class<TokenData>(
|
|
41
|
+
"clanka/CodexAuth/TokenData",
|
|
42
|
+
)({
|
|
43
|
+
access: Schema.String,
|
|
44
|
+
refresh: Schema.String,
|
|
45
|
+
expires: Schema.Number,
|
|
46
|
+
accountId: Schema.OptionFromOptional(Schema.String),
|
|
47
|
+
}) {
|
|
48
|
+
isExpired(): boolean {
|
|
49
|
+
return this.expires < Date.now() + TOKEN_EXPIRY_BUFFER_MS
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class CodexAuthError extends Schema.TaggedErrorClass<CodexAuthError>()(
|
|
54
|
+
"CodexAuthError",
|
|
55
|
+
{
|
|
56
|
+
reason: Schema.Literals([
|
|
57
|
+
"DeviceFlowFailed",
|
|
58
|
+
"TokenExchangeFailed",
|
|
59
|
+
"RefreshFailed",
|
|
60
|
+
]),
|
|
61
|
+
message: Schema.String,
|
|
62
|
+
cause: Schema.optional(Schema.Defect),
|
|
63
|
+
},
|
|
64
|
+
) {}
|
|
65
|
+
|
|
66
|
+
const DeviceCodeResponseSchema = Schema.Struct({
|
|
67
|
+
device_auth_id: Schema.String,
|
|
68
|
+
user_code: Schema.String,
|
|
69
|
+
interval: Schema.String,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const AuthorizationCodeResponseSchema = Schema.Struct({
|
|
73
|
+
authorization_code: Schema.String,
|
|
74
|
+
code_verifier: Schema.String,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const TokenResponseSchema = Schema.Struct({
|
|
78
|
+
id_token: Schema.optional(Schema.String),
|
|
79
|
+
access_token: Schema.String,
|
|
80
|
+
refresh_token: Schema.String,
|
|
81
|
+
expires_in: Schema.optional(Schema.Number),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
type TokenResponse = typeof TokenResponseSchema.Type
|
|
85
|
+
|
|
86
|
+
export interface DeviceCodeData {
|
|
87
|
+
readonly deviceAuthId: string
|
|
88
|
+
readonly userCode: string
|
|
89
|
+
readonly intervalMs: number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface AuthorizationCodeData {
|
|
93
|
+
readonly authorizationCode: string
|
|
94
|
+
readonly codeVerifier: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface JwtClaims {
|
|
98
|
+
readonly chatgpt_account_id?: string
|
|
99
|
+
readonly "https://api.openai.com/auth"?: {
|
|
100
|
+
readonly chatgpt_account_id?: string
|
|
101
|
+
}
|
|
102
|
+
readonly organizations?: ReadonlyArray<{
|
|
103
|
+
readonly id: string
|
|
104
|
+
}>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const decodeJwtJson = Schema.decodeUnknownOption(
|
|
108
|
+
Schema.fromJsonString(Schema.Unknown),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
112
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
113
|
+
|
|
114
|
+
const getString = (value: unknown): string | undefined =>
|
|
115
|
+
typeof value === "string" ? value : undefined
|
|
116
|
+
|
|
117
|
+
const toJwtClaims = (value: unknown): Option.Option<JwtClaims> => {
|
|
118
|
+
if (!isRecord(value)) {
|
|
119
|
+
return Option.none()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const accountId = getString(value["chatgpt_account_id"])
|
|
123
|
+
const authValue = value["https://api.openai.com/auth"]
|
|
124
|
+
const nestedAccountId = isRecord(authValue)
|
|
125
|
+
? getString(authValue["chatgpt_account_id"])
|
|
126
|
+
: undefined
|
|
127
|
+
const organizationsValue = value["organizations"]
|
|
128
|
+
const organizationId =
|
|
129
|
+
Array.isArray(organizationsValue) &&
|
|
130
|
+
organizationsValue[0] !== undefined &&
|
|
131
|
+
isRecord(organizationsValue[0])
|
|
132
|
+
? getString(organizationsValue[0]["id"])
|
|
133
|
+
: undefined
|
|
134
|
+
|
|
135
|
+
return Option.some({
|
|
136
|
+
...(accountId === undefined ? {} : { chatgpt_account_id: accountId }),
|
|
137
|
+
...(nestedAccountId === undefined
|
|
138
|
+
? {}
|
|
139
|
+
: {
|
|
140
|
+
"https://api.openai.com/auth": {
|
|
141
|
+
chatgpt_account_id: nestedAccountId,
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
...(organizationId === undefined
|
|
145
|
+
? {}
|
|
146
|
+
: {
|
|
147
|
+
organizations: [{ id: organizationId }],
|
|
148
|
+
}),
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const decodeJwtPayload = (token: string): Option.Option<string> => {
|
|
153
|
+
const parts = token.split(".")
|
|
154
|
+
if (parts.length !== 3) {
|
|
155
|
+
return Option.none()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const payload = parts[1]
|
|
159
|
+
if (payload === undefined) {
|
|
160
|
+
return Option.none()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return Option.fromNullishOr(
|
|
164
|
+
Result.getOrUndefined(Encoding.decodeBase64UrlString(payload)),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const parseJwtClaims = (token: string): Option.Option<JwtClaims> =>
|
|
169
|
+
decodeJwtPayload(token).pipe(
|
|
170
|
+
Option.flatMap(decodeJwtJson),
|
|
171
|
+
Option.flatMap(toJwtClaims),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
export const extractAccountIdFromClaims = (
|
|
175
|
+
claims: JwtClaims,
|
|
176
|
+
): Option.Option<string> => {
|
|
177
|
+
if (
|
|
178
|
+
claims.chatgpt_account_id !== undefined &&
|
|
179
|
+
claims.chatgpt_account_id !== ""
|
|
180
|
+
) {
|
|
181
|
+
return Option.some(claims.chatgpt_account_id)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const nestedAccountId =
|
|
185
|
+
claims["https://api.openai.com/auth"]?.chatgpt_account_id
|
|
186
|
+
if (nestedAccountId !== undefined && nestedAccountId !== "") {
|
|
187
|
+
return Option.some(nestedAccountId)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const organizationId = claims.organizations?.[0]?.id
|
|
191
|
+
if (organizationId !== undefined && organizationId !== "") {
|
|
192
|
+
return Option.some(organizationId)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return Option.none()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const extractAccountIdFromToken = (
|
|
199
|
+
token: string,
|
|
200
|
+
): Option.Option<string> =>
|
|
201
|
+
parseJwtClaims(token).pipe(Option.flatMap(extractAccountIdFromClaims))
|
|
202
|
+
|
|
203
|
+
const normalizePollInterval = (interval: string): number =>
|
|
204
|
+
Math.max(
|
|
205
|
+
Number.parseInt(interval, 10) || DEFAULT_DEVICE_POLL_INTERVAL_SECONDS,
|
|
206
|
+
1,
|
|
207
|
+
) * 1_000
|
|
208
|
+
|
|
209
|
+
const extractAccountIdFromTokens = (
|
|
210
|
+
token: TokenResponse,
|
|
211
|
+
): Option.Option<string> => {
|
|
212
|
+
if (token.id_token !== undefined && token.id_token !== "") {
|
|
213
|
+
const accountId = extractAccountIdFromToken(token.id_token)
|
|
214
|
+
if (Option.isSome(accountId)) {
|
|
215
|
+
return accountId
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return extractAccountIdFromToken(token.access_token)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const applyTokenHeaders = (
|
|
223
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
224
|
+
token: TokenData,
|
|
225
|
+
): HttpClientRequest.HttpClientRequest => {
|
|
226
|
+
const authenticatedRequest = request.pipe(
|
|
227
|
+
HttpClientRequest.bearerToken(token.access),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return Option.match(token.accountId, {
|
|
231
|
+
onNone: () => authenticatedRequest,
|
|
232
|
+
onSome: (accountId) =>
|
|
233
|
+
authenticatedRequest.pipe(
|
|
234
|
+
HttpClientRequest.setHeader(ACCOUNT_ID_HEADER, accountId),
|
|
235
|
+
),
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const toTokenDataFromResponse = (token: TokenResponse): TokenData =>
|
|
240
|
+
new TokenData({
|
|
241
|
+
access: token.access_token,
|
|
242
|
+
refresh: token.refresh_token,
|
|
243
|
+
expires:
|
|
244
|
+
Date.now() + (token.expires_in ?? DEFAULT_TOKEN_EXPIRY_SECONDS) * 1_000,
|
|
245
|
+
accountId: extractAccountIdFromTokens(token),
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const preserveAccountId = (
|
|
249
|
+
token: TokenData,
|
|
250
|
+
fallback: Option.Option<string>,
|
|
251
|
+
): TokenData => {
|
|
252
|
+
if (Option.isSome(token.accountId) || Option.isNone(fallback)) {
|
|
253
|
+
return token
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return new TokenData({
|
|
257
|
+
access: token.access,
|
|
258
|
+
refresh: token.refresh,
|
|
259
|
+
expires: token.expires,
|
|
260
|
+
accountId: fallback,
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const requestDeviceCodeError = (message: string, cause?: unknown) =>
|
|
265
|
+
new CodexAuthError({
|
|
266
|
+
reason: "DeviceFlowFailed",
|
|
267
|
+
message,
|
|
268
|
+
...(cause === undefined ? {} : { cause }),
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
const tokenExchangeError = (message: string, cause?: unknown) =>
|
|
272
|
+
new CodexAuthError({
|
|
273
|
+
reason: "TokenExchangeFailed",
|
|
274
|
+
message,
|
|
275
|
+
...(cause === undefined ? {} : { cause }),
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const refreshTokenError = (message: string, cause?: unknown) =>
|
|
279
|
+
new CodexAuthError({
|
|
280
|
+
reason: "RefreshFailed",
|
|
281
|
+
message,
|
|
282
|
+
...(cause === undefined ? {} : { cause }),
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
export const toCodexAuthKeyValueStore = (store: KeyValueStore.KeyValueStore) =>
|
|
286
|
+
KeyValueStore.prefix(store, STORE_PREFIX)
|
|
287
|
+
|
|
288
|
+
export const toTokenStore = (store: KeyValueStore.KeyValueStore) =>
|
|
289
|
+
KeyValueStore.toSchemaStore(toCodexAuthKeyValueStore(store), TokenData)
|
|
290
|
+
|
|
291
|
+
export class CodexAuth extends ServiceMap.Service<
|
|
292
|
+
CodexAuth,
|
|
293
|
+
{
|
|
294
|
+
readonly get: Effect.Effect<TokenData, CodexAuthError>
|
|
295
|
+
readonly authenticate: Effect.Effect<TokenData, CodexAuthError>
|
|
296
|
+
readonly logout: Effect.Effect<void>
|
|
297
|
+
}
|
|
298
|
+
>()("clanka/CodexAuth") {
|
|
299
|
+
static readonly make = Effect.gen(function* () {
|
|
300
|
+
const tokenStore = toTokenStore(yield* KeyValueStore.KeyValueStore)
|
|
301
|
+
const httpClient = (yield* HttpClient.HttpClient).pipe(
|
|
302
|
+
HttpClient.mapRequest(flow(HttpClientRequest.prependUrl(ISSUER))),
|
|
303
|
+
HttpClient.filterStatusOk,
|
|
304
|
+
HttpClient.retryTransient({
|
|
305
|
+
times: 5,
|
|
306
|
+
schedule: Schedule.exponential(150).pipe(
|
|
307
|
+
Schedule.either(Schedule.spaced(5000)),
|
|
308
|
+
),
|
|
309
|
+
}),
|
|
310
|
+
)
|
|
311
|
+
const semaphore = Semaphore.makeUnsafe(1)
|
|
312
|
+
|
|
313
|
+
let currentToken = yield* tokenStore.get(STORE_TOKEN_KEY).pipe(
|
|
314
|
+
Effect.catchTag("SchemaError", (error) =>
|
|
315
|
+
Console.warn(
|
|
316
|
+
`Failed to decode persisted Codex token, clearing it: ${error.message}`,
|
|
317
|
+
).pipe(
|
|
318
|
+
Effect.andThen(tokenStore.remove(STORE_TOKEN_KEY)),
|
|
319
|
+
Effect.as(Option.none()),
|
|
320
|
+
),
|
|
321
|
+
),
|
|
322
|
+
Effect.orDie,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
const saveToken = (token: TokenData) =>
|
|
326
|
+
Effect.orDie(tokenStore.set(STORE_TOKEN_KEY, token)).pipe(
|
|
327
|
+
Effect.tap(() =>
|
|
328
|
+
Effect.sync(() => {
|
|
329
|
+
currentToken = Option.some(token)
|
|
330
|
+
}),
|
|
331
|
+
),
|
|
332
|
+
Effect.as(token),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
const clearToken = Effect.orDie(tokenStore.remove(STORE_TOKEN_KEY)).pipe(
|
|
336
|
+
Effect.tap(() =>
|
|
337
|
+
Effect.sync(() => {
|
|
338
|
+
currentToken = Option.none()
|
|
339
|
+
}),
|
|
340
|
+
),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
const authenticateWithDeviceFlow = Effect.gen(function* () {
|
|
344
|
+
const deviceCode = yield* requestDeviceCode
|
|
345
|
+
yield* Console.log(
|
|
346
|
+
`Open ${ISSUER}${DEVICE_VERIFICATION_URL} and enter code: ${deviceCode.userCode}`,
|
|
347
|
+
)
|
|
348
|
+
const authorization = yield* pollAuthorization(deviceCode)
|
|
349
|
+
return yield* exchangeAuthorizationCode(authorization)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
const authenticateNoLock = Effect.uninterruptibleMask((restore) =>
|
|
353
|
+
Effect.gen(function* () {
|
|
354
|
+
const token = yield* restore(authenticateWithDeviceFlow)
|
|
355
|
+
return yield* saveToken(token)
|
|
356
|
+
}),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
const getNoLock = Effect.uninterruptibleMask((restore) =>
|
|
360
|
+
Effect.gen(function* () {
|
|
361
|
+
if (Option.isSome(currentToken) && !currentToken.value.isExpired()) {
|
|
362
|
+
return currentToken.value
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (Option.isNone(currentToken)) {
|
|
366
|
+
const token = yield* restore(authenticateWithDeviceFlow)
|
|
367
|
+
return yield* saveToken(token)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const refreshedToken = yield* restore(
|
|
371
|
+
refreshToken(currentToken.value.refresh).pipe(
|
|
372
|
+
Effect.tapError((error) =>
|
|
373
|
+
Console.warn(
|
|
374
|
+
`Codex token refresh failed, falling back to device auth: ${error.message}`,
|
|
375
|
+
),
|
|
376
|
+
),
|
|
377
|
+
Effect.option,
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if (Option.isSome(refreshedToken)) {
|
|
382
|
+
return yield* saveToken(
|
|
383
|
+
preserveAccountId(
|
|
384
|
+
refreshedToken.value,
|
|
385
|
+
currentToken.value.accountId,
|
|
386
|
+
),
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
yield* clearToken
|
|
391
|
+
const token = yield* restore(authenticateWithDeviceFlow)
|
|
392
|
+
return yield* saveToken(token)
|
|
393
|
+
}),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
const requestDeviceCode = Effect.gen(function* (): Effect.fn.Return<
|
|
397
|
+
DeviceCodeData,
|
|
398
|
+
CodexAuthError
|
|
399
|
+
> {
|
|
400
|
+
const response = yield* HttpClientRequest.post(DEVICE_CODE_URL).pipe(
|
|
401
|
+
HttpClientRequest.bodyJsonUnsafe({
|
|
402
|
+
client_id: CLIENT_ID,
|
|
403
|
+
}),
|
|
404
|
+
httpClient.execute,
|
|
405
|
+
Effect.mapError((cause) =>
|
|
406
|
+
requestDeviceCodeError(
|
|
407
|
+
"Failed to request a Codex device authorization code",
|
|
408
|
+
cause,
|
|
409
|
+
),
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
const payload = yield* HttpClientResponse.schemaBodyJson(
|
|
414
|
+
DeviceCodeResponseSchema,
|
|
415
|
+
)(response).pipe(
|
|
416
|
+
Effect.mapError((cause) =>
|
|
417
|
+
requestDeviceCodeError(
|
|
418
|
+
"Failed to decode the Codex device authorization response",
|
|
419
|
+
cause,
|
|
420
|
+
),
|
|
421
|
+
),
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
deviceAuthId: payload.device_auth_id,
|
|
426
|
+
userCode: payload.user_code,
|
|
427
|
+
intervalMs: normalizePollInterval(payload.interval),
|
|
428
|
+
}
|
|
429
|
+
}).pipe(Effect.withSpan("CodexAuth.requestDeviceCode"))
|
|
430
|
+
|
|
431
|
+
const pollAuthorization = Effect.fn("CodexAuth.pollAuthorization")(
|
|
432
|
+
function* (
|
|
433
|
+
deviceCode: DeviceCodeData,
|
|
434
|
+
): Effect.fn.Return<AuthorizationCodeData, CodexAuthError> {
|
|
435
|
+
const request = HttpClientRequest.post(DEVICE_TOKEN_URL).pipe(
|
|
436
|
+
HttpClientRequest.bodyJsonUnsafe({
|
|
437
|
+
device_auth_id: deviceCode.deviceAuthId,
|
|
438
|
+
user_code: deviceCode.userCode,
|
|
439
|
+
}),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
const delayMs = deviceCode.intervalMs + POLLING_SAFETY_MARGIN_MS
|
|
443
|
+
|
|
444
|
+
return yield* httpClient.execute(request).pipe(
|
|
445
|
+
Effect.retry({
|
|
446
|
+
while: (e) =>
|
|
447
|
+
e.response?.status === 403 || e.response?.status === 404,
|
|
448
|
+
schedule: Schedule.spaced(delayMs),
|
|
449
|
+
}),
|
|
450
|
+
Effect.mapError((cause) =>
|
|
451
|
+
requestDeviceCodeError(
|
|
452
|
+
"Failed to poll Codex device authorization",
|
|
453
|
+
cause,
|
|
454
|
+
),
|
|
455
|
+
),
|
|
456
|
+
Effect.flatMap((response) =>
|
|
457
|
+
HttpClientResponse.schemaBodyJson(AuthorizationCodeResponseSchema)(
|
|
458
|
+
response,
|
|
459
|
+
).pipe(
|
|
460
|
+
Effect.mapError((cause) =>
|
|
461
|
+
requestDeviceCodeError(
|
|
462
|
+
"Failed to decode the Codex authorization approval response",
|
|
463
|
+
cause,
|
|
464
|
+
),
|
|
465
|
+
),
|
|
466
|
+
Effect.map((payload) => ({
|
|
467
|
+
authorizationCode: payload.authorization_code,
|
|
468
|
+
codeVerifier: payload.code_verifier,
|
|
469
|
+
})),
|
|
470
|
+
),
|
|
471
|
+
),
|
|
472
|
+
)
|
|
473
|
+
},
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
const exchangeAuthorizationCode = Effect.fn(
|
|
477
|
+
"CodexAuth.exchangeAuthorizationCode",
|
|
478
|
+
)(function* (
|
|
479
|
+
authorization: AuthorizationCodeData,
|
|
480
|
+
): Effect.fn.Return<TokenData, CodexAuthError> {
|
|
481
|
+
const response = yield* HttpClientRequest.post(TOKEN_URL).pipe(
|
|
482
|
+
HttpClientRequest.bodyUrlParams({
|
|
483
|
+
grant_type: "authorization_code",
|
|
484
|
+
code: authorization.authorizationCode,
|
|
485
|
+
redirect_uri: DEVICE_REDIRECT_URI,
|
|
486
|
+
client_id: CLIENT_ID,
|
|
487
|
+
code_verifier: authorization.codeVerifier,
|
|
488
|
+
}),
|
|
489
|
+
httpClient.execute,
|
|
490
|
+
Effect.mapError((cause) =>
|
|
491
|
+
tokenExchangeError(
|
|
492
|
+
"Failed to exchange the Codex authorization code",
|
|
493
|
+
cause,
|
|
494
|
+
),
|
|
495
|
+
),
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
const payload = yield* HttpClientResponse.schemaBodyJson(
|
|
499
|
+
TokenResponseSchema,
|
|
500
|
+
)(response).pipe(
|
|
501
|
+
Effect.mapError((cause) =>
|
|
502
|
+
tokenExchangeError(
|
|
503
|
+
"Failed to decode the Codex token exchange response",
|
|
504
|
+
cause,
|
|
505
|
+
),
|
|
506
|
+
),
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
return toTokenDataFromResponse(payload)
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
const refreshToken = Effect.fn("CodexAuth.refreshToken")(function* (
|
|
513
|
+
refresh: string,
|
|
514
|
+
): Effect.fn.Return<TokenData, CodexAuthError> {
|
|
515
|
+
const response = yield* HttpClientRequest.post(TOKEN_URL).pipe(
|
|
516
|
+
HttpClientRequest.bodyUrlParams({
|
|
517
|
+
grant_type: "refresh_token",
|
|
518
|
+
refresh_token: refresh,
|
|
519
|
+
client_id: CLIENT_ID,
|
|
520
|
+
}),
|
|
521
|
+
httpClient.execute,
|
|
522
|
+
Effect.mapError((cause) =>
|
|
523
|
+
refreshTokenError("Failed to refresh the Codex access token", cause),
|
|
524
|
+
),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
const payload = yield* HttpClientResponse.schemaBodyJson(
|
|
528
|
+
TokenResponseSchema,
|
|
529
|
+
)(response).pipe(
|
|
530
|
+
Effect.mapError((cause) =>
|
|
531
|
+
refreshTokenError(
|
|
532
|
+
"Failed to decode the Codex refresh token response",
|
|
533
|
+
cause,
|
|
534
|
+
),
|
|
535
|
+
),
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
return toTokenDataFromResponse(payload)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
return CodexAuth.of({
|
|
542
|
+
get: semaphore.withPermit(getNoLock),
|
|
543
|
+
authenticate: semaphore.withPermit(authenticateNoLock),
|
|
544
|
+
logout: semaphore.withPermit(Effect.uninterruptible(clearToken)),
|
|
545
|
+
})
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
static readonly layer = Layer.effect(CodexAuth, CodexAuth.make)
|
|
549
|
+
|
|
550
|
+
static readonly layerClientNoDeps = Layer.effect(
|
|
551
|
+
HttpClient.HttpClient,
|
|
552
|
+
Effect.gen(function* () {
|
|
553
|
+
const auth = yield* CodexAuth
|
|
554
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
555
|
+
|
|
556
|
+
const injectAuthHeaders = (
|
|
557
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
558
|
+
): Effect.Effect<HttpClientRequest.HttpClientRequest> =>
|
|
559
|
+
auth.get.pipe(
|
|
560
|
+
Effect.map((token) => applyTokenHeaders(request, token)),
|
|
561
|
+
Effect.orDie,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
return httpClient.pipe(HttpClient.mapRequestEffect(injectAuthHeaders))
|
|
565
|
+
}),
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
static readonly layerClient = this.layerClientNoDeps.pipe(
|
|
569
|
+
Layer.provide(CodexAuth.layer),
|
|
570
|
+
)
|
|
571
|
+
}
|
package/src/Executor.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @since 1.0.0
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
Cause,
|
|
6
|
+
Console,
|
|
7
|
+
Effect,
|
|
8
|
+
FiberSet,
|
|
9
|
+
Layer,
|
|
10
|
+
Queue,
|
|
11
|
+
ServiceMap,
|
|
12
|
+
Stream,
|
|
13
|
+
} from "effect"
|
|
14
|
+
import { Tool, Toolkit } from "effect/unstable/ai"
|
|
15
|
+
import * as NodeConsole from "node:console"
|
|
16
|
+
import * as NodeVm from "node:vm"
|
|
17
|
+
import { Writable } from "node:stream"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @since 1.0.0
|
|
21
|
+
* @category Services
|
|
22
|
+
*/
|
|
23
|
+
export class Executor extends ServiceMap.Service<
|
|
24
|
+
Executor,
|
|
25
|
+
{
|
|
26
|
+
execute<Tools extends Record<string, Tool.Any>>(options: {
|
|
27
|
+
readonly tools: Toolkit.WithHandler<Tools>
|
|
28
|
+
readonly script: string
|
|
29
|
+
}): Stream.Stream<string, never, Tool.HandlerServices<Tools[keyof Tools]>>
|
|
30
|
+
}
|
|
31
|
+
>()("clanka/Executor") {
|
|
32
|
+
static readonly layer = Layer.effect(
|
|
33
|
+
Executor,
|
|
34
|
+
// oxlint-disable-next-line require-yield
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
const execute = Effect.fnUntraced(function* <
|
|
37
|
+
Tools extends Record<string, Tool.Any>,
|
|
38
|
+
>(options: {
|
|
39
|
+
readonly tools: Toolkit.WithHandler<Tools>
|
|
40
|
+
readonly script: string
|
|
41
|
+
}) {
|
|
42
|
+
const output = yield* Queue.unbounded<string, Cause.Done>()
|
|
43
|
+
|
|
44
|
+
yield* Effect.gen(function* () {
|
|
45
|
+
const console = yield* Console.Console
|
|
46
|
+
const services = yield* Effect.services()
|
|
47
|
+
const runPromise = yield* FiberSet.makeRuntimePromise()
|
|
48
|
+
|
|
49
|
+
const script = new NodeVm.Script(`async function main() {
|
|
50
|
+
${options.script}
|
|
51
|
+
}`)
|
|
52
|
+
const sandbox: ScriptSandbox = {
|
|
53
|
+
main: defaultMain,
|
|
54
|
+
console,
|
|
55
|
+
fetch,
|
|
56
|
+
process: undefined,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const [name, tool] of Object.entries(options.tools.tools)) {
|
|
60
|
+
const handler = services.mapUnsafe.get(
|
|
61
|
+
tool.id,
|
|
62
|
+
) as Tool.Handler<string>
|
|
63
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
64
|
+
sandbox[name] = function (params: any) {
|
|
65
|
+
return handler
|
|
66
|
+
.handler(params, {})
|
|
67
|
+
.pipe(Effect.provideServices(handler.services), runPromise)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
script.runInNewContext(sandbox, {
|
|
72
|
+
timeout: 1000,
|
|
73
|
+
})
|
|
74
|
+
yield* Effect.promise(sandbox.main)
|
|
75
|
+
}).pipe(
|
|
76
|
+
Effect.timeout("3 minutes"),
|
|
77
|
+
Effect.catchCause(Effect.logFatal),
|
|
78
|
+
Effect.provideServiceEffect(Console.Console, makeConsole(output)),
|
|
79
|
+
Effect.scoped,
|
|
80
|
+
Effect.ensuring(Queue.end(output)),
|
|
81
|
+
Effect.forkScoped,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return Stream.fromQueue(output)
|
|
85
|
+
}, Stream.unwrap)
|
|
86
|
+
|
|
87
|
+
return Executor.of({
|
|
88
|
+
execute,
|
|
89
|
+
})
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface ScriptSandbox {
|
|
95
|
+
main: () => Promise<void>
|
|
96
|
+
console: Console.Console
|
|
97
|
+
[toolName: string]: unknown
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const defaultMain = () => Promise.resolve()
|
|
101
|
+
|
|
102
|
+
const makeConsole = Effect.fn(function* (
|
|
103
|
+
queue: Queue.Queue<string, Cause.Done>,
|
|
104
|
+
) {
|
|
105
|
+
const writable = new QueueWriteStream(queue)
|
|
106
|
+
const newConsole = new NodeConsole.Console(writable)
|
|
107
|
+
yield* Effect.addFinalizer(() => {
|
|
108
|
+
writable.end()
|
|
109
|
+
return Effect.void
|
|
110
|
+
})
|
|
111
|
+
return newConsole
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
class QueueWriteStream extends Writable {
|
|
115
|
+
readonly queue: Queue.Enqueue<string, Cause.Done>
|
|
116
|
+
constructor(queue: Queue.Enqueue<string, Cause.Done>) {
|
|
117
|
+
super()
|
|
118
|
+
this.queue = queue
|
|
119
|
+
}
|
|
120
|
+
_write(
|
|
121
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
122
|
+
chunk: any,
|
|
123
|
+
_encoding: BufferEncoding,
|
|
124
|
+
callback: (error?: Error | null) => void,
|
|
125
|
+
): void {
|
|
126
|
+
Queue.offerUnsafe(this.queue, chunk.toString())
|
|
127
|
+
callback()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @since 1.0.0
|
|
3
|
+
*/
|
|
4
|
+
import { Sink } from "effect"
|
|
5
|
+
import type { Output } from "./Agent.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @since 1.0.0
|
|
9
|
+
* @category Models
|
|
10
|
+
*/
|
|
11
|
+
export type OutputFormatter<E = never, R = never> = Sink.Sink<
|
|
12
|
+
void,
|
|
13
|
+
Output,
|
|
14
|
+
never,
|
|
15
|
+
E,
|
|
16
|
+
R
|
|
17
|
+
>
|