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.
Files changed (53) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/CONTRIBUTING.en.md +54 -0
  3. package/CONTRIBUTING.md +54 -0
  4. package/LICENSE +201 -0
  5. package/NOTICE +12 -0
  6. package/README.en.md +98 -0
  7. package/README.md +98 -0
  8. package/SECURITY.md +41 -0
  9. package/SUPPORT.md +17 -0
  10. package/THIRD_PARTY_NOTICES.md +25 -0
  11. package/codex-community.patch.yml +13 -0
  12. package/dist/client/index.js +1037 -0
  13. package/dist/host/index.mjs +98 -0
  14. package/dist/internal/authorization-bridge.mjs +662 -0
  15. package/dist/internal/authorization-commit-tracker.mjs +49 -0
  16. package/dist/internal/codex-authorization.mjs +202 -0
  17. package/dist/internal/codex-credential-store.mjs +164 -0
  18. package/dist/internal/codex-identifiers.mjs +4 -0
  19. package/dist/internal/codex-pi-provider.mjs +137 -0
  20. package/dist/internal/codex-provider-runtime.mjs +256 -0
  21. package/dist/internal/codex-route-adapter.mjs +133 -0
  22. package/dist/internal/codex-session-resources.mjs +64 -0
  23. package/dist/internal/failure-normalizer.mjs +456 -0
  24. package/dist/internal/image-policy.mjs +45 -0
  25. package/dist/internal/quota-observer.mjs +142 -0
  26. package/dist/internal/reliability.mjs +12 -0
  27. package/dist/internal/remote-image-input.mjs +801 -0
  28. package/dist/internal/session-preference-command.mjs +52 -0
  29. package/dist/internal/session-preferences.mjs +93 -0
  30. package/dist/internal/stream-resilience.mjs +273 -0
  31. package/docs/README.en.md +15 -0
  32. package/docs/README.md +15 -0
  33. package/docs/architecture.en.md +106 -0
  34. package/docs/architecture.md +106 -0
  35. package/docs/compatibility.en.md +53 -0
  36. package/docs/compatibility.md +53 -0
  37. package/docs/configuration.en.md +61 -0
  38. package/docs/configuration.md +61 -0
  39. package/docs/contribution-sources.en.md +35 -0
  40. package/docs/contribution-sources.md +35 -0
  41. package/docs/github-about.md +15 -0
  42. package/docs/releases/v0.0.1.acceptance.json +160 -0
  43. package/docs/releases/v0.0.1.md +174 -0
  44. package/docs/releasing.en.md +290 -0
  45. package/docs/releasing.md +290 -0
  46. package/docs/testing.en.md +85 -0
  47. package/docs/testing.md +85 -0
  48. package/docs/troubleshooting.en.md +47 -0
  49. package/docs/troubleshooting.md +47 -0
  50. package/package.json +144 -0
  51. package/types/client.d.ts +160 -0
  52. package/types/index.d.ts +22 -0
  53. package/types/reliability.d.ts +78 -0
@@ -0,0 +1,202 @@
1
+ import {
2
+ CODEX_CREDENTIAL_KEY,
3
+ CODEX_PROVIDER_ID,
4
+ } from "./codex-credential-store.mjs"
5
+
6
+ const MAX_MESSAGE_CHARS = 2_048
7
+ const MAX_PLACEHOLDER_CHARS = 512
8
+ const MAX_CODE_CHARS = 256
9
+ const MAX_URL_CHARS = 2_048
10
+ const MAX_OPTIONS = 64
11
+
12
+ /** Register the sole OAuth flow that writes the plugin-owned Codex grant. */
13
+ export function registerCodexAuthorizationFlow({
14
+ ownerContext,
15
+ authorization,
16
+ credentialStore,
17
+ authContext,
18
+ provider,
19
+ commitTracker,
20
+ }) {
21
+ void authContext
22
+ if (provider?.id !== CODEX_PROVIDER_ID || provider.auth?.oauth === undefined) {
23
+ throw new TypeError("provider must be the OAuth-capable Codex provider")
24
+ }
25
+
26
+ let closing = false
27
+ const flow = {
28
+ key: CODEX_CREDENTIAL_KEY,
29
+ label: "Codex (ChatGPT OAuth)",
30
+ methods: [{ id: "oauth", label: "使用 ChatGPT 登录 / Sign in with ChatGPT" }],
31
+ async run(session) {
32
+ if (closing) {
33
+ await waitForAbort(session.signal)
34
+ return
35
+ }
36
+ const trackedAttempt = commitTracker?.begin()
37
+ try {
38
+ const credential = await provider.auth.oauth.login({
39
+ signal: session.signal,
40
+ notify(event) {
41
+ const notice = authorizationNotice(event)
42
+ if (notice !== undefined) session.notify(notice)
43
+ },
44
+ prompt(prompt) {
45
+ return session.prompt(authorizationPrompt(prompt))
46
+ },
47
+ })
48
+ if (session.signal.aborted) return
49
+
50
+ // The serialized mutate callback is the cancellation linearization point.
51
+ // Before it selects a replacement, abort is a no-op. After it selects one,
52
+ // this write owns the lock and must finish: a later compensating delete
53
+ // cannot distinguish this attempt from a sign-in already queued by another
54
+ // DSH process and could erase that newer grant. The tracker lets plugin
55
+ // surfaces reject a too-late cancel until authorization/settled fires.
56
+ await credentialStore.modify(CODEX_PROVIDER_ID, () => {
57
+ if (session.signal.aborted) return undefined
58
+ if (trackedAttempt !== undefined && !trackedAttempt.selectCommit()) return undefined
59
+ return credential
60
+ })
61
+ } finally {
62
+ trackedAttempt?.finish()
63
+ }
64
+ },
65
+ }
66
+
67
+ const rootContext = ownerContext?.root
68
+ if (rootContext === undefined || rootContext === ownerContext) {
69
+ return authorization.registerFlow(flow)
70
+ }
71
+
72
+ // DSH rc.2 withdraws a flow and aborts its running controller in the same
73
+ // disposer. A selected credential write is already irreversible, so keep
74
+ // the flow under the app root and give the plugin one ordered cleanup that
75
+ // waits for the public authorization settlement before withdrawing it.
76
+ const rootAuthorization = rootContext.authorization
77
+ const settlementWaiters = new Set()
78
+ const disposeSettlementListener = rootContext.on(
79
+ "authorization/settled",
80
+ (key) => {
81
+ if (key !== CODEX_CREDENTIAL_KEY) return
82
+ commitTracker?.settle()
83
+ for (const resolve of settlementWaiters) resolve()
84
+ settlementWaiters.clear()
85
+ },
86
+ { global: true },
87
+ )
88
+ let disposeFlow
89
+ let disposeLifecycle
90
+ try {
91
+ disposeFlow = rootAuthorization.registerFlow(flow)
92
+ disposeLifecycle = ownerContext.effect(
93
+ () => async () => {
94
+ closing = true
95
+ try {
96
+ while (rootAuthorization.describe(CODEX_CREDENTIAL_KEY)?.inFlight === true) {
97
+ const settled = new Promise((resolve) => { settlementWaiters.add(resolve) })
98
+ if (commitTracker?.isCommitPending() !== true) {
99
+ rootAuthorization.cancel(CODEX_CREDENTIAL_KEY)
100
+ }
101
+ await settled
102
+ }
103
+ } finally {
104
+ disposeFlow()
105
+ disposeSettlementListener()
106
+ for (const resolve of settlementWaiters) resolve()
107
+ settlementWaiters.clear()
108
+ }
109
+ },
110
+ "dsh-codex: quiescent authorization flow",
111
+ )
112
+ } catch (error) {
113
+ disposeFlow?.()
114
+ disposeSettlementListener()
115
+ throw error
116
+ }
117
+ return disposeLifecycle
118
+ }
119
+
120
+ function waitForAbort(signal) {
121
+ if (signal.aborted) return Promise.resolve()
122
+ return new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true }))
123
+ }
124
+
125
+ function authorizationNotice(event) {
126
+ switch (event?.type) {
127
+ case "auth_url":
128
+ return compact({
129
+ message: bounded(event.instructions ?? "在浏览器中继续授权。 / Continue authorization in your browser.", MAX_MESSAGE_CHARS),
130
+ url: safeAuthorizationUrl(event.url),
131
+ })
132
+ case "device_code":
133
+ return compact({
134
+ message: "在浏览器中继续授权。 / Continue authorization in your browser.",
135
+ url: safeAuthorizationUrl(event.verificationUri),
136
+ code: bounded(event.userCode, MAX_CODE_CHARS),
137
+ })
138
+ case "info":
139
+ return compact({
140
+ message: bounded(event.message, MAX_MESSAGE_CHARS),
141
+ url: safeAuthorizationUrl(event.links?.[0]?.url),
142
+ })
143
+ case "progress":
144
+ return { message: bounded(event.message, MAX_MESSAGE_CHARS) }
145
+ default:
146
+ return undefined
147
+ }
148
+ }
149
+
150
+ function authorizationPrompt(prompt) {
151
+ const signal = prompt.signal === undefined ? {} : { signal: prompt.signal }
152
+ if (prompt.type === "select") {
153
+ return {
154
+ ...signal,
155
+ kind: "select",
156
+ message: bounded(prompt.message, MAX_MESSAGE_CHARS),
157
+ options: prompt.options.slice(0, MAX_OPTIONS).map((option) => compact({
158
+ id: bounded(option.id, MAX_CODE_CHARS),
159
+ label: bounded(option.label, MAX_MESSAGE_CHARS),
160
+ description: option.description === undefined
161
+ ? undefined
162
+ : bounded(option.description, MAX_MESSAGE_CHARS),
163
+ })),
164
+ }
165
+ }
166
+ return compact({
167
+ ...signal,
168
+ kind: prompt.type === "secret" ? "secret" : "text",
169
+ message: bounded(prompt.message, MAX_MESSAGE_CHARS),
170
+ placeholder: prompt.placeholder === undefined
171
+ ? undefined
172
+ : bounded(prompt.placeholder, MAX_PLACEHOLDER_CHARS),
173
+ })
174
+ }
175
+
176
+ function safeAuthorizationUrl(value) {
177
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_URL_CHARS) return undefined
178
+ try {
179
+ const url = new URL(value)
180
+ if (url.username !== "" || url.password !== "") return undefined
181
+ if (url.protocol === "https:") return url.href
182
+ if (url.protocol === "http:" && isLoopback(url.hostname)) return url.href
183
+ } catch {
184
+ // Invalid URLs are omitted from the neutral notice.
185
+ }
186
+ return undefined
187
+ }
188
+
189
+ function isLoopback(hostname) {
190
+ return hostname === "localhost"
191
+ || hostname === "[::1]"
192
+ || /^127(?:\.\d{1,3}){3}$/u.test(hostname)
193
+ }
194
+
195
+ function bounded(value, maxLength) {
196
+ const text = String(value)
197
+ return text.length <= maxLength ? text : text.slice(0, maxLength)
198
+ }
199
+
200
+ function compact(value) {
201
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined))
202
+ }
@@ -0,0 +1,164 @@
1
+ import {
2
+ CODEX_CREDENTIAL_KEY,
3
+ CODEX_PROVIDER_ID,
4
+ } from "./codex-identifiers.mjs"
5
+
6
+ export {
7
+ CODEX_CREDENTIAL_KEY,
8
+ CODEX_PROVIDER_ID,
9
+ } from "./codex-identifiers.mjs"
10
+
11
+ const MAX_JSON_DEPTH = 24
12
+ const MAX_JSON_NODES = 8_192
13
+ const MAX_STRING_CHARS = 1_048_576
14
+
15
+ export class CodexCredentialStoreError extends Error {
16
+ constructor(message, code = "INVALID_CODEX_CREDENTIAL", options) {
17
+ super(message, options)
18
+ this.name = "CodexCredentialStoreError"
19
+ this.code = code
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Adapt the Harness credential-record seam to pi-ai's provider-scoped store.
25
+ *
26
+ * The adapter owns exactly one record. It never enumerates or interprets any
27
+ * other plugin's records, and it accepts OAuth grants only: an API key cannot
28
+ * accidentally become an alternate authentication path for this provider.
29
+ */
30
+ export function createCodexCredentialStore(credentials) {
31
+ if (credentials === null || typeof credentials !== "object") {
32
+ throw new TypeError("credentials must be a credential provider")
33
+ }
34
+
35
+ return Object.freeze({
36
+ async read(providerId) {
37
+ if (providerId !== CODEX_PROVIDER_ID) return undefined
38
+ return credentialFromRecord(await credentials.readRecord(CODEX_CREDENTIAL_KEY), true)
39
+ },
40
+
41
+ async list() {
42
+ const info = await credentials.describeRecord(CODEX_CREDENTIAL_KEY)
43
+ if (info.configured !== true) return []
44
+ if (info.kind !== "grant") {
45
+ throw invalidCredential("stored Codex credential is not an OAuth grant")
46
+ }
47
+ return [{ providerId: CODEX_PROVIDER_ID, type: "oauth" }]
48
+ },
49
+
50
+ async modify(providerId, mutate) {
51
+ if (providerId !== CODEX_PROVIDER_ID) {
52
+ throw new CodexCredentialStoreError(
53
+ "the Codex credential store does not own this provider",
54
+ "UNOWNED_PROVIDER",
55
+ )
56
+ }
57
+ if (typeof mutate !== "function") throw new TypeError("mutate must be a function")
58
+
59
+ const result = await credentials.modifyRecord(CODEX_CREDENTIAL_KEY, async (current) => {
60
+ // An explicit sign-in may repair an incompatible record. Request-time
61
+ // refresh still fails closed because a callback returning undefined
62
+ // leaves the incompatible record untouched and the final decode rejects.
63
+ const decoded = credentialFromRecord(current, false)
64
+ const next = await mutate(decoded)
65
+ return next === undefined ? undefined : recordFromCredential(next)
66
+ })
67
+ return credentialFromRecord(result, true)
68
+ },
69
+
70
+ async delete(providerId) {
71
+ if (providerId !== CODEX_PROVIDER_ID) return
72
+ await credentials.deleteRecord(CODEX_CREDENTIAL_KEY)
73
+ },
74
+ })
75
+ }
76
+
77
+ function credentialFromRecord(record, strict) {
78
+ if (record === undefined) return undefined
79
+ if (record?.kind !== "grant") {
80
+ if (!strict) return undefined
81
+ throw invalidCredential("stored Codex credential is not an OAuth grant")
82
+ }
83
+
84
+ try {
85
+ return oauthCredential(record.payload)
86
+ } catch (error) {
87
+ if (!strict) return undefined
88
+ if (error instanceof CodexCredentialStoreError) throw error
89
+ throw invalidCredential("stored Codex OAuth grant is invalid", error)
90
+ }
91
+ }
92
+
93
+ function recordFromCredential(credential) {
94
+ return { kind: "grant", payload: oauthCredential(credential) }
95
+ }
96
+
97
+ function oauthCredential(value) {
98
+ const payload = cloneJson(value)
99
+ if (!plainRecord(payload) || payload.type !== "oauth") {
100
+ throw invalidCredential("Codex credentials must use OAuth")
101
+ }
102
+ for (const field of ["access", "refresh"]) {
103
+ if (typeof payload[field] !== "string" || payload[field].length === 0) {
104
+ throw invalidCredential(`Codex OAuth credential is missing ${field}`)
105
+ }
106
+ }
107
+ if (!Number.isSafeInteger(payload.expires) || payload.expires <= 0) {
108
+ throw invalidCredential("Codex OAuth credential has an invalid expiry")
109
+ }
110
+ return payload
111
+ }
112
+
113
+ function cloneJson(value) {
114
+ const state = { nodes: 0, stack: new Set() }
115
+ validateJson(value, state, 0)
116
+ try {
117
+ return JSON.parse(JSON.stringify(value))
118
+ } catch (error) {
119
+ throw invalidCredential("Codex OAuth credential is not JSON compatible", error)
120
+ }
121
+ }
122
+
123
+ function validateJson(value, state, depth) {
124
+ state.nodes += 1
125
+ if (state.nodes > MAX_JSON_NODES || depth > MAX_JSON_DEPTH) {
126
+ throw invalidCredential("Codex OAuth credential exceeds structural limits")
127
+ }
128
+ if (value === null || typeof value === "boolean") return
129
+ if (typeof value === "number") {
130
+ if (!Number.isFinite(value)) throw invalidCredential("Codex OAuth credential contains an invalid number")
131
+ return
132
+ }
133
+ if (typeof value === "string") {
134
+ if (value.length > MAX_STRING_CHARS) throw invalidCredential("Codex OAuth credential contains an oversized string")
135
+ return
136
+ }
137
+ if (typeof value !== "object") {
138
+ throw invalidCredential("Codex OAuth credential contains a non-JSON value")
139
+ }
140
+ if (state.stack.has(value)) throw invalidCredential("Codex OAuth credential contains a cycle")
141
+ const prototype = Object.getPrototypeOf(value)
142
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
143
+ throw invalidCredential("Codex OAuth credential contains a non-plain object")
144
+ }
145
+
146
+ state.stack.add(value)
147
+ if (Array.isArray(value)) {
148
+ for (const item of value) validateJson(item, state, depth + 1)
149
+ } else {
150
+ for (const [key, item] of Object.entries(value)) {
151
+ if (key.length > MAX_STRING_CHARS) throw invalidCredential("Codex OAuth credential contains an oversized key")
152
+ validateJson(item, state, depth + 1)
153
+ }
154
+ }
155
+ state.stack.delete(value)
156
+ }
157
+
158
+ function plainRecord(value) {
159
+ return typeof value === "object" && value !== null && !Array.isArray(value)
160
+ }
161
+
162
+ function invalidCredential(message, cause) {
163
+ return new CodexCredentialStoreError(message, "INVALID_CODEX_CREDENTIAL", cause === undefined ? undefined : { cause })
164
+ }
@@ -0,0 +1,4 @@
1
+ /** Stable public-facing route and private provider/credential identities. */
2
+ export const CODEX_ROUTE_ID = "dsh-codex"
3
+ export const CODEX_PROVIDER_ID = "openai-codex"
4
+ export const CODEX_CREDENTIAL_KEY = "dsh-codex/openai-codex"
@@ -0,0 +1,137 @@
1
+ import {
2
+ clampThinkingLevel,
3
+ createProvider,
4
+ } from "@earendil-works/pi-ai"
5
+ import { buildBaseOptions } from "@earendil-works/pi-ai/api/simple-options"
6
+ import { stream as streamCodexResponses } from "@earendil-works/pi-ai/api/openai-codex-responses"
7
+ import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex"
8
+
9
+ import { codexTransportSessionId } from "./codex-session-resources.mjs"
10
+
11
+ const FACTORY_OPTION_KEYS = new Set([
12
+ "resolveSessionPreferences",
13
+ "resolveTransportSessionId",
14
+ "serviceTier",
15
+ ])
16
+ const TRANSPORTS = new Set([
17
+ "auto",
18
+ "sse",
19
+ "websocket",
20
+ "websocket-cached",
21
+ ])
22
+
23
+ function plainObject(value) {
24
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
25
+ const prototype = Object.getPrototypeOf(value)
26
+ return prototype === Object.prototype || prototype === null
27
+ }
28
+
29
+ function validateFactoryOptions(options) {
30
+ if (!plainObject(options)) throw new TypeError("options must be a plain object")
31
+ for (const key of Object.keys(options)) {
32
+ if (!FACTORY_OPTION_KEYS.has(key)) throw new TypeError(`unknown option: ${key}`)
33
+ }
34
+ if (options.serviceTier !== undefined && options.serviceTier !== "priority") {
35
+ throw new TypeError("serviceTier must be priority when provided")
36
+ }
37
+ if (
38
+ options.resolveSessionPreferences !== undefined
39
+ && typeof options.resolveSessionPreferences !== "function"
40
+ ) {
41
+ throw new TypeError("resolveSessionPreferences must be a function")
42
+ }
43
+ if (
44
+ options.resolveTransportSessionId !== undefined
45
+ && typeof options.resolveTransportSessionId !== "function"
46
+ ) {
47
+ throw new TypeError("resolveTransportSessionId must be a function")
48
+ }
49
+ }
50
+
51
+ function resolveSessionPreferenceOptions(resolver, sessionId) {
52
+ const preferences = resolver(sessionId) ?? { fast: false }
53
+ if (!plainObject(preferences)) {
54
+ throw new TypeError("session preferences must be a plain object")
55
+ }
56
+ for (const key of Object.keys(preferences)) {
57
+ if (key !== "fast" && key !== "transport") {
58
+ throw new TypeError(`unknown session preference: ${key}`)
59
+ }
60
+ }
61
+ const fast = preferences.fast ?? false
62
+ if (typeof fast !== "boolean") {
63
+ throw new TypeError("session preferences fast must be a boolean")
64
+ }
65
+ const transport = preferences.transport
66
+ if (transport !== undefined && !TRANSPORTS.has(transport)) {
67
+ throw new TypeError("session preferences transport is invalid")
68
+ }
69
+ return { fast, transport }
70
+ }
71
+
72
+ function reasoningOptions(model, reasoning) {
73
+ if (reasoning === undefined) return {}
74
+ const effort = clampThinkingLevel(model, reasoning)
75
+ return effort === "off" ? {} : { reasoningEffort: effort }
76
+ }
77
+
78
+ /**
79
+ * Build the public pi-ai Codex provider with an optional per-session Fast policy.
80
+ * Authentication, models, transports, and response conversion remain owned by
81
+ * the published provider and PiAiAdapter; only the documented service tier is
82
+ * added to the provider-specific request options.
83
+ */
84
+ export function createCodexPiProvider(options = {}) {
85
+ validateFactoryOptions(options)
86
+ const source = openaiCodexProvider()
87
+ const resolveSessionPreferences = options.resolveSessionPreferences
88
+ const resolveTransportSessionId = options.resolveTransportSessionId
89
+ ?? codexTransportSessionId
90
+
91
+ return createProvider({
92
+ id: source.id,
93
+ name: source.name,
94
+ baseUrl: source.baseUrl,
95
+ headers: source.headers,
96
+ auth: source.auth,
97
+ models: source.getModels(),
98
+ ...(source.filterModels === undefined
99
+ ? {}
100
+ : {
101
+ filterModels: (models, credential) => source.filterModels(models, credential),
102
+ }),
103
+ api: {
104
+ stream: streamCodexResponses,
105
+ streamSimple(model, context, streamOptions = {}) {
106
+ const sessionPreferences = resolveSessionPreferences === undefined
107
+ ? undefined
108
+ : resolveSessionPreferenceOptions(
109
+ resolveSessionPreferences,
110
+ streamOptions.sessionId,
111
+ )
112
+ const serviceTier = sessionPreferences === undefined
113
+ ? options.serviceTier
114
+ : sessionPreferences.fast
115
+ ? "priority"
116
+ : undefined
117
+ const transportSessionId = resolveTransportSessionId(streamOptions.sessionId)
118
+ if (
119
+ transportSessionId !== undefined
120
+ && (typeof transportSessionId !== "string" || transportSessionId.length === 0)
121
+ ) {
122
+ throw new TypeError("resolved transport session id must be a non-empty string")
123
+ }
124
+
125
+ return streamCodexResponses(model, context, {
126
+ ...buildBaseOptions(model, context, streamOptions),
127
+ sessionId: transportSessionId,
128
+ ...reasoningOptions(model, streamOptions.reasoning),
129
+ ...(serviceTier === undefined ? {} : { serviceTier }),
130
+ ...(sessionPreferences?.transport === undefined
131
+ ? {}
132
+ : { transport: sessionPreferences.transport }),
133
+ })
134
+ },
135
+ },
136
+ })
137
+ }