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.
- package/CHANGELOG.md +12 -0
- package/README.md +3 -3
- package/TELEGRAM-INGRESS.md +8 -4
- package/bin/kimi-model-broker.js +5 -0
- package/docs/container-runtime.md +22 -0
- package/docs/isolated-provider-runtime.md +95 -0
- package/package.json +6 -3
- package/scripts/verify-package.js +8 -1
- package/src/activity-log.js +3 -3
- package/src/cli.js +20 -14
- package/src/isolated-runtime-client.js +9 -0
- package/src/isolated-runtime.js +199 -64
- package/src/isolated-state.js +105 -44
- package/src/isolated-worker.js +28 -7
- package/src/kimi-model-broker-policy.js +209 -0
- package/src/kimi-model-broker.js +325 -0
- package/src/kimi-oauth-store.js +267 -0
- package/src/providers/index.js +8 -3
- package/src/providers/kimi.js +165 -0
- package/src/telegram-ingress/command.js +4 -4
- package/src/telegram-ingress/config.js +11 -0
- package/src/telegram-ingress/core.js +33 -10
- package/src/telegram-ingress/http.js +2 -1
- package/src/telegram-webhook.js +8 -3
- package/src/workspace-profile.js +3 -3
- package/threadwire.workspace-profiles.json +1 -1
package/src/isolated-state.js
CHANGED
|
@@ -12,10 +12,12 @@ const OWNER = "threadwire-isolated-runtime"
|
|
|
12
12
|
* Authenticated registry of both public session aliases and private volume
|
|
13
13
|
* ownership. Lineage ownership deliberately outlives an expired alias until
|
|
14
14
|
* Docker confirms that the volume was deleted.
|
|
15
|
-
* @param {{file: string, key: string, namespace: string, now?: () => number, random?: (size: number) => Buffer}} options
|
|
15
|
+
* @param {{file: string, key: string, namespace: string, provider?: "codex" | "kimi", now?: () => number, random?: (size: number) => Buffer}} options
|
|
16
16
|
*/
|
|
17
17
|
export async function openStateRegistry(options) {
|
|
18
|
-
|
|
18
|
+
const provider = options.provider ?? "codex"
|
|
19
|
+
if (!/^\/.+/u.test(options.file) || options.key.length < 32 || !/^[A-Za-z0-9_.-]{1,64}$/u.test(options.namespace)
|
|
20
|
+
|| (provider !== "codex" && provider !== "kimi")) {
|
|
19
21
|
throw new Error("Invalid isolated state configuration")
|
|
20
22
|
}
|
|
21
23
|
const now = options.now ?? Date.now
|
|
@@ -31,28 +33,35 @@ export async function openStateRegistry(options) {
|
|
|
31
33
|
try {
|
|
32
34
|
const document = JSON.parse(await readFile(options.file, "utf8"))
|
|
33
35
|
const payload = JSON.stringify(document.payload)
|
|
34
|
-
if (![1, 2, 3].includes(document.version) || typeof document.mac !== "string" || !equalMac(document.mac, mac(options.key, payload))) throw new Error()
|
|
36
|
+
if (![1, 2, 3, 4].includes(document.version) || typeof document.mac !== "string" || !equalMac(document.mac, mac(options.key, payload))) throw new Error()
|
|
37
|
+
if (document.version < 4 && provider !== "codex") throw new Error()
|
|
35
38
|
if (!Array.isArray(document.payload?.sessions)) throw new Error()
|
|
36
|
-
for (const
|
|
37
|
-
if (!validSession(
|
|
39
|
+
for (const rawEntry of document.payload.sessions) {
|
|
40
|
+
if (!validSession(rawEntry, document.version === 4)) throw new Error()
|
|
41
|
+
const entry = document.version === 4 ? rawEntry : {...rawEntry, provider: "codex"}
|
|
42
|
+
if (entry.provider !== provider) throw new Error()
|
|
38
43
|
sessions.set(entry.sessionId, without(entry, "sessionId"))
|
|
39
44
|
lineages.set(entry.lineage, lineageFromSession(options.namespace, entry))
|
|
40
45
|
}
|
|
41
46
|
if (document.version === 2) {
|
|
42
47
|
if (!Array.isArray(document.payload.lineages)) throw new Error()
|
|
43
48
|
for (const entry of document.payload.lineages) {
|
|
44
|
-
if (!validLineage(entry, options.namespace)) throw new Error()
|
|
45
|
-
lineages.set(entry.lineage, entry)
|
|
49
|
+
if (!validLineage(entry, options.namespace, false)) throw new Error()
|
|
50
|
+
lineages.set(entry.lineage, {...entry, provider: "codex"})
|
|
46
51
|
}
|
|
47
52
|
}
|
|
48
|
-
if (document.version === 3) {
|
|
53
|
+
if (document.version === 3 || document.version === 4) {
|
|
49
54
|
if (!Array.isArray(document.payload.lineages) || !Array.isArray(document.payload.runs)) throw new Error()
|
|
50
|
-
for (const
|
|
51
|
-
if (!validLineage(
|
|
55
|
+
for (const rawEntry of document.payload.lineages) {
|
|
56
|
+
if (!validLineage(rawEntry, options.namespace, document.version === 4)) throw new Error()
|
|
57
|
+
const entry = document.version === 4 ? rawEntry : {...rawEntry, provider: "codex"}
|
|
58
|
+
if (entry.provider !== provider) throw new Error()
|
|
52
59
|
lineages.set(entry.lineage, entry)
|
|
53
60
|
}
|
|
54
|
-
for (const
|
|
55
|
-
canonicalRunIdentity(
|
|
61
|
+
for (const rawEntry of document.payload.runs) {
|
|
62
|
+
canonicalRunIdentity(rawEntry)
|
|
63
|
+
const entry = document.version === 4 ? rawEntry : {...rawEntry, provider: "codex", sealVersion: 3}
|
|
64
|
+
if (entry.provider !== provider) throw new Error()
|
|
56
65
|
runs.set(entry.run, entry)
|
|
57
66
|
}
|
|
58
67
|
}
|
|
@@ -69,7 +78,7 @@ export async function openStateRegistry(options) {
|
|
|
69
78
|
runs: [...runState.values()]
|
|
70
79
|
}
|
|
71
80
|
const serializedPayload = JSON.stringify(payload)
|
|
72
|
-
const document = JSON.stringify({version:
|
|
81
|
+
const document = JSON.stringify({version: 4, payload, mac: mac(options.key, serializedPayload)})
|
|
73
82
|
const temporary = join(dirname(options.file), `.sessions-${random(12).toString("hex")}.tmp`)
|
|
74
83
|
let handle
|
|
75
84
|
try {
|
|
@@ -104,17 +113,33 @@ export async function openStateRegistry(options) {
|
|
|
104
113
|
return result
|
|
105
114
|
}
|
|
106
115
|
const allocationRecord = (allocation) => ({
|
|
116
|
+
provider: allocation.provider,
|
|
107
117
|
task: allocation.task,
|
|
108
118
|
lineage: allocation.lineage,
|
|
109
119
|
volume: allocation.volume,
|
|
110
120
|
labels: allocation.labels
|
|
111
121
|
})
|
|
122
|
+
const ownsRunSeal = (identity, seal, legacyLabels = false) => {
|
|
123
|
+
const persisted = runs.get(identity.run)
|
|
124
|
+
if (!persisted || typeof seal !== "string") return false
|
|
125
|
+
try {
|
|
126
|
+
if (canonicalRunIdentity(persisted) !== canonicalRunIdentity(identity)) return false
|
|
127
|
+
const legacySeal = persisted.sealVersion === 3
|
|
128
|
+
if (legacyLabels !== legacySeal) return false
|
|
129
|
+
const canonical = legacySeal ? legacyCanonicalRunIdentity(persisted) : canonicalRunIdentity(persisted)
|
|
130
|
+
return equalMac(seal, mac(options.key, canonical))
|
|
131
|
+
} catch {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
112
135
|
|
|
113
136
|
return {
|
|
114
137
|
namespace: options.namespace,
|
|
138
|
+
provider,
|
|
115
139
|
allocate(task) {
|
|
116
140
|
const lineage = random(24).toString("hex")
|
|
117
141
|
return {
|
|
142
|
+
provider,
|
|
118
143
|
task,
|
|
119
144
|
lineage,
|
|
120
145
|
volume: `threadwire-state-${options.namespace}-${lineage}`,
|
|
@@ -122,22 +147,29 @@ export async function openStateRegistry(options) {
|
|
|
122
147
|
}
|
|
123
148
|
},
|
|
124
149
|
sealRun(identity) {
|
|
150
|
+
if (identity.sealVersion !== undefined) throw new Error("Invalid isolated run identity")
|
|
125
151
|
return mac(options.key, canonicalRunIdentity(identity))
|
|
126
152
|
},
|
|
127
|
-
|
|
153
|
+
ownsRunSeal(identity, seal, legacyLabels = false) {
|
|
154
|
+
return ownsRunSeal(identity, seal, legacyLabels)
|
|
155
|
+
},
|
|
156
|
+
ownsRun(identity, seal, legacyLabels = false) {
|
|
128
157
|
const lineage = lineages.get(identity.lineage)
|
|
129
|
-
if (!lineage || lineage.
|
|
130
|
-
|
|
158
|
+
if (!lineage || lineage.provider !== (identity.provider ?? "codex")
|
|
159
|
+
|| lineage.volume !== identity.volume || lineage.labels["org.threadwire.task"] !== identity.task) return false
|
|
160
|
+
return ownsRunSeal(identity, seal, legacyLabels)
|
|
131
161
|
},
|
|
132
162
|
async trackRun(identity, signal) {
|
|
133
163
|
await transact(async () => {
|
|
134
164
|
throwIfAborted(signal)
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
if (
|
|
165
|
+
const normalized = {...identity, provider: identity.provider ?? provider}
|
|
166
|
+
if (normalized.sealVersion !== undefined) throw new Error("Invalid isolated run identity")
|
|
167
|
+
canonicalRunIdentity(normalized)
|
|
168
|
+
if (normalized.provider !== provider || !lineages.has(normalized.lineage)) throw new Error(`${provider === "kimi" ? "Kimi" : "Codex"} run lineage is unavailable`)
|
|
169
|
+
const existing = runs.get(normalized.run)
|
|
170
|
+
if (existing && canonicalRunIdentity(existing) !== canonicalRunIdentity(normalized)) throw new Error(`${provider === "kimi" ? "Kimi" : "Codex"} run ownership collision`)
|
|
139
171
|
const nextRuns = new Map(runs)
|
|
140
|
-
nextRuns.set(
|
|
172
|
+
nextRuns.set(normalized.run, existing ?? normalized)
|
|
141
173
|
await persist(sessions, lineages, nextRuns, signal)
|
|
142
174
|
replace(runs, nextRuns)
|
|
143
175
|
throwIfAborted(signal)
|
|
@@ -157,19 +189,20 @@ export async function openStateRegistry(options) {
|
|
|
157
189
|
runRecords() {
|
|
158
190
|
return [...runs.values()].map((entry) => ({...entry}))
|
|
159
191
|
},
|
|
160
|
-
lookup(sessionId) {
|
|
192
|
+
lookup(sessionId, expectedProvider = provider) {
|
|
161
193
|
const entry = sessions.get(sessionId)
|
|
162
|
-
if (!entry || entry.expiresAt <= now()) return undefined
|
|
194
|
+
if (!entry || entry.provider !== expectedProvider || entry.expiresAt <= now()) return undefined
|
|
163
195
|
return {...entry, labels: stateLabels(options.namespace, entry.task, entry.lineage)}
|
|
164
196
|
},
|
|
165
197
|
async track(allocation, signal) {
|
|
166
198
|
await transact(async () => {
|
|
167
199
|
throwIfAborted(signal)
|
|
168
|
-
const
|
|
169
|
-
const
|
|
200
|
+
const normalized = providerAllocation(allocation, provider)
|
|
201
|
+
const existing = lineages.get(normalized.lineage)
|
|
202
|
+
const candidate = allocationRecord(normalized)
|
|
170
203
|
if (existing && JSON.stringify(existing) !== JSON.stringify(candidate)) throw new Error("Codex lineage ownership collision")
|
|
171
204
|
const nextLineages = new Map(lineages)
|
|
172
|
-
nextLineages.set(
|
|
205
|
+
nextLineages.set(normalized.lineage, candidate)
|
|
173
206
|
await persist(sessions, nextLineages, runs, signal)
|
|
174
207
|
replace(lineages, nextLineages)
|
|
175
208
|
throwIfAborted(signal)
|
|
@@ -178,21 +211,23 @@ export async function openStateRegistry(options) {
|
|
|
178
211
|
async register(sessionIds, allocation, ttlMs, signal) {
|
|
179
212
|
await transact(async () => {
|
|
180
213
|
throwIfAborted(signal)
|
|
181
|
-
const
|
|
182
|
-
const
|
|
214
|
+
const normalized = providerAllocation(allocation, provider)
|
|
215
|
+
const candidate = allocationRecord(normalized)
|
|
216
|
+
const owned = lineages.get(normalized.lineage)
|
|
183
217
|
if (owned && JSON.stringify(owned) !== JSON.stringify(candidate)) throw new Error("Codex lineage ownership collision")
|
|
184
218
|
const nextSessions = new Map(sessions)
|
|
185
219
|
const nextLineages = new Map(lineages)
|
|
186
220
|
for (const sessionId of sessionIds) {
|
|
187
221
|
const existing = nextSessions.get(sessionId)
|
|
188
|
-
if (existing && (existing.task !==
|
|
222
|
+
if (existing && (existing.provider !== normalized.provider || existing.task !== normalized.task || existing.lineage !== normalized.lineage || existing.volume !== normalized.volume)) {
|
|
189
223
|
throw new Error("Codex session lineage collision")
|
|
190
224
|
}
|
|
191
225
|
nextSessions.set(sessionId, {
|
|
192
|
-
|
|
226
|
+
provider: normalized.provider, task: normalized.task, lineage: normalized.lineage,
|
|
227
|
+
volume: normalized.volume, expiresAt: now() + ttlMs
|
|
193
228
|
})
|
|
194
229
|
}
|
|
195
|
-
nextLineages.set(
|
|
230
|
+
nextLineages.set(normalized.lineage, candidate)
|
|
196
231
|
await persist(nextSessions, nextLineages, runs, signal)
|
|
197
232
|
replace(sessions, nextSessions)
|
|
198
233
|
replace(lineages, nextLineages)
|
|
@@ -215,7 +250,7 @@ export async function openStateRegistry(options) {
|
|
|
215
250
|
const nextLineages = new Map(lineages)
|
|
216
251
|
for (const [sessionId, entry] of nextSessions) if (entry.expiresAt <= now()) nextSessions.delete(sessionId)
|
|
217
252
|
for (const volume of Array.isArray(listed?.Volumes) ? listed.Volumes : []) {
|
|
218
|
-
const orphan = ownedVolumeRecord(volume, options.namespace)
|
|
253
|
+
const orphan = ownedVolumeRecord(volume, options.namespace, provider)
|
|
219
254
|
if (orphan && !nextLineages.has(orphan.lineage)) nextLineages.set(orphan.lineage, orphan)
|
|
220
255
|
}
|
|
221
256
|
// Persist removals and adopted cleanup evidence before touching Docker.
|
|
@@ -282,7 +317,7 @@ export function validateStateVolume(volume, name, labels) {
|
|
|
282
317
|
for (const [key, value] of Object.entries(labels)) if (actual[key] !== value) throw new Error("Isolated state volume collision")
|
|
283
318
|
}
|
|
284
319
|
|
|
285
|
-
function ownedVolumeRecord(volume, namespace) {
|
|
320
|
+
function ownedVolumeRecord(volume, namespace, provider) {
|
|
286
321
|
if (!record(volume) || !record(volume.Labels)) return undefined
|
|
287
322
|
const labels = volume.Labels
|
|
288
323
|
const lineage = labels["org.threadwire.lineage"]
|
|
@@ -293,25 +328,33 @@ function ownedVolumeRecord(volume, namespace) {
|
|
|
293
328
|
|| !/^[0-9a-f]{64}$/u.test(labels["org.threadwire.task"])
|
|
294
329
|
|| !/^[0-9a-f]{48}$/u.test(lineage)
|
|
295
330
|
|| name !== `threadwire-state-${namespace}-${lineage}`) return undefined
|
|
296
|
-
return {task: "", lineage, volume: name, labels}
|
|
331
|
+
return {provider, task: "", lineage, volume: name, labels}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function providerAllocation(allocation, expectedProvider) {
|
|
335
|
+
const actualProvider = allocation.provider ?? "codex"
|
|
336
|
+
if (actualProvider !== expectedProvider) throw new Error("Isolated state provider mismatch")
|
|
337
|
+
return {...allocation, provider: actualProvider}
|
|
297
338
|
}
|
|
298
339
|
|
|
299
340
|
function lineageFromSession(namespace, entry) {
|
|
300
341
|
return {
|
|
301
|
-
task: entry.task, lineage: entry.lineage, volume: entry.volume,
|
|
342
|
+
provider: entry.provider, task: entry.task, lineage: entry.lineage, volume: entry.volume,
|
|
302
343
|
labels: stateLabels(namespace, entry.task, entry.lineage)
|
|
303
344
|
}
|
|
304
345
|
}
|
|
305
346
|
|
|
306
|
-
function validSession(value) {
|
|
307
|
-
return record(value) &&
|
|
347
|
+
function validSession(value, requireProvider = true) {
|
|
348
|
+
return record(value) && (!requireProvider || value.provider === "codex" || value.provider === "kimi")
|
|
349
|
+
&& typeof value.sessionId === "string" && typeof value.task === "string"
|
|
308
350
|
&& /^[0-9a-f]{48}$/u.test(value.lineage)
|
|
309
351
|
&& /^threadwire-state-[A-Za-z0-9_.-]+-[0-9a-f]{48}$/u.test(value.volume)
|
|
310
352
|
&& Number.isSafeInteger(value.expiresAt)
|
|
311
353
|
}
|
|
312
354
|
|
|
313
|
-
function validLineage(value, namespace) {
|
|
314
|
-
return record(value) &&
|
|
355
|
+
function validLineage(value, namespace, requireProvider = true) {
|
|
356
|
+
return record(value) && (!requireProvider || value.provider === "codex" || value.provider === "kimi")
|
|
357
|
+
&& typeof value.task === "string" && /^[0-9a-f]{48}$/u.test(value.lineage)
|
|
315
358
|
&& value.volume === `threadwire-state-${namespace}-${value.lineage}` && record(value.labels)
|
|
316
359
|
&& Object.keys(value.labels).length === 4
|
|
317
360
|
&& value.labels["org.threadwire.owner"] === OWNER
|
|
@@ -366,14 +409,15 @@ async function removeAuthenticatedTemps(directory, key, namespace) {
|
|
|
366
409
|
}
|
|
367
410
|
|
|
368
411
|
function authenticatedDocument(document, key, namespace) {
|
|
369
|
-
if (!record(document) || ![1, 2, 3].includes(document.version) || typeof document.mac !== "string"
|
|
412
|
+
if (!record(document) || ![1, 2, 3, 4].includes(document.version) || typeof document.mac !== "string"
|
|
370
413
|
|| !record(document.payload) || !Array.isArray(document.payload.sessions)) return false
|
|
371
414
|
const payload = JSON.stringify(document.payload)
|
|
372
415
|
if (!equalMac(document.mac, mac(key, payload))) return false
|
|
373
|
-
|
|
416
|
+
const current = document.version === 4
|
|
417
|
+
if (!document.payload.sessions.every((entry) => validSession(entry, current))) return false
|
|
374
418
|
if (document.version === 1) return true
|
|
375
419
|
if (!Array.isArray(document.payload.lineages)
|
|
376
|
-
|| !document.payload.lineages.every((entry) => validLineage(entry, namespace))) return false
|
|
420
|
+
|| !document.payload.lineages.every((entry) => validLineage(entry, namespace, current))) return false
|
|
377
421
|
return document.version === 2 || (Array.isArray(document.payload.runs)
|
|
378
422
|
&& document.payload.runs.every((entry) => {
|
|
379
423
|
try { canonicalRunIdentity(entry); return true } catch { return false }
|
|
@@ -381,12 +425,29 @@ function authenticatedDocument(document, key, namespace) {
|
|
|
381
425
|
}
|
|
382
426
|
|
|
383
427
|
function canonicalRunIdentity(identity) {
|
|
428
|
+
const keys = [
|
|
429
|
+
"provider", "namespace", "run", "lineage", "task", "volume", "network", "container",
|
|
430
|
+
"image", "worktreeType", "worktreeSource", "worktreeSubpath"
|
|
431
|
+
]
|
|
432
|
+
if (!record(identity)) throw new Error("Invalid isolated run identity")
|
|
433
|
+
const normalized = {...identity, provider: identity.provider ?? "codex"}
|
|
434
|
+
const legacySeal = normalized.sealVersion === 3 && normalized.provider === "codex"
|
|
435
|
+
const expectedKeys = legacySeal ? [...keys, "sealVersion"] : keys
|
|
436
|
+
if (Object.keys(normalized).length !== expectedKeys.length
|
|
437
|
+
|| expectedKeys.some((key) => !Object.prototype.hasOwnProperty.call(normalized, key))
|
|
438
|
+
|| (normalized.provider !== "codex" && normalized.provider !== "kimi")
|
|
439
|
+
|| keys.some((key) => typeof normalized[key] !== "string")) throw new Error("Invalid isolated run identity")
|
|
440
|
+
return JSON.stringify(Object.fromEntries(keys.map((key) => [key, normalized[key]])))
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function legacyCanonicalRunIdentity(identity) {
|
|
384
444
|
const keys = [
|
|
385
445
|
"namespace", "run", "lineage", "task", "volume", "network", "container",
|
|
386
446
|
"image", "worktreeType", "worktreeSource", "worktreeSubpath"
|
|
387
447
|
]
|
|
388
|
-
if (!record(identity) ||
|
|
389
|
-
|| keys
|
|
448
|
+
if (!record(identity) || identity.provider !== "codex" || identity.sealVersion !== 3
|
|
449
|
+
|| Object.keys(identity).length !== keys.length + 2
|
|
450
|
+
|| keys.some((key) => typeof identity[key] !== "string")) throw new Error("Invalid legacy Codex run identity")
|
|
390
451
|
return JSON.stringify(Object.fromEntries(keys.map((key) => [key, identity[key]])))
|
|
391
452
|
}
|
|
392
453
|
|
package/src/isolated-worker.js
CHANGED
|
@@ -7,6 +7,7 @@ const SAFE_VALUE = /^[^\0\r\n]+$/u
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* @typedef {{
|
|
10
|
+
* provider?: "codex" | "kimi",
|
|
10
11
|
* image: string,
|
|
11
12
|
* worktree: string,
|
|
12
13
|
* networkName: string,
|
|
@@ -17,6 +18,7 @@ const SAFE_VALUE = /^[^\0\r\n]+$/u
|
|
|
17
18
|
* worktreeVolume?: {name: string, subpath: string}
|
|
18
19
|
* workingDirectory: string,
|
|
19
20
|
* providerArguments: string[],
|
|
21
|
+
* model?: string,
|
|
20
22
|
* stateVolume: string,
|
|
21
23
|
* worktreeInode: string,
|
|
22
24
|
* cwdInode: string
|
|
@@ -30,6 +32,8 @@ const SAFE_VALUE = /^[^\0\r\n]+$/u
|
|
|
30
32
|
|
|
31
33
|
/** @param {WorkerSpecOptions} options */
|
|
32
34
|
export function buildWorkerContainerSpec(options) {
|
|
35
|
+
const provider = options.provider ?? "codex"
|
|
36
|
+
if (provider !== "codex" && provider !== "kimi") throw new Error("Worker provider is invalid")
|
|
33
37
|
if (!DIGEST_IMAGE.test(options.image)) throw new Error("Worker image must use an immutable digest")
|
|
34
38
|
if (!isAbsolute(options.worktree) || normalize(options.worktree) !== options.worktree || options.worktree === "/") {
|
|
35
39
|
throw new Error("Worker worktree must be a normalized non-root absolute path")
|
|
@@ -39,19 +43,33 @@ export function buildWorkerContainerSpec(options) {
|
|
|
39
43
|
}
|
|
40
44
|
if (options.resumeSession !== undefined && !SAFE_VALUE.test(options.resumeSession)) throw new Error("Worker resume session is invalid")
|
|
41
45
|
if (!isAbsolute(options.workingDirectory) || !withinWorktree(options.workingDirectory)) throw new Error("Worker cwd is invalid")
|
|
42
|
-
|
|
46
|
+
if (provider === "kimi" && (typeof options.model !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u.test(options.model))) {
|
|
47
|
+
throw new Error("Worker Kimi model is invalid")
|
|
48
|
+
}
|
|
49
|
+
const commonEnvironment = [
|
|
43
50
|
"HOME=/home/worker",
|
|
44
|
-
"CODEX_HOME=/home/worker/.codex",
|
|
45
51
|
"TMPDIR=/tmp",
|
|
46
|
-
`OPENAI_BASE_URL=${options.brokerUrl ?? "http://model-broker:8789/v1"}`,
|
|
47
|
-
`OPENAI_API_KEY=${options.brokerToken}`,
|
|
48
|
-
`CODEX_API_KEY=${options.brokerToken}`,
|
|
49
52
|
`THREADWIRE_PROMPT=${options.prompt}`,
|
|
50
|
-
`THREADWIRE_CODEX_ARGUMENTS=${JSON.stringify(options.providerArguments)}`,
|
|
51
53
|
`THREADWIRE_WORKTREE_INODE=${options.worktreeInode}`,
|
|
52
54
|
`THREADWIRE_CWD_INODE=${options.cwdInode}`,
|
|
53
55
|
...(options.resumeSession === undefined ? [] : [`THREADWIRE_RESUME_SESSION=${options.resumeSession}`])
|
|
54
56
|
]
|
|
57
|
+
const environment = provider === "kimi"
|
|
58
|
+
? [
|
|
59
|
+
...commonEnvironment,
|
|
60
|
+
"KIMI_CODE_HOME=/home/worker/.kimi-code",
|
|
61
|
+
`THREADWIRE_KIMI_MODEL=${options.model}`,
|
|
62
|
+
`THREADWIRE_KIMI_BROKER_URL=${options.brokerUrl}`,
|
|
63
|
+
`THREADWIRE_KIMI_BROKER_TOKEN=${options.brokerToken}`
|
|
64
|
+
]
|
|
65
|
+
: [
|
|
66
|
+
...commonEnvironment,
|
|
67
|
+
"CODEX_HOME=/home/worker/.codex",
|
|
68
|
+
`OPENAI_BASE_URL=${options.brokerUrl ?? "http://model-broker:8789/v1"}`,
|
|
69
|
+
`OPENAI_API_KEY=${options.brokerToken}`,
|
|
70
|
+
`CODEX_API_KEY=${options.brokerToken}`,
|
|
71
|
+
`THREADWIRE_CODEX_ARGUMENTS=${JSON.stringify(options.providerArguments)}`
|
|
72
|
+
]
|
|
55
73
|
const worktreeMount = options.worktreeVolume === undefined
|
|
56
74
|
? {
|
|
57
75
|
Type: "bind",
|
|
@@ -73,6 +91,7 @@ export function buildWorkerContainerSpec(options) {
|
|
|
73
91
|
"org.threadwire.owner": "isolated-runtime",
|
|
74
92
|
...(options.lineage === undefined ? {} : {
|
|
75
93
|
"org.threadwire.namespace": options.namespace,
|
|
94
|
+
"org.threadwire.provider": provider,
|
|
76
95
|
"org.threadwire.run": options.runId,
|
|
77
96
|
"org.threadwire.lineage": options.lineage,
|
|
78
97
|
"org.threadwire.task": options.taskHash,
|
|
@@ -81,7 +100,9 @@ export function buildWorkerContainerSpec(options) {
|
|
|
81
100
|
"org.threadwire.run-seal": options.runSeal
|
|
82
101
|
})
|
|
83
102
|
},
|
|
84
|
-
Entrypoint:
|
|
103
|
+
Entrypoint: provider === "kimi"
|
|
104
|
+
? ["node", "/opt/threadwire/docker/kimi-worker-entrypoint.mjs"]
|
|
105
|
+
: ["/usr/local/libexec/threadwire/worker-entrypoint"],
|
|
85
106
|
Env: environment,
|
|
86
107
|
WorkingDir: options.workingDirectory,
|
|
87
108
|
User: "10002:10002",
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {randomBytes} from "node:crypto"
|
|
4
|
+
|
|
5
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,128}$/u
|
|
6
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u
|
|
7
|
+
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u
|
|
8
|
+
const ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u
|
|
9
|
+
const TASK_PATTERN = /^[0-9a-f]{64}$/u
|
|
10
|
+
const SESSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,511}$/u
|
|
11
|
+
|
|
12
|
+
/** @typedef {{provider: "kimi", runId: string, networkId: string, taskId: string, sessionId: string, modelAlias: string, model: string, expiresAt: number, active: boolean}} KimiGrant */
|
|
13
|
+
/** @typedef {{alias: string, model: string, protocol: "kimi"}} ApprovedKimiModel */
|
|
14
|
+
const ALLOWED_HEADERS = new Set([
|
|
15
|
+
"authorization", "content-length", "content-type", "accept", "accept-encoding",
|
|
16
|
+
"accept-language", "sec-fetch-mode", "user-agent",
|
|
17
|
+
"x-stainless-arch", "x-stainless-lang", "x-stainless-os",
|
|
18
|
+
"x-stainless-package-version", "x-stainless-runtime", "x-stainless-runtime-version",
|
|
19
|
+
"x-stainless-retry-count", "x-stainless-timeout", "x-stainless-async"
|
|
20
|
+
])
|
|
21
|
+
const BODY_KEYS = new Set([
|
|
22
|
+
"model", "messages", "stream", "stream_options", "tools", "thinking",
|
|
23
|
+
"max_completion_tokens", "temperature", "top_p", "n", "presence_penalty",
|
|
24
|
+
"frequency_penalty", "stop", "prompt_cache_key", "response_format"
|
|
25
|
+
])
|
|
26
|
+
const NUMERIC_KEYS = new Set([
|
|
27
|
+
"max_completion_tokens", "temperature", "top_p", "n", "presence_penalty", "frequency_penalty"
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
export const KIMI_CHAT_COMPLETIONS_PATH = "/coding/v1/chat/completions"
|
|
31
|
+
|
|
32
|
+
/** @param {{now?: () => number, random?: (bytes: number) => Buffer}} [options] */
|
|
33
|
+
export function createKimiGrantStore(options = {}) {
|
|
34
|
+
const now = options.now ?? Date.now
|
|
35
|
+
const random = options.random ?? randomBytes
|
|
36
|
+
/** @type {Map<string, KimiGrant>} */
|
|
37
|
+
const grants = new Map()
|
|
38
|
+
return {
|
|
39
|
+
/** @param {{provider: "kimi", runId: string, networkId: string, taskId: string, sessionId: string, modelAlias: string, model: string, ttlMs: number}} grant */
|
|
40
|
+
issue(grant) {
|
|
41
|
+
if (grant.provider !== "kimi" || !ID_PATTERN.test(grant.runId) || !ID_PATTERN.test(grant.networkId)
|
|
42
|
+
|| !TASK_PATTERN.test(grant.taskId) || !SESSION_PATTERN.test(grant.sessionId)
|
|
43
|
+
|| !ALIAS_PATTERN.test(grant.modelAlias) || !MODEL_PATTERN.test(grant.model)
|
|
44
|
+
|| !Number.isSafeInteger(grant.ttlMs) || grant.ttlMs < 1 || grant.ttlMs > 3_600_000) {
|
|
45
|
+
throw new Error("Invalid Kimi broker grant")
|
|
46
|
+
}
|
|
47
|
+
const token = random(32).toString("base64url")
|
|
48
|
+
if (!TOKEN_PATTERN.test(token) || grants.has(token)) throw new Error("Invalid Kimi broker grant")
|
|
49
|
+
grants.set(token, {...grant, expiresAt: now() + grant.ttlMs, active: false})
|
|
50
|
+
return token
|
|
51
|
+
},
|
|
52
|
+
/** @param {string} token */
|
|
53
|
+
activate(token) {
|
|
54
|
+
const grant = currentGrant(grants, token, now)
|
|
55
|
+
if (grant.active) throw new Error("Kimi broker grant already active")
|
|
56
|
+
grant.active = true
|
|
57
|
+
return {...grant}
|
|
58
|
+
},
|
|
59
|
+
/** @param {string} token @param {{provider: string, runId: string, networkId: string, taskId: string, sessionId: string, modelAlias: string}} context */
|
|
60
|
+
authorize(token, context) {
|
|
61
|
+
const grant = currentGrant(grants, token, now)
|
|
62
|
+
if (grant.provider !== context.provider || grant.runId !== context.runId
|
|
63
|
+
|| grant.networkId !== context.networkId || grant.taskId !== context.taskId
|
|
64
|
+
|| grant.sessionId !== context.sessionId || grant.modelAlias !== context.modelAlias) {
|
|
65
|
+
throw new Error("Kimi broker grant lineage denied")
|
|
66
|
+
}
|
|
67
|
+
if (!grant.active) throw new Error("Kimi broker grant pending")
|
|
68
|
+
return {...grant}
|
|
69
|
+
},
|
|
70
|
+
/** @param {string} token */
|
|
71
|
+
revoke(token) { grants.delete(token) },
|
|
72
|
+
clear() { grants.clear() }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {Map<string, KimiGrant>} grants
|
|
78
|
+
* @param {string} token
|
|
79
|
+
* @param {() => number} now
|
|
80
|
+
*/
|
|
81
|
+
function currentGrant(grants, token, now) {
|
|
82
|
+
const grant = grants.get(token)
|
|
83
|
+
if (!grant) throw new Error("Kimi broker grant unknown token")
|
|
84
|
+
if (grant.expiresAt < now()) {
|
|
85
|
+
grants.delete(token)
|
|
86
|
+
throw new Error("Kimi broker grant expired")
|
|
87
|
+
}
|
|
88
|
+
return grant
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {{method?: string, path?: string, headers?: Record<string, string | string[] | undefined>}} request */
|
|
92
|
+
export function validateKimiBrokerRequest(request) {
|
|
93
|
+
if (request.method !== "POST" || request.path !== "/v1/chat/completions") {
|
|
94
|
+
throw new Error("Kimi broker request denied")
|
|
95
|
+
}
|
|
96
|
+
for (const header of Object.keys(request.headers ?? {})) {
|
|
97
|
+
if (!ALLOWED_HEADERS.has(header.toLowerCase())) throw new Error(`Kimi broker request denied header: ${header.toLowerCase()}`)
|
|
98
|
+
}
|
|
99
|
+
return {method: "POST", path: "/v1/chat/completions", upstreamPath: KIMI_CHAT_COMPLETIONS_PATH}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @param {Buffer} body @param {string} allowedModel */
|
|
103
|
+
export function validateKimiChatBody(body, allowedModel) {
|
|
104
|
+
const text = body.toString("utf8")
|
|
105
|
+
if (topLevelKeyCount(text, "model") !== 1) throw denied()
|
|
106
|
+
let value
|
|
107
|
+
try { value = JSON.parse(text) } catch { throw denied() }
|
|
108
|
+
if (!record(value) || Object.keys(value).some((key) => !BODY_KEYS.has(key))) throw denied()
|
|
109
|
+
if (value.model !== allowedModel || !Array.isArray(value.messages) || value.stream !== true) throw denied()
|
|
110
|
+
if (value.messages.length === 0 || value.messages.length > 4096 || !value.messages.every(validMessage)) throw denied()
|
|
111
|
+
if (value.tools !== undefined && (!Array.isArray(value.tools) || value.tools.length > 512 || !value.tools.every(record))) throw denied()
|
|
112
|
+
if (value.stream_options !== undefined && (!record(value.stream_options)
|
|
113
|
+
|| !exactKeys(value.stream_options, ["include_usage"]) || value.stream_options.include_usage !== true)) throw denied()
|
|
114
|
+
if (value.thinking !== undefined && !validThinking(value.thinking)) throw denied()
|
|
115
|
+
if (value.response_format !== undefined && !record(value.response_format)) throw denied()
|
|
116
|
+
if (value.prompt_cache_key !== undefined && (typeof value.prompt_cache_key !== "string" || value.prompt_cache_key.length > 512)) throw denied()
|
|
117
|
+
if (value.stop !== undefined && !validStop(value.stop)) throw denied()
|
|
118
|
+
for (const key of NUMERIC_KEYS) {
|
|
119
|
+
const item = value[key]
|
|
120
|
+
if (item !== undefined && (typeof item !== "number" || !Number.isFinite(item))) throw denied()
|
|
121
|
+
}
|
|
122
|
+
if (value.max_completion_tokens !== undefined && (typeof value.max_completion_tokens !== "number"
|
|
123
|
+
|| !Number.isSafeInteger(value.max_completion_tokens)
|
|
124
|
+
|| value.max_completion_tokens < 1 || value.max_completion_tokens > 1_000_000)) throw denied()
|
|
125
|
+
if (value.n !== undefined && value.n !== 1) throw denied()
|
|
126
|
+
return value
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @param {unknown} value */
|
|
130
|
+
function validMessage(value) {
|
|
131
|
+
if (!record(value) || typeof value.role !== "string" || !["system", "user", "assistant", "tool"].includes(value.role)) return false
|
|
132
|
+
const allowed = new Set(["role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content", "reasoning_details", "reasoning", "tools"])
|
|
133
|
+
return Object.keys(value).every((key) => allowed.has(key))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @param {unknown} value */
|
|
137
|
+
function validThinking(value) {
|
|
138
|
+
if (!record(value) || Object.keys(value).some((key) => !["type", "effort", "keep"].includes(key))) return false
|
|
139
|
+
if (value.type !== undefined && value.type !== "enabled" && value.type !== "disabled") return false
|
|
140
|
+
if (value.effort !== undefined && (typeof value.effort !== "string" || !/^[A-Za-z0-9_-]{1,32}$/u.test(value.effort))) return false
|
|
141
|
+
return value.keep === undefined || value.keep === "all"
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @param {unknown} value */
|
|
145
|
+
function validStop(value) {
|
|
146
|
+
return typeof value === "string" ? value.length <= 512
|
|
147
|
+
: Array.isArray(value) && value.length <= 16 && value.every((item) => typeof item === "string" && item.length <= 512)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @param {string | undefined} authorization */
|
|
151
|
+
export function kimiBearerToken(authorization) {
|
|
152
|
+
const match = /^Bearer ([A-Za-z0-9_-]{32,128})$/u.exec(authorization ?? "")
|
|
153
|
+
if (!match || !TOKEN_PATTERN.test(match[1] ?? "")) throw new Error("Kimi broker grant format denied")
|
|
154
|
+
return /** @type {string} */ (match[1])
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @param {string | undefined} value */
|
|
158
|
+
export function parseApprovedKimiModels(value) {
|
|
159
|
+
let parsed
|
|
160
|
+
try { parsed = JSON.parse(value ?? "") } catch { throw new Error("THREADWIRE_ALLOWED_KIMI_MODELS must contain allowed Kimi models") }
|
|
161
|
+
if (!record(parsed) || Object.keys(parsed).length < 1 || Object.keys(parsed).length > 32) {
|
|
162
|
+
throw new Error("THREADWIRE_ALLOWED_KIMI_MODELS must contain allowed Kimi models")
|
|
163
|
+
}
|
|
164
|
+
/** @type {Map<string, ApprovedKimiModel>} */
|
|
165
|
+
const models = new Map()
|
|
166
|
+
for (const [alias, item] of Object.entries(parsed)) {
|
|
167
|
+
if (!ALIAS_PATTERN.test(alias) || !record(item) || !exactKeys(item, ["model", "protocol"])
|
|
168
|
+
|| typeof item.model !== "string" || !MODEL_PATTERN.test(item.model) || item.protocol !== "kimi") {
|
|
169
|
+
throw new Error("THREADWIRE_ALLOWED_KIMI_MODELS must contain allowed Kimi models")
|
|
170
|
+
}
|
|
171
|
+
models.set(alias, {alias, model: item.model, protocol: "kimi"})
|
|
172
|
+
}
|
|
173
|
+
return models
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** @returns {Error} */
|
|
177
|
+
function denied() { return new Error("Kimi broker request denied") }
|
|
178
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
179
|
+
function record(value) { return typeof value === "object" && value !== null && !Array.isArray(value) }
|
|
180
|
+
/** @param {Record<string, unknown>} value @param {readonly string[]} keys */
|
|
181
|
+
function exactKeys(value, keys) {
|
|
182
|
+
const actual = Object.keys(value).sort()
|
|
183
|
+
return actual.length === keys.length && actual.every((key, index) => key === [...keys].sort()[index])
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @param {string} text @param {string} wanted @returns {number} */
|
|
187
|
+
function topLevelKeyCount(text, wanted) {
|
|
188
|
+
let depth = 0
|
|
189
|
+
let count = 0
|
|
190
|
+
let index = 0
|
|
191
|
+
while (index < text.length) {
|
|
192
|
+
if (text[index] === "\"") {
|
|
193
|
+
let string = ""
|
|
194
|
+
index++
|
|
195
|
+
while (index < text.length && text[index] !== "\"") {
|
|
196
|
+
if (text[index] === "\\") { index += 2; continue }
|
|
197
|
+
string += text[index++]
|
|
198
|
+
}
|
|
199
|
+
if (depth === 1) {
|
|
200
|
+
let next = index + 1
|
|
201
|
+
while (/\s/u.test(text[next] ?? "")) next++
|
|
202
|
+
if (text[next] === ":" && string === wanted) count++
|
|
203
|
+
}
|
|
204
|
+
} else if (text[index] === "{") depth++
|
|
205
|
+
else if (text[index] === "}") depth--
|
|
206
|
+
index++
|
|
207
|
+
}
|
|
208
|
+
return count
|
|
209
|
+
}
|