dsh-codex-community 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/CHANGELOG.md +90 -0
- package/CONTRIBUTING.en.md +54 -0
- package/CONTRIBUTING.md +54 -0
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.en.md +98 -0
- package/README.md +98 -0
- package/SECURITY.md +41 -0
- package/SUPPORT.md +17 -0
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/codex-community.patch.yml +13 -0
- package/dist/client/index.js +1037 -0
- package/dist/host/index.mjs +98 -0
- package/dist/internal/authorization-bridge.mjs +662 -0
- package/dist/internal/authorization-commit-tracker.mjs +49 -0
- package/dist/internal/codex-authorization.mjs +202 -0
- package/dist/internal/codex-credential-store.mjs +164 -0
- package/dist/internal/codex-identifiers.mjs +4 -0
- package/dist/internal/codex-pi-provider.mjs +137 -0
- package/dist/internal/codex-provider-runtime.mjs +256 -0
- package/dist/internal/codex-route-adapter.mjs +133 -0
- package/dist/internal/codex-session-resources.mjs +64 -0
- package/dist/internal/failure-normalizer.mjs +456 -0
- package/dist/internal/image-policy.mjs +45 -0
- package/dist/internal/quota-observer.mjs +142 -0
- package/dist/internal/reliability.mjs +12 -0
- package/dist/internal/remote-image-input.mjs +801 -0
- package/dist/internal/session-preference-command.mjs +52 -0
- package/dist/internal/session-preferences.mjs +93 -0
- package/dist/internal/stream-resilience.mjs +273 -0
- package/docs/README.en.md +15 -0
- package/docs/README.md +15 -0
- package/docs/architecture.en.md +106 -0
- package/docs/architecture.md +106 -0
- package/docs/compatibility.en.md +53 -0
- package/docs/compatibility.md +53 -0
- package/docs/configuration.en.md +61 -0
- package/docs/configuration.md +61 -0
- package/docs/contribution-sources.en.md +35 -0
- package/docs/contribution-sources.md +35 -0
- package/docs/github-about.md +15 -0
- package/docs/releases/v0.0.1.acceptance.json +160 -0
- package/docs/releases/v0.0.1.md +174 -0
- package/docs/releasing.en.md +290 -0
- package/docs/releasing.md +290 -0
- package/docs/testing.en.md +85 -0
- package/docs/testing.md +85 -0
- package/docs/troubleshooting.en.md +47 -0
- package/docs/troubleshooting.md +47 -0
- package/package.json +144 -0
- package/types/client.d.ts +160 -0
- package/types/index.d.ts +22 -0
- package/types/reliability.d.ts +78 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
import { AuthorizationDeclinedError } from "@deepseek-ai/dsh-authorization"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CODEX_CREDENTIAL_KEY,
|
|
7
|
+
CODEX_PROVIDER_ID,
|
|
8
|
+
createCodexCredentialStore,
|
|
9
|
+
} from "./codex-credential-store.mjs"
|
|
10
|
+
|
|
11
|
+
export const AUTHORIZATION_RPC_CHANNEL = "/dsh-codex"
|
|
12
|
+
export const CODEX_AUTHORIZATION_KEY = CODEX_CREDENTIAL_KEY
|
|
13
|
+
|
|
14
|
+
const ATTEMPT_RETENTION_MS = 5 * 60_000
|
|
15
|
+
const STATUS_WAIT_MS = 25_000
|
|
16
|
+
const MAX_EVENTS = 128
|
|
17
|
+
const MAX_NOTICES = 32
|
|
18
|
+
const MAX_ATTEMPTS = 8
|
|
19
|
+
const MAX_RETAINED_ATTEMPTS = 64
|
|
20
|
+
const MAX_WAITERS_PER_ATTEMPT = 8
|
|
21
|
+
const MAX_METHODS = 16
|
|
22
|
+
const MAX_OPTIONS = 64
|
|
23
|
+
const MAX_ID_CHARS = 128
|
|
24
|
+
const MAX_ANSWER_CHARS = 16_384
|
|
25
|
+
const MAX_MESSAGE_CHARS = 2_048
|
|
26
|
+
const MAX_PLACEHOLDER_CHARS = 512
|
|
27
|
+
const MAX_CODE_CHARS = 256
|
|
28
|
+
const MAX_URL_CHARS = 2_048
|
|
29
|
+
|
|
30
|
+
class RpcInputError extends Error {
|
|
31
|
+
constructor(message) {
|
|
32
|
+
super(message)
|
|
33
|
+
this.name = "RpcInputError"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A short-lived interaction adapter around the public authorization seam.
|
|
39
|
+
* It never reads a credential value: only describeRecord() presence metadata
|
|
40
|
+
* crosses the interface, while notices and prompt answers live in memory for
|
|
41
|
+
* the duration of one loopback browser interaction.
|
|
42
|
+
*/
|
|
43
|
+
export class CodexAuthorizationBridge {
|
|
44
|
+
#authorization
|
|
45
|
+
#credentials
|
|
46
|
+
#commitTracker
|
|
47
|
+
#quotaObserver
|
|
48
|
+
#attempts = new Map()
|
|
49
|
+
#closed = false
|
|
50
|
+
#retentionMs
|
|
51
|
+
#waitMs
|
|
52
|
+
|
|
53
|
+
constructor({ authorization, credentials }, options = {}) {
|
|
54
|
+
this.#authorization = authorization
|
|
55
|
+
this.#credentials = credentials
|
|
56
|
+
this.#commitTracker = options.commitTracker
|
|
57
|
+
this.#quotaObserver = options.quotaObserver
|
|
58
|
+
this.#retentionMs = options.retentionMs ?? ATTEMPT_RETENTION_MS
|
|
59
|
+
this.#waitMs = options.waitMs ?? STATUS_WAIT_MS
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async status(payload = {}, signal) {
|
|
63
|
+
const input = objectInput(payload)
|
|
64
|
+
assertOnlyKeys(input, ["attemptId", "after"])
|
|
65
|
+
if (input.attemptId === undefined) {
|
|
66
|
+
if (input.after !== undefined) throw new RpcInputError("after requires attemptId")
|
|
67
|
+
return this.#publicStatus()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const attemptId = requiredString(input, "attemptId", { maxLength: MAX_ID_CHARS })
|
|
71
|
+
const after = optionalSequence(input.after)
|
|
72
|
+
const attempt = this.#attempt(attemptId)
|
|
73
|
+
await waitForAttempt(attempt, after, signal, this.#waitMs)
|
|
74
|
+
return {
|
|
75
|
+
attemptId,
|
|
76
|
+
events: attempt.events.filter((event) => event.seq > after),
|
|
77
|
+
nextSeq: attempt.events.at(-1)?.seq ?? after,
|
|
78
|
+
done: attempt.done,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async start(payload = {}) {
|
|
83
|
+
this.#assertOpen()
|
|
84
|
+
const input = objectInput(payload)
|
|
85
|
+
assertOnlyKeys(input, ["method"])
|
|
86
|
+
const activeAttempts = [...this.#attempts.values()].filter((attempt) => !attempt.done).length
|
|
87
|
+
if (activeAttempts >= MAX_ATTEMPTS) {
|
|
88
|
+
throw new RpcInputError("Too many authorization attempts")
|
|
89
|
+
}
|
|
90
|
+
if (this.#attempts.size >= MAX_RETAINED_ATTEMPTS) {
|
|
91
|
+
throw new RpcInputError("Too many recent authorization attempts")
|
|
92
|
+
}
|
|
93
|
+
const method = optionalString(input, "method", { maxLength: MAX_ID_CHARS })
|
|
94
|
+
const entry = this.#authorization.describe(CODEX_AUTHORIZATION_KEY)
|
|
95
|
+
if (entry === undefined) throw new RpcInputError("Codex authorization is unavailable")
|
|
96
|
+
if (method !== undefined && !entry.methods.some((candidate) => candidate.id === method)) {
|
|
97
|
+
throw new RpcInputError("Unknown Codex authorization method")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const attempt = {
|
|
101
|
+
id: randomUUID(),
|
|
102
|
+
controller: new AbortController(),
|
|
103
|
+
events: [],
|
|
104
|
+
nextSeq: 1,
|
|
105
|
+
noticeCount: 0,
|
|
106
|
+
prompt: undefined,
|
|
107
|
+
waiters: new Set(),
|
|
108
|
+
done: false,
|
|
109
|
+
cleanup: undefined,
|
|
110
|
+
}
|
|
111
|
+
this.#attempts.set(attempt.id, attempt)
|
|
112
|
+
|
|
113
|
+
void Promise.resolve().then(() => this.#authorization.begin({
|
|
114
|
+
key: CODEX_AUTHORIZATION_KEY,
|
|
115
|
+
...(method === undefined ? {} : { method }),
|
|
116
|
+
signal: attempt.controller.signal,
|
|
117
|
+
interaction: {
|
|
118
|
+
notify: (notice) => {
|
|
119
|
+
if (!attempt.done && attempt.noticeCount < MAX_NOTICES) {
|
|
120
|
+
attempt.noticeCount += 1
|
|
121
|
+
this.#emit(attempt, {
|
|
122
|
+
type: "notice",
|
|
123
|
+
notice: serializeNotice(notice),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
prompt: (prompt) => this.#prompt(attempt, prompt),
|
|
128
|
+
},
|
|
129
|
+
})).then(
|
|
130
|
+
(outcome) => this.#settle(attempt, {
|
|
131
|
+
type: "settled",
|
|
132
|
+
status: outcome.status,
|
|
133
|
+
}),
|
|
134
|
+
(error) => this.#settle(attempt, {
|
|
135
|
+
type: "failed",
|
|
136
|
+
error: safeAuthorizationFailure(error),
|
|
137
|
+
}),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return { attemptId: attempt.id }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
respond(payload = {}) {
|
|
144
|
+
const input = objectInput(payload)
|
|
145
|
+
assertOnlyKeys(input, ["attemptId", "promptId", "action", "value"])
|
|
146
|
+
const attempt = this.#attempt(requiredString(input, "attemptId", { maxLength: MAX_ID_CHARS }))
|
|
147
|
+
const promptId = requiredString(input, "promptId", { maxLength: MAX_ID_CHARS })
|
|
148
|
+
const action = requiredString(input, "action", { maxLength: 16 })
|
|
149
|
+
const pending = attempt.prompt
|
|
150
|
+
if (pending === undefined || pending.id !== promptId) {
|
|
151
|
+
throw new RpcInputError("The authorization prompt is no longer active")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (action === "answer") {
|
|
155
|
+
const value = requiredString(input, "value", {
|
|
156
|
+
allowEmpty: true,
|
|
157
|
+
maxLength: MAX_ANSWER_CHARS,
|
|
158
|
+
})
|
|
159
|
+
const answer = pending.mapAnswer(value)
|
|
160
|
+
this.#closePrompt(attempt, pending)
|
|
161
|
+
pending.resolve(answer)
|
|
162
|
+
return { accepted: true }
|
|
163
|
+
}
|
|
164
|
+
if (action === "decline") {
|
|
165
|
+
this.#closePrompt(attempt, pending)
|
|
166
|
+
pending.reject(new AuthorizationDeclinedError())
|
|
167
|
+
return { accepted: true }
|
|
168
|
+
}
|
|
169
|
+
throw new RpcInputError("Unknown prompt response action")
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
cancel(payload = {}) {
|
|
173
|
+
const input = objectInput(payload)
|
|
174
|
+
assertOnlyKeys(input, ["attemptId"])
|
|
175
|
+
const attempt = input.attemptId === undefined
|
|
176
|
+
? undefined
|
|
177
|
+
: this.#attempt(requiredString(input, "attemptId", { maxLength: MAX_ID_CHARS }))
|
|
178
|
+
if (attempt?.done === true) return { accepted: false }
|
|
179
|
+
const cancel = () => {
|
|
180
|
+
if (attempt !== undefined) {
|
|
181
|
+
attempt.controller.abort("cancelled by the interaction surface")
|
|
182
|
+
this.#rejectPrompt(attempt, new Error("authorization cancelled"))
|
|
183
|
+
}
|
|
184
|
+
this.#authorization.cancel(CODEX_AUTHORIZATION_KEY)
|
|
185
|
+
}
|
|
186
|
+
if (this.#commitTracker?.tryCancel(cancel) === false) {
|
|
187
|
+
return { accepted: false, reason: "commit-in-progress" }
|
|
188
|
+
}
|
|
189
|
+
if (this.#commitTracker === undefined) cancel()
|
|
190
|
+
return { accepted: true }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async logout(payload = {}) {
|
|
194
|
+
const input = objectInput(payload)
|
|
195
|
+
assertOnlyKeys(input, [])
|
|
196
|
+
for (const attempt of this.#attempts.values()) {
|
|
197
|
+
if (attempt.done) continue
|
|
198
|
+
attempt.controller.abort("signed out by the interaction surface")
|
|
199
|
+
this.#rejectPrompt(attempt, new Error("authorization signed out"))
|
|
200
|
+
}
|
|
201
|
+
this.#authorization.cancel(CODEX_AUTHORIZATION_KEY)
|
|
202
|
+
await this.#credentials.deleteRecord(CODEX_AUTHORIZATION_KEY)
|
|
203
|
+
return { signedOut: true }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
dispatch(endpoint, payload, signal) {
|
|
207
|
+
switch (endpoint) {
|
|
208
|
+
case "status": return this.status(payload, signal)
|
|
209
|
+
case "start": return this.start(payload)
|
|
210
|
+
case "respond": return this.respond(payload)
|
|
211
|
+
case "cancel": return this.cancel(payload)
|
|
212
|
+
case "logout": return this.logout(payload)
|
|
213
|
+
default: throw new RpcInputError("Unknown dsh-codex RPC endpoint")
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
dispose() {
|
|
218
|
+
if (this.#closed) return
|
|
219
|
+
this.#closed = true
|
|
220
|
+
const cancel = () => {
|
|
221
|
+
for (const attempt of this.#attempts.values()) {
|
|
222
|
+
if (!attempt.done) attempt.controller.abort("authorization bridge disposed")
|
|
223
|
+
}
|
|
224
|
+
this.#authorization.cancel(CODEX_AUTHORIZATION_KEY)
|
|
225
|
+
}
|
|
226
|
+
// A disposed interaction surface may detach from an irreversible commit,
|
|
227
|
+
// but must not make AuthorizationService report that commit as cancelled.
|
|
228
|
+
if (this.#commitTracker === undefined) cancel()
|
|
229
|
+
else this.#commitTracker.tryCancel(cancel)
|
|
230
|
+
for (const attempt of this.#attempts.values()) {
|
|
231
|
+
if (attempt.cleanup !== undefined) clearTimeout(attempt.cleanup)
|
|
232
|
+
this.#rejectPrompt(attempt, new Error("authorization bridge disposed"))
|
|
233
|
+
wakeAttempt(attempt)
|
|
234
|
+
}
|
|
235
|
+
this.#attempts.clear()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async #publicStatus() {
|
|
239
|
+
const entry = this.#authorization.describe(CODEX_AUTHORIZATION_KEY)
|
|
240
|
+
const credential = await describeCodexCredential(this.#credentials)
|
|
241
|
+
return {
|
|
242
|
+
flow: entry === undefined ? undefined : {
|
|
243
|
+
key: CODEX_AUTHORIZATION_KEY,
|
|
244
|
+
label: boundedText(entry.label, MAX_MESSAGE_CHARS),
|
|
245
|
+
methods: entry.methods
|
|
246
|
+
.filter(({ id }) => typeof id === "string" && id.length > 0 && id.length <= MAX_ID_CHARS)
|
|
247
|
+
.slice(0, MAX_METHODS)
|
|
248
|
+
.map(({ id, label }) => ({ id, label: boundedText(label, MAX_MESSAGE_CHARS) })),
|
|
249
|
+
inFlight: entry.inFlight,
|
|
250
|
+
},
|
|
251
|
+
credential,
|
|
252
|
+
quota: this.#quotaObserver?.snapshot() ?? Object.freeze({ status: "unknown" }),
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#attempt(id) {
|
|
257
|
+
const attempt = this.#attempts.get(id)
|
|
258
|
+
if (attempt === undefined) throw new RpcInputError("Unknown authorization attempt")
|
|
259
|
+
return attempt
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#assertOpen() {
|
|
263
|
+
if (this.#closed) throw new RpcInputError("Authorization bridge is closed")
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#prompt(attempt, prompt) {
|
|
267
|
+
if (attempt.done || attempt.controller.signal.aborted) {
|
|
268
|
+
return Promise.reject(new Error("authorization attempt is no longer active"))
|
|
269
|
+
}
|
|
270
|
+
if (attempt.prompt !== undefined) {
|
|
271
|
+
return Promise.reject(new Error("authorization flow opened overlapping prompts"))
|
|
272
|
+
}
|
|
273
|
+
if (prompt.signal?.aborted === true) {
|
|
274
|
+
return Promise.reject(new Error("authorization prompt was already withdrawn"))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return new Promise((resolve, reject) => {
|
|
278
|
+
const serialized = serializePrompt(prompt)
|
|
279
|
+
const pending = {
|
|
280
|
+
id: randomUUID(),
|
|
281
|
+
resolve,
|
|
282
|
+
reject,
|
|
283
|
+
mapAnswer: serialized.mapAnswer,
|
|
284
|
+
signal: prompt.signal,
|
|
285
|
+
onAbort: undefined,
|
|
286
|
+
}
|
|
287
|
+
if (prompt.signal !== undefined) {
|
|
288
|
+
pending.onAbort = () => {
|
|
289
|
+
if (attempt.prompt !== pending) return
|
|
290
|
+
this.#closePrompt(attempt, pending)
|
|
291
|
+
reject(new Error("authorization prompt withdrawn"))
|
|
292
|
+
}
|
|
293
|
+
prompt.signal.addEventListener("abort", pending.onAbort, { once: true })
|
|
294
|
+
}
|
|
295
|
+
attempt.prompt = pending
|
|
296
|
+
this.#emit(attempt, {
|
|
297
|
+
type: "prompt",
|
|
298
|
+
promptId: pending.id,
|
|
299
|
+
prompt: serialized.prompt,
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
#closePrompt(attempt, pending) {
|
|
305
|
+
if (pending.signal !== undefined && pending.onAbort !== undefined) {
|
|
306
|
+
pending.signal.removeEventListener("abort", pending.onAbort)
|
|
307
|
+
}
|
|
308
|
+
if (attempt.prompt === pending) attempt.prompt = undefined
|
|
309
|
+
this.#emit(attempt, { type: "prompt-closed", promptId: pending.id })
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#rejectPrompt(attempt, error) {
|
|
313
|
+
const pending = attempt.prompt
|
|
314
|
+
if (pending === undefined) return
|
|
315
|
+
this.#closePrompt(attempt, pending)
|
|
316
|
+
pending.reject(error)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#settle(attempt, terminal) {
|
|
320
|
+
if (attempt.done) return
|
|
321
|
+
attempt.done = true
|
|
322
|
+
this.#rejectPrompt(attempt, new Error("authorization attempt settled"))
|
|
323
|
+
this.#emit(attempt, terminal)
|
|
324
|
+
attempt.cleanup = setTimeout(() => {
|
|
325
|
+
this.#attempts.delete(attempt.id)
|
|
326
|
+
}, this.#retentionMs)
|
|
327
|
+
attempt.cleanup.unref?.()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#emit(attempt, event) {
|
|
331
|
+
attempt.events.push(Object.freeze({ seq: attempt.nextSeq++, ...event }))
|
|
332
|
+
if (attempt.events.length > MAX_EVENTS) attempt.events.shift()
|
|
333
|
+
wakeAttempt(attempt)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function installAuthorizationRpc(ctx, options) {
|
|
338
|
+
const bridge = new CodexAuthorizationBridge({
|
|
339
|
+
authorization: ctx.authorization,
|
|
340
|
+
credentials: ctx.credentials,
|
|
341
|
+
}, options)
|
|
342
|
+
ctx.connection.rpc.handle(
|
|
343
|
+
AUTHORIZATION_RPC_CHANNEL,
|
|
344
|
+
createAuthorizationRpcHandler(bridge),
|
|
345
|
+
{ authority: "loopback" },
|
|
346
|
+
)
|
|
347
|
+
ctx.effect(() => () => bridge.dispose(), "dsh-codex: authorization RPC state")
|
|
348
|
+
return bridge
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function createAuthorizationRpcHandler(bridge) {
|
|
352
|
+
return async (endpoint, payload, signal) => {
|
|
353
|
+
try {
|
|
354
|
+
return { ok: true, value: await bridge.dispatch(endpoint, payload, signal) }
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (signal?.aborted === true) {
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
error: { code: "cancelled", message: "Request cancelled", details: {} },
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (error instanceof RpcInputError) {
|
|
363
|
+
return {
|
|
364
|
+
ok: false,
|
|
365
|
+
error: { code: "bad-request", message: error.message, details: { issues: [] } },
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
ok: false,
|
|
370
|
+
error: { code: "internal", message: "Authorization request failed", details: {} },
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function registerCodexLoginCommand(ctx, options = {}) {
|
|
377
|
+
const commitTracker = options.commitTracker
|
|
378
|
+
ctx.commands.register({
|
|
379
|
+
name: "codex-login",
|
|
380
|
+
description: "管理 Codex 登录 / Manage Codex sign-in",
|
|
381
|
+
input: { hint: "[status|cancel|logout]" },
|
|
382
|
+
recordInput: false,
|
|
383
|
+
handler: async ({ rawInput }) => {
|
|
384
|
+
const action = rawInput.trim()
|
|
385
|
+
try {
|
|
386
|
+
if (action === "cancel") {
|
|
387
|
+
const cancel = () => ctx.authorization.cancel(CODEX_AUTHORIZATION_KEY)
|
|
388
|
+
if (commitTracker?.tryCancel(cancel) === false) {
|
|
389
|
+
return {
|
|
390
|
+
kind: "success",
|
|
391
|
+
text: "Codex 凭据已开始提交,当前无法取消;请稍候检查登录状态。 / Codex credential commit has started and can no longer be cancelled; check sign-in status shortly.",
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (commitTracker === undefined) cancel()
|
|
395
|
+
return {
|
|
396
|
+
kind: "success",
|
|
397
|
+
text: "已请求取消 Codex 登录。请在 Codex 设置页重试。 / Codex sign-in cancellation requested. Retry from Codex Settings.",
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (action === "logout") {
|
|
401
|
+
ctx.authorization.cancel(CODEX_AUTHORIZATION_KEY)
|
|
402
|
+
await ctx.credentials.deleteRecord(CODEX_AUTHORIZATION_KEY)
|
|
403
|
+
return {
|
|
404
|
+
kind: "success",
|
|
405
|
+
text: "已退出 Codex 登录。 / Signed out of Codex.",
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (action !== "" && action !== "status") {
|
|
409
|
+
return {
|
|
410
|
+
kind: "error",
|
|
411
|
+
text: "用法:/codex-login [status|cancel|logout] / Usage: /codex-login [status|cancel|logout]",
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const flow = ctx.authorization.describe(CODEX_AUTHORIZATION_KEY)
|
|
416
|
+
const credential = await describeCodexCredential(ctx.credentials)
|
|
417
|
+
if (flow?.inFlight === true) {
|
|
418
|
+
return {
|
|
419
|
+
kind: "success",
|
|
420
|
+
text: "Codex 登录正在进行;请在 Codex 设置页继续。 / Codex sign-in is in progress; continue in Codex Settings.",
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (credential.state === "signed-in") {
|
|
424
|
+
return {
|
|
425
|
+
kind: "success",
|
|
426
|
+
text: "Codex 已登录。 / Codex is signed in.",
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (credential.state === "invalid") {
|
|
430
|
+
return {
|
|
431
|
+
kind: "success",
|
|
432
|
+
text: "已保存的 Codex 凭据无效;请重新登录或退出后重试。 / The saved Codex credential is invalid; sign in again or sign out before retrying.",
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
kind: "success",
|
|
437
|
+
text: flow === undefined
|
|
438
|
+
? "Codex 登录当前不可用。 / Codex sign-in is currently unavailable."
|
|
439
|
+
: "Codex 尚未登录;请打开 Codex 设置页。 / Codex is not signed in; open Codex Settings.",
|
|
440
|
+
}
|
|
441
|
+
} catch {
|
|
442
|
+
return {
|
|
443
|
+
kind: "error",
|
|
444
|
+
text: "Codex 登录操作失败,请在设置页重试。 / Codex sign-in action failed. Retry from Settings.",
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function describeCodexCredential(credentials) {
|
|
452
|
+
const description = await credentials.describeRecord(CODEX_AUTHORIZATION_KEY)
|
|
453
|
+
let state = "signed-out"
|
|
454
|
+
if (description.configured === true) {
|
|
455
|
+
if (description.kind !== "grant") {
|
|
456
|
+
state = "invalid"
|
|
457
|
+
} else {
|
|
458
|
+
try {
|
|
459
|
+
const credential = await createCodexCredentialStore(credentials).read(CODEX_PROVIDER_ID)
|
|
460
|
+
state = credential === undefined ? "signed-out" : "signed-in"
|
|
461
|
+
} catch {
|
|
462
|
+
state = "invalid"
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
configured: description.configured === true,
|
|
468
|
+
state,
|
|
469
|
+
...(description.kind === undefined ? {} : { kind: description.kind }),
|
|
470
|
+
writable: description.writable === true,
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function registerCodexUsageCommand(ctx, quotaObserver) {
|
|
475
|
+
ctx.commands.register({
|
|
476
|
+
name: "codex-usage",
|
|
477
|
+
description: "查看最近观测到的 Codex 额度状态 / Show the last observed Codex quota state",
|
|
478
|
+
input: { hint: "[status]" },
|
|
479
|
+
recordInput: false,
|
|
480
|
+
handler: async ({ rawInput }) => {
|
|
481
|
+
const action = rawInput.trim()
|
|
482
|
+
if (action !== "" && action !== "status") {
|
|
483
|
+
return {
|
|
484
|
+
kind: "error",
|
|
485
|
+
text: "用法:/codex-usage [status] / Usage: /codex-usage [status]",
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
return {
|
|
491
|
+
kind: "success",
|
|
492
|
+
text: formatQuotaSnapshot(quotaObserver.snapshot()),
|
|
493
|
+
}
|
|
494
|
+
} catch {
|
|
495
|
+
return {
|
|
496
|
+
kind: "error",
|
|
497
|
+
text: "暂时无法读取 Codex 额度观测。 / Codex quota observation is temporarily unavailable.",
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function formatQuotaSnapshot(snapshot) {
|
|
505
|
+
if (snapshot.status === "recent-success") {
|
|
506
|
+
return `最近一次 Codex 请求成功于 ${new Date(snapshot.observedAt).toISOString()};这不代表账户剩余额度。 / Last Codex request succeeded at ${new Date(snapshot.observedAt).toISOString()}; this does not represent remaining account quota.`
|
|
507
|
+
}
|
|
508
|
+
if (snapshot.status === "exhausted") {
|
|
509
|
+
if (snapshot.resetAt === undefined) {
|
|
510
|
+
return "最近观测到 Codex 账户额度耗尽,但未获得通过校验的重置时间。 / Codex account quota was exhausted in the latest observation, but no reset time passed validation."
|
|
511
|
+
}
|
|
512
|
+
const reset = new Date(snapshot.resetAt).toISOString()
|
|
513
|
+
return `最近观测到 Codex 账户额度耗尽;观测到的重置时间:${reset}。 / Codex account quota was exhausted in the latest observation; observed reset time: ${reset}.`
|
|
514
|
+
}
|
|
515
|
+
return "暂无近期 Codex 额度观测;插件不会调用未公开的账户额度接口。 / No recent Codex quota observation; the plugin does not call undocumented account-quota endpoints."
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function objectInput(value) {
|
|
519
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
520
|
+
throw new RpcInputError("RPC payload must be an object")
|
|
521
|
+
}
|
|
522
|
+
return value
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function requiredString(value, key, options = {}) {
|
|
526
|
+
const field = value[key]
|
|
527
|
+
if (typeof field !== "string" || (!options.allowEmpty && field.length === 0)) {
|
|
528
|
+
throw new RpcInputError(`${key} must be a ${options.allowEmpty ? "string" : "non-empty string"}`)
|
|
529
|
+
}
|
|
530
|
+
if (options.maxLength !== undefined && field.length > options.maxLength) {
|
|
531
|
+
throw new RpcInputError(`${key} is too long`)
|
|
532
|
+
}
|
|
533
|
+
return field
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function optionalString(value, key, options) {
|
|
537
|
+
if (value[key] === undefined) return undefined
|
|
538
|
+
return requiredString(value, key, options)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function assertOnlyKeys(value, allowed) {
|
|
542
|
+
const keys = new Set(allowed)
|
|
543
|
+
if (Object.keys(value).some((key) => !keys.has(key))) {
|
|
544
|
+
throw new RpcInputError("RPC payload contains an unknown field")
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function optionalSequence(value) {
|
|
549
|
+
if (value === undefined) return 0
|
|
550
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
551
|
+
throw new RpcInputError("after must be a non-negative safe integer")
|
|
552
|
+
}
|
|
553
|
+
return value
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function serializeNotice(notice) {
|
|
557
|
+
const url = safeAuthorizationUrl(notice.url)
|
|
558
|
+
return {
|
|
559
|
+
message: boundedText(notice.message, MAX_MESSAGE_CHARS),
|
|
560
|
+
...(url === undefined ? {} : { url }),
|
|
561
|
+
...(notice.code === undefined ? {} : { code: boundedText(notice.code, MAX_CODE_CHARS) }),
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function serializePrompt(prompt) {
|
|
566
|
+
if (prompt.kind === "select") {
|
|
567
|
+
const answers = new Map()
|
|
568
|
+
const options = prompt.options.slice(0, MAX_OPTIONS).map((option, index) => {
|
|
569
|
+
const id = `option-${index + 1}`
|
|
570
|
+
answers.set(id, String(option.id))
|
|
571
|
+
return {
|
|
572
|
+
id,
|
|
573
|
+
label: boundedText(option.label, MAX_MESSAGE_CHARS),
|
|
574
|
+
...(option.description === undefined
|
|
575
|
+
? {}
|
|
576
|
+
: { description: boundedText(option.description, MAX_MESSAGE_CHARS) }),
|
|
577
|
+
}
|
|
578
|
+
})
|
|
579
|
+
return {
|
|
580
|
+
prompt: {
|
|
581
|
+
kind: "select",
|
|
582
|
+
message: boundedText(prompt.message, MAX_MESSAGE_CHARS),
|
|
583
|
+
options,
|
|
584
|
+
},
|
|
585
|
+
mapAnswer: (answer) => {
|
|
586
|
+
const selected = answers.get(answer)
|
|
587
|
+
if (selected === undefined) throw new RpcInputError("Unknown prompt option")
|
|
588
|
+
return selected
|
|
589
|
+
},
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
prompt: {
|
|
594
|
+
kind: prompt.kind === "secret" ? "secret" : "text",
|
|
595
|
+
message: boundedText(prompt.message, MAX_MESSAGE_CHARS),
|
|
596
|
+
...(prompt.placeholder === undefined
|
|
597
|
+
? {}
|
|
598
|
+
: { placeholder: boundedText(prompt.placeholder, MAX_PLACEHOLDER_CHARS) }),
|
|
599
|
+
},
|
|
600
|
+
mapAnswer: (answer) => answer,
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function boundedText(value, maxLength) {
|
|
605
|
+
const text = String(value)
|
|
606
|
+
return text.length <= maxLength ? text : text.slice(0, maxLength)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function safeAuthorizationUrl(value) {
|
|
610
|
+
if (value === undefined) return undefined
|
|
611
|
+
const text = String(value)
|
|
612
|
+
if (text.length === 0 || text.length > MAX_URL_CHARS) return undefined
|
|
613
|
+
try {
|
|
614
|
+
const url = new URL(text)
|
|
615
|
+
if (url.username !== "" || url.password !== "") return undefined
|
|
616
|
+
if (url.protocol === "https:") return url.href
|
|
617
|
+
if (url.protocol !== "http:" || !isLoopbackHostname(url.hostname)) return undefined
|
|
618
|
+
return url.href
|
|
619
|
+
} catch {
|
|
620
|
+
return undefined
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function isLoopbackHostname(hostname) {
|
|
625
|
+
return hostname === "localhost"
|
|
626
|
+
|| hostname === "[::1]"
|
|
627
|
+
|| /^127(?:\.\d{1,3}){3}$/u.test(hostname)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function safeAuthorizationFailure(error) {
|
|
631
|
+
const candidate = typeof error?.code === "string" ? error.code : "AUTHORIZATION_FAILED"
|
|
632
|
+
const code = /^[A-Z0-9_-]{1,64}$/u.test(candidate) ? candidate : "AUTHORIZATION_FAILED"
|
|
633
|
+
return { code, message: "Authorization failed" }
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function waitForAttempt(attempt, after, signal, waitMs) {
|
|
637
|
+
if (attempt.done || attempt.events.some((event) => event.seq > after)) return Promise.resolve()
|
|
638
|
+
if (signal?.aborted === true) return Promise.reject(signal.reason ?? new Error("request cancelled"))
|
|
639
|
+
if (attempt.waiters.size >= MAX_WAITERS_PER_ATTEMPT) {
|
|
640
|
+
return Promise.reject(new RpcInputError("Too many status waiters"))
|
|
641
|
+
}
|
|
642
|
+
return new Promise((resolve, reject) => {
|
|
643
|
+
let timer
|
|
644
|
+
const finish = (error) => {
|
|
645
|
+
attempt.waiters.delete(wake)
|
|
646
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
647
|
+
signal?.removeEventListener("abort", abort)
|
|
648
|
+
if (error === undefined) resolve()
|
|
649
|
+
else reject(error)
|
|
650
|
+
}
|
|
651
|
+
const wake = () => finish()
|
|
652
|
+
const abort = () => finish(signal.reason ?? new Error("request cancelled"))
|
|
653
|
+
attempt.waiters.add(wake)
|
|
654
|
+
signal?.addEventListener("abort", abort, { once: true })
|
|
655
|
+
timer = setTimeout(wake, waitMs)
|
|
656
|
+
timer.unref?.()
|
|
657
|
+
})
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function wakeAttempt(attempt) {
|
|
661
|
+
for (const wake of [...attempt.waiters]) wake()
|
|
662
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coordinate the user-facing cancellation boundary around one local Codex
|
|
3
|
+
* authorization flow. No credential material enters this state machine.
|
|
4
|
+
*/
|
|
5
|
+
export function createAuthorizationCommitTracker() {
|
|
6
|
+
let generation = 0
|
|
7
|
+
let active
|
|
8
|
+
|
|
9
|
+
return Object.freeze({
|
|
10
|
+
begin() {
|
|
11
|
+
if (active !== undefined) {
|
|
12
|
+
throw new Error("a Codex authorization attempt is already tracked")
|
|
13
|
+
}
|
|
14
|
+
const id = ++generation
|
|
15
|
+
active = { id, phase: "cancellable" }
|
|
16
|
+
let finished = false
|
|
17
|
+
|
|
18
|
+
return Object.freeze({
|
|
19
|
+
selectCommit() {
|
|
20
|
+
if (finished || active?.id !== id || active.phase !== "cancellable") return false
|
|
21
|
+
active = { id, phase: "committing" }
|
|
22
|
+
return true
|
|
23
|
+
},
|
|
24
|
+
finish() {
|
|
25
|
+
if (finished) return
|
|
26
|
+
finished = true
|
|
27
|
+
if (active?.id === id && active.phase === "cancellable") active = undefined
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/** Check and run cancellation in one synchronous call stack. */
|
|
33
|
+
tryCancel(cancel) {
|
|
34
|
+
if (typeof cancel !== "function") throw new TypeError("cancel must be a function")
|
|
35
|
+
if (active?.phase === "committing") return false
|
|
36
|
+
cancel()
|
|
37
|
+
return true
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
isCommitPending() {
|
|
41
|
+
return active?.phase === "committing"
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/** Called when the public authorization attempt reaches its terminal state. */
|
|
45
|
+
settle() {
|
|
46
|
+
active = undefined
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
}
|