threadwire 0.1.8 → 0.1.9

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.
@@ -0,0 +1,325 @@
1
+ // @ts-check
2
+
3
+ import {createServer} from "node:http"
4
+ import {once} from "node:events"
5
+ import {abortable} from "./absolute-deadline.js"
6
+ import {
7
+ createKimiGrantStore,
8
+ kimiBearerToken,
9
+ parseApprovedKimiModels,
10
+ validateKimiBrokerRequest,
11
+ validateKimiChatBody
12
+ } from "./kimi-model-broker-policy.js"
13
+ import {openKimiOAuthStore} from "./kimi-oauth-store.js"
14
+
15
+ const MAX_REQUEST_BYTES = 2_097_152
16
+ const MAX_RESPONSE_BYTES = 8_388_608
17
+ const UPSTREAM_URL = "https://api.kimi.com/coding/v1/chat/completions"
18
+
19
+ /** @typedef {{provider: "kimi", runId: string, networkId: string, taskId: string, sessionId: string, modelAlias: string, model: string, ttlMs: number}} KimiGrant */
20
+ /** @typedef {{alias: string, model: string, protocol: "kimi"}} ApprovedKimiModel */
21
+ /** @typedef {ReturnType<typeof createKimiGrantStore>} KimiGrantStore */
22
+ /** @typedef {Awaited<ReturnType<typeof openKimiOAuthStore>>} KimiOAuthStore */
23
+
24
+ /** @param {{environment?: NodeJS.ProcessEnv, fetchImplementation?: typeof fetch, oauthFetchImplementation?: typeof fetch}} [options] */
25
+ export async function startKimiModelBroker(options = {}) {
26
+ const environment = options.environment ?? process.env
27
+ const host = safeHost(environment.THREADWIRE_KIMI_MODEL_BROKER_HOST ?? "0.0.0.0")
28
+ const port = portValue(environment.THREADWIRE_KIMI_MODEL_BROKER_PORT, 8791)
29
+ const adminToken = authoritySetting(environment.THREADWIRE_KIMI_MODEL_BROKER_ADMIN_TOKEN, "THREADWIRE_KIMI_MODEL_BROKER_ADMIN_TOKEN")
30
+ const approvedModels = parseApprovedKimiModels(environment.THREADWIRE_ALLOWED_KIMI_MODELS)
31
+ const oauth = await openKimiOAuthStore({
32
+ file: safeSetting(environment.THREADWIRE_KIMI_OAUTH_FILE, "THREADWIRE_KIMI_OAUTH_FILE"),
33
+ fetchImplementation: options.oauthFetchImplementation ?? fetch
34
+ })
35
+ const fetchImplementation = options.fetchImplementation ?? fetch
36
+ const grants = createKimiGrantStore()
37
+ /** @type {Map<string, import("node:http").Server>} */
38
+ const listeners = new Map()
39
+ /** @type {Map<string, {controller: AbortController, timer: NodeJS.Timeout, sockets: Set<import("node:net").Socket>}>} */
40
+ const runtimes = new Map()
41
+ /** @type {Set<import("node:net").Socket>} */
42
+ const controlSockets = new Set()
43
+ let stopping = false
44
+
45
+ /** @param {string} token */
46
+ const revokeGrant = async (token) => {
47
+ grants.revoke(token)
48
+ const runtime = runtimes.get(token)
49
+ runtimes.delete(token)
50
+ if (runtime !== undefined) {
51
+ clearTimeout(runtime.timer)
52
+ runtime.controller.abort(new Error("Kimi broker grant revoked"))
53
+ for (const socket of runtime.sockets) socket.destroy()
54
+ runtime.sockets.clear()
55
+ }
56
+ await closeListener(listeners, token)
57
+ }
58
+
59
+ const server = createServer(async (request, response) => {
60
+ try {
61
+ if (stopping) throw new Error("Kimi model broker is shutting down")
62
+ if (request.method === "GET" && request.url === "/healthz") {
63
+ const health = await oauth.health()
64
+ sendJson(response, health.ready ? 200 : 503, health.ready
65
+ ? {ok: true, models: approvedModels.size}
66
+ : {ok: false, needsLogin: true})
67
+ return
68
+ }
69
+ requireAdmin(request.headers.authorization, adminToken)
70
+ if (request.method === "POST" && request.url === "/admin/grants") {
71
+ const health = await oauth.health()
72
+ if (!health.ready) throw new Error("Kimi OAuth login required")
73
+ const body = await readJson(request)
74
+ const selection = parseGrant(body, approvedModels)
75
+ const token = grants.issue(selection.grant)
76
+ const controller = new AbortController()
77
+ /** @type {Set<import("node:net").Socket>} */
78
+ const sockets = new Set()
79
+ const listener = createServer((workerRequest, workerResponse) => {
80
+ proxyWorkerRequest(workerRequest, workerResponse, {
81
+ token, grant: selection.grant, grants, oauth, fetchImplementation, signal: controller.signal
82
+ }).catch((error) => {
83
+ if (workerResponse.destroyed) return
84
+ const status = clientError(error) ? 403 : 502
85
+ sendJson(workerResponse, status, {error: status === 403 ? "request denied" : "Kimi model broker unavailable"})
86
+ })
87
+ })
88
+ listener.on("connection", (socket) => {
89
+ if (controller.signal.aborted) return socket.destroy()
90
+ sockets.add(socket)
91
+ socket.once("close", () => sockets.delete(socket))
92
+ })
93
+ try { await listen(listener, 0, selection.brokerAddress) } catch (error) {
94
+ grants.revoke(token)
95
+ throw error
96
+ }
97
+ listeners.set(token, listener)
98
+ const timer = setTimeout(() => { void revokeGrant(token) }, selection.grant.ttlMs)
99
+ timer.unref()
100
+ runtimes.set(token, {controller, timer, sockets})
101
+ const address = listener.address()
102
+ if (address === null || typeof address === "string") throw new Error("Kimi broker listener unavailable")
103
+ sendJson(response, 201, {token, port: address.port})
104
+ return
105
+ }
106
+ if (request.method === "POST" && request.url?.startsWith("/admin/grants/") && request.url.endsWith("/activate")) {
107
+ const token = decodeURIComponent(request.url.slice("/admin/grants/".length, -"/activate".length))
108
+ grants.activate(token)
109
+ sendJson(response, 200, {ok: true})
110
+ return
111
+ }
112
+ if (request.method === "DELETE" && request.url?.startsWith("/admin/grants/")) {
113
+ const token = decodeURIComponent(request.url.slice("/admin/grants/".length))
114
+ await revokeGrant(token)
115
+ response.writeHead(204)
116
+ response.end()
117
+ return
118
+ }
119
+ sendJson(response, 403, {error: "request denied"})
120
+ } catch (error) {
121
+ const status = clientError(error) ? 403 : 503
122
+ sendJson(response, status, {error: status === 403 ? "request denied" : "Kimi model broker unavailable"})
123
+ }
124
+ })
125
+ server.on("connection", (socket) => {
126
+ controlSockets.add(socket)
127
+ socket.once("close", () => controlSockets.delete(socket))
128
+ })
129
+ await listen(server, port, host)
130
+ return {
131
+ server,
132
+ activeListeners: () => listeners.size,
133
+ close: async () => {
134
+ if (stopping) return
135
+ stopping = true
136
+ for (const socket of controlSockets) socket.destroy()
137
+ await Promise.all([...listeners.keys()].map(revokeGrant))
138
+ await boundedClose(server)
139
+ },
140
+ config: {host, port, models: [...approvedModels.keys()]}
141
+ }
142
+ }
143
+
144
+ /**
145
+ * @param {import("node:http").IncomingMessage} request
146
+ * @param {import("node:http").ServerResponse} response
147
+ * @param {{token: string, grant: KimiGrant, grants: KimiGrantStore, oauth: KimiOAuthStore, fetchImplementation: typeof fetch, signal: AbortSignal}} options
148
+ */
149
+ async function proxyWorkerRequest(request, response, options) {
150
+ throwIfAborted(options.signal)
151
+ const policyHeaders = {...request.headers}
152
+ delete policyHeaders.host
153
+ delete policyHeaders.connection
154
+ delete policyHeaders["transfer-encoding"]
155
+ validateKimiBrokerRequest({method: request.method ?? "", path: request.url ?? "", headers: policyHeaders})
156
+ const token = kimiBearerToken(request.headers.authorization)
157
+ if (token !== options.token) throw new Error("Kimi broker request denied")
158
+ const context = {
159
+ provider: "kimi", runId: options.grant.runId,
160
+ networkId: options.grant.networkId, taskId: options.grant.taskId,
161
+ sessionId: options.grant.sessionId, modelAlias: options.grant.modelAlias
162
+ }
163
+ const grant = options.grants.authorize(token, context)
164
+ const body = await readBody(request, options.signal)
165
+ options.grants.authorize(token, context)
166
+ validateKimiChatBody(body, grant.model)
167
+ const accessToken = await options.oauth.getAccessToken({signal: options.signal})
168
+ throwIfAborted(options.signal)
169
+ const upstream = await abortable(options.fetchImplementation(UPSTREAM_URL, {
170
+ method: "POST",
171
+ redirect: "error",
172
+ headers: {
173
+ authorization: `Bearer ${accessToken}`,
174
+ "content-type": "application/json",
175
+ accept: "text/event-stream",
176
+ "user-agent": "threadwire-kimi-model-broker/1"
177
+ },
178
+ body,
179
+ signal: options.signal
180
+ }), options.signal)
181
+ throwIfAborted(options.signal)
182
+ const contentType = safeContentType(upstream.headers.get("content-type"))
183
+ response.writeHead(upstream.status, {"content-type": contentType, "cache-control": "no-store"})
184
+ await streamResponse(upstream, response, options.signal)
185
+ }
186
+
187
+ /** @param {Response} upstream @param {import("node:http").ServerResponse} response @param {AbortSignal} signal */
188
+ async function streamResponse(upstream, response, signal) {
189
+ if (upstream.body === null) { response.end(); return }
190
+ const reader = upstream.body.getReader()
191
+ let bytes = 0
192
+ try {
193
+ while (true) {
194
+ throwIfAborted(signal)
195
+ const {done, value} = await reader.read()
196
+ if (done) break
197
+ bytes += value.byteLength
198
+ if (bytes > MAX_RESPONSE_BYTES) throw new Error("Kimi broker response exceeded capacity")
199
+ if (!response.write(Buffer.from(value))) await abortable(once(response, "drain"), signal)
200
+ }
201
+ response.end()
202
+ } catch (error) {
203
+ await reader.cancel().catch(() => {})
204
+ response.destroy(error instanceof Error ? error : undefined)
205
+ throw error
206
+ } finally { reader.releaseLock() }
207
+ }
208
+
209
+ /** @param {unknown} value @param {Map<string, ApprovedKimiModel>} approvedModels @returns {{brokerAddress: string, grant: KimiGrant}} */
210
+ function parseGrant(value, approvedModels) {
211
+ if (!record(value) || !exactKeys(value, ["brokerAddress", "modelAlias", "networkId", "provider", "runId", "sessionId", "taskId", "ttlMs"])
212
+ || value.provider !== "kimi" || typeof value.runId !== "string" || typeof value.networkId !== "string"
213
+ || typeof value.taskId !== "string" || typeof value.sessionId !== "string"
214
+ || typeof value.modelAlias !== "string" || typeof value.brokerAddress !== "string" || typeof value.ttlMs !== "number") {
215
+ throw new Error("Invalid Kimi broker grant")
216
+ }
217
+ if (!/^(?:\d{1,3}\.){3}\d{1,3}$/u.test(value.brokerAddress)) throw new Error("Invalid Kimi broker grant")
218
+ const model = approvedModels.get(value.modelAlias)
219
+ if (model === undefined) throw new Error("Kimi model is not allowed")
220
+ return {
221
+ brokerAddress: value.brokerAddress,
222
+ grant: {
223
+ provider: "kimi", runId: value.runId, networkId: value.networkId,
224
+ taskId: value.taskId, sessionId: value.sessionId,
225
+ modelAlias: model.alias, model: model.model, ttlMs: value.ttlMs
226
+ }
227
+ }
228
+ }
229
+
230
+ /** @param {import("node:http").IncomingMessage} request @returns {Promise<unknown>} */
231
+ async function readJson(request) {
232
+ const body = await readBody(request)
233
+ try { return JSON.parse(body.toString("utf8")) } catch { throw new Error("Invalid Kimi broker request") }
234
+ }
235
+
236
+ /** @param {import("node:http").IncomingMessage} request @param {AbortSignal | undefined} [signal] @returns {Promise<Buffer>} */
237
+ async function readBody(request, signal) {
238
+ /** @type {Buffer[]} */
239
+ const chunks = []
240
+ let size = 0
241
+ for await (const chunk of request) {
242
+ throwIfAborted(signal)
243
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
244
+ size += buffer.length
245
+ if (size > MAX_REQUEST_BYTES) throw new Error("Kimi broker request exceeded capacity")
246
+ chunks.push(buffer)
247
+ }
248
+ throwIfAborted(signal)
249
+ return Buffer.concat(chunks)
250
+ }
251
+
252
+ /** @param {AbortSignal | undefined} signal */
253
+ function throwIfAborted(signal) {
254
+ if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("Kimi broker request aborted")
255
+ }
256
+ /** @param {string | undefined} value @param {string} expected */
257
+ function requireAdmin(value, expected) {
258
+ if (value !== `Bearer ${expected}`) throw new Error("Kimi broker request denied")
259
+ }
260
+ /** @param {string | undefined} value @param {string} name @returns {string} */
261
+ function safeSetting(value, name) {
262
+ if (typeof value !== "string" || value.length === 0 || /[\0\r\n]/u.test(value)) throw new Error(`${name} is required`)
263
+ return value
264
+ }
265
+ /** @param {string | undefined} value @param {string} name @returns {string} */
266
+ function authoritySetting(value, name) {
267
+ const authority = safeSetting(value, name)
268
+ if (authority.length < 32 || authority.length > 512) throw new Error(`${name} must be at least 32 characters`)
269
+ return authority
270
+ }
271
+ /** @param {string} value @returns {string} */
272
+ function safeHost(value) {
273
+ if (value !== "0.0.0.0" && value !== "127.0.0.1" && value !== "::") throw new Error("Invalid Kimi model broker host")
274
+ return value
275
+ }
276
+ /** @param {string | undefined} value @param {number} fallback @returns {number} */
277
+ function portValue(value, fallback) {
278
+ if (value === undefined) return fallback
279
+ const number = Number(value)
280
+ if (!/^\d+$/u.test(value) || !Number.isSafeInteger(number) || number < 1 || number > 65535) throw new Error("Invalid Kimi model broker port")
281
+ return number
282
+ }
283
+ /** @param {string | null} value @returns {string} */
284
+ function safeContentType(value) {
285
+ return value && /^[\w.+-]+\/[\w.+-]+(?:;\s*charset=[\w-]+)?$/u.test(value) ? value : "application/octet-stream"
286
+ }
287
+ /** @param {import("node:http").ServerResponse} response @param {number} status @param {unknown} value */
288
+ function sendJson(response, status, value) {
289
+ if (response.headersSent || response.destroyed) return
290
+ response.writeHead(status, {"content-type": "application/json", "cache-control": "no-store"})
291
+ response.end(JSON.stringify(value))
292
+ }
293
+ /** @param {unknown} error @returns {boolean} */
294
+ function clientError(error) {
295
+ return error instanceof Error && /(denied|invalid|unknown token|expired|pending|already active|not allowed)/iu.test(error.message)
296
+ }
297
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
298
+ function record(value) { return typeof value === "object" && value !== null && !Array.isArray(value) }
299
+ /** @param {Record<string, unknown>} value @param {readonly string[]} expected @returns {boolean} */
300
+ function exactKeys(value, expected) {
301
+ const actual = Object.keys(value).sort()
302
+ const wanted = [...expected].sort()
303
+ return actual.length === wanted.length && actual.every((key, index) => key === wanted[index])
304
+ }
305
+ /** @param {import("node:http").Server} server @param {number} port @param {string} host @returns {Promise<void>} */
306
+ function listen(server, port, host) {
307
+ return new Promise((resolve, reject) => {
308
+ server.once("error", reject)
309
+ server.listen(port, host, () => { server.off("error", reject); resolve(undefined) })
310
+ })
311
+ }
312
+ /** @param {import("node:http").Server} server @returns {Promise<void>} */
313
+ function boundedClose(server) {
314
+ return Promise.race([
315
+ new Promise((resolve) => server.close(() => resolve(undefined))),
316
+ new Promise((resolve) => { const timer = setTimeout(resolve, 1000); timer.unref() })
317
+ ])
318
+ }
319
+ /** @param {Map<string, import("node:http").Server>} listeners @param {string} token */
320
+ async function closeListener(listeners, token) {
321
+ const listener = listeners.get(token)
322
+ listeners.delete(token)
323
+ if (listener === undefined) return
324
+ await boundedClose(listener)
325
+ }
@@ -0,0 +1,267 @@
1
+ // @ts-check
2
+
3
+ import {randomBytes} from "node:crypto"
4
+ import {constants} from "node:fs"
5
+ import {chmod, lstat, open, rename, unlink} from "node:fs/promises"
6
+ import {dirname, isAbsolute, join} from "node:path"
7
+
8
+ const MAX_CREDENTIAL_BYTES = 65_536
9
+ const MAX_TOKEN_BYTES = 8_192
10
+ const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"
11
+ const TOKEN_URL = "https://auth.kimi.com/api/oauth/token"
12
+ const EXACT_KEYS = ["access_token", "expires_at", "expires_in", "refresh_token", "scope", "token_type"]
13
+
14
+ /** @typedef {{access_token: string, expires_at: number, expires_in: number, refresh_token: string, scope: string, token_type: "Bearer"}} KimiCredential */
15
+
16
+ /**
17
+ * @param {{
18
+ * file: string,
19
+ * expectedUid?: number,
20
+ * now?: () => number,
21
+ * fetchImplementation?: typeof fetch,
22
+ * maxRefreshAttempts?: number,
23
+ * sleep?: (milliseconds: number) => Promise<void>,
24
+ * durabilityObserver?: (phase: string) => void | Promise<void>
25
+ * }} options
26
+ */
27
+ export async function openKimiOAuthStore(options) {
28
+ if (!isAbsolute(options.file) || options.file.includes("\0")) throw unavailable()
29
+ const expectedUid = options.expectedUid ?? process.getuid?.() ?? -1
30
+ if (!Number.isSafeInteger(expectedUid) || expectedUid < 0) throw unavailable()
31
+ const now = options.now ?? (() => Math.floor(Date.now() / 1000))
32
+ const fetchImplementation = options.fetchImplementation ?? fetch
33
+ const maxRefreshAttempts = options.maxRefreshAttempts ?? 3
34
+ if (!Number.isSafeInteger(maxRefreshAttempts) || maxRefreshAttempts < 1 || maxRefreshAttempts > 3) throw unavailable()
35
+ const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => {
36
+ const timer = setTimeout(resolve, milliseconds)
37
+ timer.unref()
38
+ }))
39
+ let credential = await readCredential(options.file, expectedUid)
40
+ /** @type {Promise<string> | undefined} */
41
+ let refreshInFlight
42
+
43
+ /** @param {AbortSignal | undefined} signal */
44
+ const refresh = async (signal) => {
45
+ if (credential.access_token.length === 0 || credential.refresh_token.length === 0) throw needsLogin()
46
+ /** @type {unknown} */
47
+ let lastFailure
48
+ for (let attempt = 0; attempt < maxRefreshAttempts; attempt++) {
49
+ if (signal?.aborted) throw signal.reason
50
+ let response
51
+ try {
52
+ const signals = [AbortSignal.timeout(30_000)]
53
+ if (signal !== undefined) signals.push(signal)
54
+ response = await fetchImplementation(TOKEN_URL, {
55
+ method: "POST",
56
+ redirect: "error",
57
+ headers: {accept: "application/json", "content-type": "application/x-www-form-urlencoded"},
58
+ body: new URLSearchParams({
59
+ client_id: CLIENT_ID,
60
+ grant_type: "refresh_token",
61
+ refresh_token: credential.refresh_token
62
+ }).toString(),
63
+ signal: AbortSignal.any(signals)
64
+ })
65
+ } catch (error) {
66
+ if (signal?.aborted) throw signal.reason
67
+ lastFailure = error
68
+ if (attempt + 1 < maxRefreshAttempts) { await sleep(2 ** attempt * 100); continue }
69
+ throw refreshUnavailable(lastFailure)
70
+ }
71
+ const bytes = await readCappedResponse(response, signal).catch((error) => {
72
+ lastFailure = error
73
+ return undefined
74
+ })
75
+ if (bytes === undefined) {
76
+ if (attempt + 1 < maxRefreshAttempts) { await sleep(2 ** attempt * 100); continue }
77
+ throw refreshUnavailable(lastFailure)
78
+ }
79
+ /** @type {Record<string, unknown>} */
80
+ let body = {}
81
+ try {
82
+ const parsed = JSON.parse(bytes.toString("utf8"))
83
+ if (record(parsed)) body = parsed
84
+ } catch { /* Invalid response bodies remain untrusted. */ }
85
+ if (response.status === 401 || response.status === 403 || body.error === "invalid_grant") {
86
+ credential = {
87
+ access_token: "", refresh_token: "", expires_at: 0,
88
+ scope: credential.scope, token_type: credential.token_type, expires_in: 0
89
+ }
90
+ await persistCredential(options.file, expectedUid, credential, options.durabilityObserver)
91
+ throw needsLogin()
92
+ }
93
+ if (response.ok) {
94
+ const replacement = tokenResponse(body, credential, now())
95
+ await persistCredential(options.file, expectedUid, replacement, options.durabilityObserver)
96
+ credential = replacement
97
+ return credential.access_token
98
+ }
99
+ if (![429, 500, 502, 503, 504].includes(response.status) || attempt + 1 >= maxRefreshAttempts) {
100
+ throw refreshUnavailable()
101
+ }
102
+ await sleep(2 ** attempt * 100)
103
+ }
104
+ throw refreshUnavailable(lastFailure)
105
+ }
106
+
107
+ return {
108
+ /** @param {{signal?: AbortSignal, force?: boolean}} [request] */
109
+ async getAccessToken(request = {}) {
110
+ if (credential.access_token.length === 0) throw needsLogin()
111
+ const threshold = Math.max(300, credential.expires_in * 0.5)
112
+ if (request.force !== true && credential.expires_at - now() > threshold) return credential.access_token
113
+ if (refreshInFlight !== undefined) return refreshInFlight
114
+ const operation = refresh(request.signal).finally(() => {
115
+ if (refreshInFlight === operation) refreshInFlight = undefined
116
+ })
117
+ refreshInFlight = operation
118
+ return operation
119
+ },
120
+ async health() {
121
+ return credential.access_token.length === 0
122
+ ? {ready: false, needsLogin: true}
123
+ : {ready: true, needsLogin: false}
124
+ }
125
+ }
126
+ }
127
+
128
+ /** @param {string} file @param {number} expectedUid @returns {Promise<KimiCredential>} */
129
+ async function readCredential(file, expectedUid) {
130
+ const directory = dirname(file)
131
+ try {
132
+ const parent = await lstat(directory, {bigint: true})
133
+ if (!parent.isDirectory() || parent.isSymbolicLink() || parent.uid !== BigInt(expectedUid)
134
+ || (parent.mode & 0o077n) !== 0n) throw unavailable()
135
+ let handle
136
+ try {
137
+ handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
138
+ const metadata = await handle.stat({bigint: true})
139
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1n
140
+ || metadata.uid !== BigInt(expectedUid) || (metadata.mode & 0o777n) !== 0o600n
141
+ || metadata.size < 1n || metadata.size > BigInt(MAX_CREDENTIAL_BYTES)) throw unavailable()
142
+ const bytes = await handle.readFile()
143
+ return parseCredential(bytes)
144
+ } finally { await handle?.close().catch(() => {}) }
145
+ } catch (error) {
146
+ if (error instanceof Error && error.message === unavailable().message) throw error
147
+ throw unavailable()
148
+ }
149
+ }
150
+
151
+ /** @param {Buffer} bytes @returns {KimiCredential} */
152
+ function parseCredential(bytes) {
153
+ /** @type {unknown} */
154
+ let value
155
+ try { value = JSON.parse(bytes.toString("utf8")) } catch { throw unavailable() }
156
+ if (!record(value) || !exactKeys(value, EXACT_KEYS)
157
+ || !safeToken(value.access_token, true) || !safeToken(value.refresh_token, true)
158
+ || typeof value.expires_at !== "number" || !Number.isSafeInteger(value.expires_at) || value.expires_at < 0
159
+ || typeof value.expires_in !== "number" || !Number.isSafeInteger(value.expires_in) || value.expires_in < 0
160
+ || typeof value.scope !== "string" || value.scope.length > 2048 || /[\0\r\n]/u.test(value.scope)
161
+ || value.token_type !== "Bearer") throw unavailable()
162
+ const tombstone = value.access_token === "" && value.refresh_token === ""
163
+ && value.expires_at === 0 && value.expires_in === 0
164
+ if (!tombstone && (value.access_token === "" || value.refresh_token === ""
165
+ || value.expires_at < 1 || value.expires_in < 1)) throw unavailable()
166
+ return /** @type {KimiCredential} */ (value)
167
+ }
168
+
169
+ /** @param {unknown} value @param {KimiCredential} previous @param {number} currentTime @returns {KimiCredential} */
170
+ function tokenResponse(value, previous, currentTime) {
171
+ if (!record(value) || !safeToken(value.access_token, false) || !safeToken(value.refresh_token, false)
172
+ || !Number.isFinite(Number(value.expires_in)) || Number(value.expires_in) <= 0) throw refreshUnavailable()
173
+ const expiresIn = Number(value.expires_in)
174
+ if (!Number.isSafeInteger(expiresIn) || expiresIn > 604_800) throw refreshUnavailable()
175
+ const scope = value.scope === undefined ? previous.scope : value.scope
176
+ const tokenType = value.token_type === undefined ? "Bearer" : value.token_type
177
+ if (typeof scope !== "string" || scope.length > 2048 || /[\0\r\n]/u.test(scope) || tokenType !== "Bearer") {
178
+ throw refreshUnavailable()
179
+ }
180
+ return {
181
+ access_token: value.access_token,
182
+ refresh_token: value.refresh_token,
183
+ expires_at: currentTime + expiresIn,
184
+ scope,
185
+ token_type: "Bearer",
186
+ expires_in: expiresIn
187
+ }
188
+ }
189
+
190
+ /** @param {string} file @param {number} expectedUid @param {KimiCredential} credential @param {((phase: string) => void | Promise<void>) | undefined} observer */
191
+ async function persistCredential(file, expectedUid, credential, observer) {
192
+ const directory = dirname(file)
193
+ const temporary = join(directory, `.kimi-code.json.tmp.${randomBytes(12).toString("hex")}`)
194
+ let handle
195
+ try {
196
+ handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
197
+ await handle.writeFile(`${JSON.stringify(credential)}\n`)
198
+ await observer?.("before-file-sync")
199
+ await handle.sync()
200
+ await observer?.("after-file-sync")
201
+ await handle.close()
202
+ handle = undefined
203
+ await chmod(temporary, 0o600)
204
+ const metadata = await lstat(temporary, {bigint: true})
205
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1n
206
+ || metadata.uid !== BigInt(expectedUid) || (metadata.mode & 0o777n) !== 0o600n) throw unavailable()
207
+ await observer?.("before-rename")
208
+ await rename(temporary, file)
209
+ await observer?.("after-rename")
210
+ const parent = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY)
211
+ try {
212
+ await observer?.("before-directory-sync")
213
+ await parent.sync()
214
+ await observer?.("after-directory-sync")
215
+ } finally { await parent.close() }
216
+ } catch (error) {
217
+ await handle?.close().catch(() => {})
218
+ await unlink(temporary).catch(() => {})
219
+ if (error instanceof Error && [needsLogin().message, refreshUnavailable().message].includes(error.message)) throw error
220
+ // Persistence errors may contain credential material or private paths; do not retain them as a cause.
221
+ // eslint-disable-next-line preserve-caught-error
222
+ throw new Error("Kimi OAuth credential persistence failed")
223
+ }
224
+ }
225
+
226
+ /** @param {Response} response @param {AbortSignal | undefined} signal @returns {Promise<Buffer>} */
227
+ async function readCappedResponse(response, signal) {
228
+ if (response.body === null) return Buffer.alloc(0)
229
+ const reader = response.body.getReader()
230
+ /** @type {Buffer[]} */
231
+ const chunks = []
232
+ let size = 0
233
+ try {
234
+ while (true) {
235
+ if (signal?.aborted) throw signal.reason
236
+ const {done, value} = await reader.read()
237
+ if (done) break
238
+ size += value.byteLength
239
+ if (size > MAX_CREDENTIAL_BYTES) throw refreshUnavailable()
240
+ chunks.push(Buffer.from(value))
241
+ }
242
+ } catch (error) {
243
+ await reader.cancel().catch(() => {})
244
+ throw error
245
+ } finally { reader.releaseLock() }
246
+ return Buffer.concat(chunks)
247
+ }
248
+
249
+ /** @param {unknown} value @param {boolean} allowEmpty @returns {value is string} */
250
+ function safeToken(value, allowEmpty) {
251
+ return typeof value === "string" && (allowEmpty || value.length > 0)
252
+ && value.length <= MAX_TOKEN_BYTES && !/[\0\r\n]/u.test(value)
253
+ }
254
+ /** @param {Record<string, unknown>} value @param {readonly string[]} expected @returns {boolean} */
255
+ function exactKeys(value, expected) {
256
+ const actual = Object.keys(value).sort()
257
+ const wanted = [...expected].sort()
258
+ return actual.length === wanted.length && actual.every((key, index) => key === wanted[index])
259
+ }
260
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
261
+ function record(value) { return typeof value === "object" && value !== null && !Array.isArray(value) }
262
+ /** @returns {Error} */
263
+ function unavailable() { return new Error("Kimi OAuth credential file is unavailable") }
264
+ /** @returns {Error} */
265
+ function needsLogin() { return new Error("Kimi OAuth credential was rejected; re-login required") }
266
+ /** @param {unknown} [cause] @returns {Error} */
267
+ function refreshUnavailable(cause) { return new Error("Kimi OAuth refresh is unavailable", cause === undefined ? undefined : {cause}) }
@@ -2,14 +2,15 @@
2
2
 
3
3
  import {buildClaudeCommand, claudeSessionId, createClaudeParser} from "./claude.js"
4
4
  import {buildCodexCommand, codexSessionId, parseCodexEvent} from "./codex.js"
5
+ import {buildKimiCommand, createKimiParser, createKimiSessionId} from "./kimi.js"
5
6
  import {buildOpenCodeCommand, createOpenCodeParser, createOpenCodeSessionId} from "./opencode.js"
6
7
 
7
8
  /**
8
- * @typedef {{name: "codex" | "claude" | "opencode", executable: string, arguments: string[], parse: (record: unknown) => import("../types.js").WorkerEvent[], sessionId: (record: unknown) => string | undefined}} Provider
9
+ * @typedef {{name: "codex" | "claude" | "kimi" | "opencode", executable: string, arguments: string[], parse: (record: unknown) => import("../types.js").WorkerEvent[], sessionId: (record: unknown) => string | undefined}} Provider
9
10
  */
10
11
 
11
- /** @type {readonly ["codex", "claude", "opencode"]} */
12
- export const PROVIDERS = ["codex", "claude", "opencode"]
12
+ /** @type {readonly ["codex", "claude", "kimi", "opencode"]} */
13
+ export const PROVIDERS = ["codex", "claude", "kimi", "opencode"]
13
14
 
14
15
  /** @param {string} name @param {string[]} providerArguments @param {string} prompt @param {string | undefined} resumeSession @param {NodeJS.ProcessEnv} [environment] @returns {Provider} */
15
16
  export function createProvider(name, providerArguments, prompt, resumeSession, environment = process.env) {
@@ -25,5 +26,9 @@ export function createProvider(name, providerArguments, prompt, resumeSession, e
25
26
  const command = buildOpenCodeCommand(providerArguments, prompt, resumeSession, environment)
26
27
  return {...command, name: "opencode", parse: createOpenCodeParser(), sessionId: createOpenCodeSessionId()}
27
28
  }
29
+ if (name === "kimi") {
30
+ const command = buildKimiCommand(providerArguments, prompt, resumeSession)
31
+ return {...command, name: "kimi", parse: createKimiParser(), sessionId: createKimiSessionId()}
32
+ }
28
33
  throw new Error(`--provider must be one of: ${PROVIDERS.join(", ")}`)
29
34
  }