threadwire 0.1.12 → 0.1.14
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 +24 -0
- package/README.md +14 -8
- package/TELEGRAM-INGRESS.md +7 -7
- package/docs/capacity-admission.md +147 -0
- package/docs/container-runtime.md +18 -34
- package/docs/development-container.md +77 -0
- package/docs/isolated-provider-runtime.md +23 -20
- package/package.json +2 -3
- package/scripts/provider-shims/front-door.sh.template +1 -5
- package/scripts/verify-package.js +8 -3
- package/src/absolute-deadline.js +6 -3
- package/src/activity-log.js +1 -14
- package/src/cli.js +145 -60
- package/src/docker-api.js +8 -0
- package/src/isolated-runtime-client.js +124 -4
- package/src/isolated-runtime.js +145 -11
- package/src/jsonl-record-spool.js +178 -0
- package/src/kimi-oauth-store.js +16 -0
- package/src/provider-capacity-codex.js +176 -0
- package/src/provider-capacity-kimi.js +105 -0
- package/src/provider-capacity.js +292 -0
- package/src/providers/index.js +4 -4
- package/src/providers/kimi.js +55 -5
- package/src/run-worker.js +290 -75
- package/src/telegram-ingress/config.js +14 -8
- package/src/telegram-ingress/core.js +78 -47
- package/src/telegram-ingress/http.js +0 -6
- package/src/telegram-webhook.js +11 -10
- package/src/workspace-profile.js +0 -212
- package/threadwire.workspace-profiles.json +0 -12
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import {createHash, randomUUID, timingSafeEqual} from "node:crypto"
|
|
4
|
+
import {abortable} from "../absolute-deadline.js"
|
|
4
5
|
import {evidenceTelegramDestination} from "../evidence-store.js"
|
|
5
6
|
import {NoticeQueue} from "../notice-queue.js"
|
|
6
7
|
import {createFetchTransport} from "../notifiers/fetch-transport.js"
|
|
@@ -10,7 +11,6 @@ import {kimiSessionEnvelopeId} from "../providers/kimi.js"
|
|
|
10
11
|
import {Relay} from "../relay.js"
|
|
11
12
|
import {runWorker} from "../run-worker.js"
|
|
12
13
|
import {WorkerControl} from "../worker-control.js"
|
|
13
|
-
import {resolveWorkspaceProfile} from "../workspace-profile.js"
|
|
14
14
|
import {buildProviderEnvironment, collectEvidenceRedactions} from "./config.js"
|
|
15
15
|
import {parseCodeCommand, parseEvidenceCommand} from "./command.js"
|
|
16
16
|
|
|
@@ -27,12 +27,12 @@ import {parseCodeCommand, parseEvidenceCommand} from "./command.js"
|
|
|
27
27
|
* Relay?: typeof Relay,
|
|
28
28
|
* processNumber?: number,
|
|
29
29
|
* providerEnvironment?: NodeJS.ProcessEnv,
|
|
30
|
+
* cwd?: string,
|
|
30
31
|
* kimiBinding?: unknown,
|
|
31
|
-
*
|
|
32
|
-
* activity?: Pick<import("../activity-log.js").ActivityLog, "recordWorkspace" | "recordStarted" | "recordSession" | "close">,
|
|
32
|
+
* activity?: Pick<import("../activity-log.js").ActivityLog, "recordStarted" | "recordSession" | "close">,
|
|
33
33
|
* evidenceStore?: import("../evidence-store.js").EvidenceStore,
|
|
34
|
-
* isolatedRuntimeClient?: Pick<import("../isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "
|
|
35
|
-
* kimiIsolatedRuntimeClient?: Pick<import("../isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "
|
|
34
|
+
* isolatedRuntimeClient?: Pick<import("../isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "startRun">,
|
|
35
|
+
* kimiIsolatedRuntimeClient?: Pick<import("../isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "startRun">,
|
|
36
36
|
* onWorkerFailure?: (message: string) => void,
|
|
37
37
|
* onWorkerSettled?: () => void
|
|
38
38
|
* }} IngressDependencies
|
|
@@ -97,9 +97,6 @@ export function interpretUpdate(update, config) {
|
|
|
97
97
|
*/
|
|
98
98
|
export async function dispatchWorker(job, config, dependencies = {}) {
|
|
99
99
|
if ("type" in job) throw new Error("Worker dispatch requires a worker job")
|
|
100
|
-
if (job.provider === "kimi" && dependencies.kimiIsolatedRuntimeClient === undefined) {
|
|
101
|
-
throw new Error("Kimi isolated runtime is required")
|
|
102
|
-
}
|
|
103
100
|
if (job.provider === "codex" && config.requireCodexIsolation === true
|
|
104
101
|
&& dependencies.isolatedRuntimeClient === undefined) {
|
|
105
102
|
throw new Error("Codex isolated runtime is required")
|
|
@@ -112,65 +109,74 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
112
109
|
const NoticeQueueImpl = dependencies.NoticeQueue ?? NoticeQueue
|
|
113
110
|
const RelayImpl = dependencies.Relay ?? Relay
|
|
114
111
|
const processNumber = dependencies.processNumber ?? process.pid
|
|
115
|
-
|
|
116
|
-
const providerEnvironment = buildProviderEnvironment(dependencies.providerEnvironment ?? {})
|
|
112
|
+
const cwd = dependencies.cwd ?? process.cwd()
|
|
117
113
|
const kimiBinding = job.provider === "kimi" ? dependencies.kimiBinding : undefined
|
|
118
|
-
const workspace = await resolveWorkspaceProfile(
|
|
119
|
-
{provider: job.provider, ...(kimiBinding === undefined ? {} : {binding: kimiBinding})},
|
|
120
|
-
dependencies.workspaceProfileOperations
|
|
121
|
-
)
|
|
122
114
|
const isolatedRuntimeClient = job.provider === "kimi"
|
|
123
115
|
? dependencies.kimiIsolatedRuntimeClient
|
|
124
116
|
: job.provider === "codex" ? dependencies.isolatedRuntimeClient : undefined
|
|
125
|
-
|
|
117
|
+
// Explicit only: never fall back to ambient process.env (avoids secret leakage).
|
|
118
|
+
const providerEnvironment = buildProviderEnvironment(dependencies.providerEnvironment ?? {}, {
|
|
119
|
+
provider: job.provider,
|
|
120
|
+
isolated: isolatedRuntimeClient !== undefined
|
|
121
|
+
})
|
|
122
|
+
const earlyKimiPreflight = job.provider === "kimi" && isolatedRuntimeClient !== undefined
|
|
126
123
|
? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).preflight({
|
|
127
124
|
provider: "kimi",
|
|
128
|
-
profile:
|
|
125
|
+
profile: "explicit-isolation",
|
|
129
126
|
binding: kimiBinding,
|
|
130
127
|
providerArguments: []
|
|
131
128
|
})
|
|
132
129
|
: undefined
|
|
133
130
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
131
|
+
/** @type {ReturnType<typeof createProvider>} */
|
|
132
|
+
let provider
|
|
133
|
+
/** @type {ReturnType<typeof createTelegramSender>} */
|
|
134
|
+
let sender
|
|
135
|
+
/** @type {WorkerControl} */
|
|
136
|
+
let control
|
|
137
|
+
/** @type {Awaited<ReturnType<import("../evidence-store.js").EvidenceStore["createArtifact"]>> | undefined} */
|
|
138
|
+
let evidence
|
|
139
|
+
try {
|
|
140
|
+
provider = createProviderImpl(job.provider, [], job.prompt, undefined, providerEnvironment)
|
|
141
|
+
const transport = createFetchTransportImpl(config.botToken, undefined, config.telegramRequestTimeoutMs)
|
|
142
|
+
sender = createTelegramSenderImpl(job.target, transport)
|
|
143
|
+
control = new WorkerControlImpl({
|
|
144
|
+
sender,
|
|
145
|
+
processNumber,
|
|
146
|
+
toolMessages: config.toolMessages ?? false,
|
|
147
|
+
NoticeQueueClass: NoticeQueueImpl,
|
|
148
|
+
RelayClass: RelayImpl
|
|
149
|
+
})
|
|
150
|
+
const evidenceOwner = dependencies.evidenceStore?.createOwnerScope({
|
|
151
|
+
destinationId: evidenceTelegramDestination(job.target, job.senderId),
|
|
152
|
+
runId: randomUUID()
|
|
153
|
+
})
|
|
154
|
+
evidence = evidenceOwner === undefined ? undefined : await dependencies.evidenceStore?.createArtifact(evidenceOwner, {
|
|
155
|
+
contentType: "text/plain; charset=utf-8",
|
|
156
|
+
redactions: await collectEvidenceRedactions(dependencies.providerEnvironment ?? {})
|
|
157
|
+
})
|
|
158
|
+
} catch (error) {
|
|
159
|
+
earlyKimiPreflight?.deadline?.close()
|
|
160
|
+
throw error
|
|
161
|
+
}
|
|
158
162
|
let evidenceTransferred = false
|
|
159
163
|
try {
|
|
160
164
|
await evidence?.append("prompt", `prompt\n${job.prompt}\nprovider-stream\n`)
|
|
161
165
|
|
|
162
166
|
if (isolatedRuntimeClient !== undefined) {
|
|
163
167
|
evidenceTransferred = true
|
|
168
|
+
let isolatedDeadline = earlyKimiPreflight?.deadline
|
|
164
169
|
try {
|
|
165
170
|
const preflight = earlyKimiPreflight ?? await isolatedRuntimeClient.preflight({
|
|
166
171
|
provider: job.provider,
|
|
167
|
-
profile:
|
|
172
|
+
profile: "explicit-isolation",
|
|
168
173
|
...(job.provider === "kimi"
|
|
169
174
|
? {binding: kimiBinding}
|
|
170
|
-
: {repositoryRoot:
|
|
175
|
+
: {repositoryRoot: cwd, cwd}),
|
|
171
176
|
providerArguments: []
|
|
172
177
|
})
|
|
173
|
-
|
|
178
|
+
isolatedDeadline = preflight.deadline
|
|
179
|
+
const isolatedRun = isolatedRuntimeClient.startRun({
|
|
174
180
|
preflightId: preflight.preflightId,
|
|
175
181
|
prompt: job.prompt,
|
|
176
182
|
providerArguments: [],
|
|
@@ -185,6 +191,13 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
185
191
|
onStderrChunk: (chunk) => evidence?.append("provider-stderr", chunk)
|
|
186
192
|
})
|
|
187
193
|
})
|
|
194
|
+
let exitCode
|
|
195
|
+
try {
|
|
196
|
+
exitCode = await abortable(isolatedRun.completion, preflight.deadline?.signal)
|
|
197
|
+
} catch (error) {
|
|
198
|
+
await isolatedRun.cancel(error instanceof Error ? error : new Error("Isolated runtime run failed"))
|
|
199
|
+
throw error
|
|
200
|
+
}
|
|
188
201
|
if (exitCode !== 0) throw new Error(`Isolated ${job.provider === "kimi" ? "Kimi" : "Codex"} worker exited with status ${exitCode}`)
|
|
189
202
|
await control.close()
|
|
190
203
|
if (evidence !== undefined) {
|
|
@@ -197,6 +210,7 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
197
210
|
await evidence?.abort()
|
|
198
211
|
throw error
|
|
199
212
|
} finally {
|
|
213
|
+
isolatedDeadline?.close()
|
|
200
214
|
dependencies.onWorkerSettled?.()
|
|
201
215
|
}
|
|
202
216
|
}
|
|
@@ -241,9 +255,11 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
241
255
|
const running = runWorkerImpl({
|
|
242
256
|
executable: provider.executable,
|
|
243
257
|
arguments: provider.arguments,
|
|
244
|
-
cwd
|
|
258
|
+
cwd,
|
|
245
259
|
environment: providerEnvironment,
|
|
260
|
+
provider: provider.name,
|
|
246
261
|
parse: provider.parse,
|
|
262
|
+
...(provider.completion === undefined ? {} : {completion: provider.completion}),
|
|
247
263
|
onEvent: async (event) => control.accept(event),
|
|
248
264
|
onSpawn: (pid) => {
|
|
249
265
|
if (pid !== undefined) dependencies.activity?.recordStarted(provider.name, pid)
|
|
@@ -262,11 +278,22 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
262
278
|
// reported. Always notify settlement so concurrency slots are released.
|
|
263
279
|
evidenceTransferred = true
|
|
264
280
|
void (async () => {
|
|
281
|
+
/** @type {Error | undefined} */
|
|
282
|
+
let nativeKimiExitFailure
|
|
265
283
|
try {
|
|
266
284
|
try {
|
|
267
|
-
await running
|
|
285
|
+
const exitCode = await running
|
|
286
|
+
if (job.provider === "kimi" && spawnGate.settled) {
|
|
287
|
+
nativeKimiExitFailure = exitCode === 0 ? undefined : new Error(`Kimi worker exited with status ${exitCode}`)
|
|
288
|
+
await control.accept({
|
|
289
|
+
type: "lifecycle",
|
|
290
|
+
phase: exitCode === 0 ? "completed" : "failed",
|
|
291
|
+
summary: exitCode === 0 ? "Kimi worker completed" : "Kimi worker failed"
|
|
292
|
+
})
|
|
293
|
+
if (nativeKimiExitFailure !== undefined) throw nativeKimiExitFailure
|
|
294
|
+
}
|
|
268
295
|
} catch (error) {
|
|
269
|
-
const failure = error instanceof Error ? error : new Error(safeFailureMessage(error))
|
|
296
|
+
const failure = nativeKimiExitFailure ?? (error instanceof Error ? error : new Error(safeFailureMessage(error)))
|
|
270
297
|
if (spawnGate.settled) {
|
|
271
298
|
reportBackgroundFailure(failure)
|
|
272
299
|
} else {
|
|
@@ -316,7 +343,11 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
316
343
|
|
|
317
344
|
await spawned
|
|
318
345
|
} finally {
|
|
319
|
-
|
|
346
|
+
try {
|
|
347
|
+
if (!evidenceTransferred) await evidence?.abort()
|
|
348
|
+
} finally {
|
|
349
|
+
earlyKimiPreflight?.deadline?.close()
|
|
350
|
+
}
|
|
320
351
|
}
|
|
321
352
|
}
|
|
322
353
|
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import {createConcurrencyLimiter} from "./concurrency.js"
|
|
4
4
|
import {dispatchEvidenceRead, dispatchWorker, interpretUpdate, secretsEqual} from "./core.js"
|
|
5
5
|
import {createUpdateGuard, parseUpdateId} from "./update-guard.js"
|
|
6
|
-
import {WorkspaceProviderMismatchError} from "../workspace-profile.js"
|
|
7
6
|
|
|
8
7
|
export const MAX_WEBHOOK_BODY_BYTES = 65_536
|
|
9
8
|
export const WEBHOOK_PATH = "/webhook"
|
|
@@ -159,11 +158,6 @@ async function handleRequest(request, response, config, dependencies) {
|
|
|
159
158
|
// Safety release for failures before the observer is installed (e.g. provider setup).
|
|
160
159
|
// Idempotent with onWorkerSettled via slotHeld.
|
|
161
160
|
releaseSlot()
|
|
162
|
-
if (error instanceof WorkspaceProviderMismatchError) {
|
|
163
|
-
dependencies.updateGuard.complete(updateId)
|
|
164
|
-
send(response, 200)
|
|
165
|
-
return
|
|
166
|
-
}
|
|
167
161
|
dependencies.updateGuard.release(updateId)
|
|
168
162
|
const message = error instanceof Error ? error.message : "Worker launch failed"
|
|
169
163
|
dependencies.onOperationalError?.(message)
|
package/src/telegram-webhook.js
CHANGED
|
@@ -17,7 +17,7 @@ import {parseTrustedThreadwireBinding} from "./threadwire-binding.js"
|
|
|
17
17
|
* onOperationalError?: (message: string) => void,
|
|
18
18
|
* onWorkerFailure?: (message: string) => void,
|
|
19
19
|
* providerEnvironment?: NodeJS.ProcessEnv,
|
|
20
|
-
* activity?: Pick<ActivityLog, "
|
|
20
|
+
* activity?: Pick<ActivityLog, "recordStarted" | "recordSession" | "close">,
|
|
21
21
|
* evidenceStore?: EvidenceStore,
|
|
22
22
|
* handlerDependencies?: import("./telegram-ingress/http.js").WebhookDependencies
|
|
23
23
|
* }} [options]
|
|
@@ -25,14 +25,6 @@ import {parseTrustedThreadwireBinding} from "./threadwire-binding.js"
|
|
|
25
25
|
*/
|
|
26
26
|
export async function startTelegramWebhook(options = {}) {
|
|
27
27
|
const environment = await resolveIngressEnvironment(options.env ?? process.env)
|
|
28
|
-
// Capture this trusted topology attestation before anything derives a
|
|
29
|
-
// provider environment. The parsed object is passed only to Kimi preflight.
|
|
30
|
-
// Codex-only Compose renders the variable as an empty string; a blank value
|
|
31
|
-
// means "absent", while a malformed non-blank value still fails closed.
|
|
32
|
-
const rawKimiBinding = environment.THREADWIRE_KIMI_TASK_BINDING
|
|
33
|
-
const kimiBinding = rawKimiBinding === undefined || rawKimiBinding.trim().length === 0
|
|
34
|
-
? undefined
|
|
35
|
-
: parseTrustedThreadwireBinding(rawKimiBinding)
|
|
36
28
|
const config = parseIngressConfig(environment)
|
|
37
29
|
const createServerImpl = options.createServerImpl ?? createServer
|
|
38
30
|
const onOperationalError = options.onOperationalError ?? defaultOperationalError
|
|
@@ -42,7 +34,10 @@ export async function startTelegramWebhook(options = {}) {
|
|
|
42
34
|
// Strip ingress/Telegram secrets before they enter the worker dependency chain.
|
|
43
35
|
// Never pass ambient process.env through unfiltered.
|
|
44
36
|
const providerEnvironment = buildProviderEnvironment(
|
|
45
|
-
options.providerEnvironment ?? environment
|
|
37
|
+
options.providerEnvironment ?? environment,
|
|
38
|
+
// dispatchWorker applies the final provider/path-aware filter. Retain only
|
|
39
|
+
// the narrow native Kimi candidate configuration until it can do so.
|
|
40
|
+
{provider: "kimi"}
|
|
46
41
|
)
|
|
47
42
|
const isolatedConfigured = typeof environment.THREADWIRE_ISOLATED_RUNTIME_CONTROL_TOKEN === "string"
|
|
48
43
|
&& environment.THREADWIRE_ISOLATED_RUNTIME_CONTROL_TOKEN.trim().length > 0
|
|
@@ -52,6 +47,12 @@ export async function startTelegramWebhook(options = {}) {
|
|
|
52
47
|
&& environment.THREADWIRE_KIMI_ISOLATED_RUNTIME_CONTROL_TOKEN.trim().length > 0
|
|
53
48
|
const kimiIsolatedRuntimeClient = options.handlerDependencies?.kimiIsolatedRuntimeClient
|
|
54
49
|
?? (kimiIsolatedConfigured ? kimiIsolatedRuntimeClientFromEnvironment(environment) : undefined)
|
|
50
|
+
// A task binding belongs solely to an explicitly selected isolated Kimi
|
|
51
|
+
// supervisor. Native Kimi neither reads nor validates this topology input.
|
|
52
|
+
const rawKimiBinding = environment.THREADWIRE_KIMI_TASK_BINDING
|
|
53
|
+
const kimiBinding = kimiIsolatedRuntimeClient === undefined || rawKimiBinding === undefined || rawKimiBinding.trim().length === 0
|
|
54
|
+
? undefined
|
|
55
|
+
: parseTrustedThreadwireBinding(rawKimiBinding)
|
|
55
56
|
const activity = options.activity ?? options.handlerDependencies?.activity ?? new ActivityLog("/var/lib/threadwire/activity/threadwire.jsonl")
|
|
56
57
|
const evidenceStore = options.evidenceStore ?? options.handlerDependencies?.evidenceStore ?? await EvidenceStore.open({
|
|
57
58
|
root: config.evidenceRoot ?? "/var/lib/threadwire/evidence"
|
package/src/workspace-profile.js
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
|
|
3
|
-
import {execFile as nodeExecFile} from "node:child_process"
|
|
4
|
-
import {readFile} from "node:fs/promises"
|
|
5
|
-
import {dirname, join, resolve, sep} from "node:path"
|
|
6
|
-
import {fileURLToPath} from "node:url"
|
|
7
|
-
import {promisify} from "node:util"
|
|
8
|
-
import {PROVIDERS} from "./providers/index.js"
|
|
9
|
-
import {parseThreadwireBinding} from "./threadwire-binding.js"
|
|
10
|
-
|
|
11
|
-
const execFile = promisify(nodeExecFile)
|
|
12
|
-
const DEFAULT_PROFILES_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "threadwire.workspace-profiles.json")
|
|
13
|
-
const REVISION_PATTERN = /^[0-9a-f]{40}$/u
|
|
14
|
-
const SOURCE_IDENTITY_PATTERN = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/u
|
|
15
|
-
const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @typedef {{repositoryRoot: string, cwd: string, providers: ("codex" | "claude" | "kimi" | "opencode")[], bindings?: {kimi: "threadwire-v1"}}} WorkspaceProfile
|
|
19
|
-
* @typedef {{version: 1 | 2, defaultProfile: string, profiles: Record<string, WorkspaceProfile>}} WorkspaceProfilesConfig
|
|
20
|
-
* @typedef {{profile: string, repositoryRoot: string, cwd: string, revision: string, sourceIdentity: string, binding?: unknown}} ResolvedWorkspaceProfile
|
|
21
|
-
* @typedef {{repositoryRoot: string, revision: string, sourceIdentity: string}} GitProvenance
|
|
22
|
-
* @typedef {{
|
|
23
|
-
* readProfiles?: () => Promise<unknown>,
|
|
24
|
-
* directoryExists?: (path: string) => Promise<boolean>,
|
|
25
|
-
* resolveGitProvenance?: (cwd: string) => Promise<GitProvenance>
|
|
26
|
-
* }} WorkspaceProfileOperations
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
/** @param {unknown} value @returns {WorkspaceProfilesConfig} */
|
|
30
|
-
export function parseWorkspaceProfiles(value) {
|
|
31
|
-
if (!isRecord(value) || (value.version !== 1 && value.version !== 2) || typeof value.defaultProfile !== "string" || !isRecord(value.profiles)) {
|
|
32
|
-
throw new Error("Workspace profile configuration is invalid")
|
|
33
|
-
}
|
|
34
|
-
assertExactKeys(value, ["version", "defaultProfile", "profiles"])
|
|
35
|
-
/** @type {Record<string, WorkspaceProfile>} */
|
|
36
|
-
const profiles = {}
|
|
37
|
-
for (const [name, profile] of Object.entries(value.profiles)) {
|
|
38
|
-
if (!PROFILE_NAME_PATTERN.test(name) || !isRecord(profile)) throw new Error("Workspace profile configuration is invalid")
|
|
39
|
-
assertExactKeys(profile, value.version === 2 ? ["repositoryRoot", "cwd", "providers", "bindings"] : ["repositoryRoot", "cwd", "providers"])
|
|
40
|
-
const repositoryRoot = absoluteNormalizedPath(profile.repositoryRoot)
|
|
41
|
-
const cwd = absoluteAbsolutePath(profile.cwd)
|
|
42
|
-
if (cwd !== resolve(cwd) || !pathWithin(repositoryRoot, resolve(cwd))) {
|
|
43
|
-
throw new Error(`Workspace profile ${name} cwd must stay within its repository root`)
|
|
44
|
-
}
|
|
45
|
-
const providers = parseProviders(profile.providers)
|
|
46
|
-
let bindings
|
|
47
|
-
if (value.version === 2 && Object.hasOwn(profile, "bindings")) {
|
|
48
|
-
if (!isRecord(profile.bindings)) throw new Error("Workspace profile configuration is invalid")
|
|
49
|
-
assertExactKeys(profile.bindings, ["kimi"])
|
|
50
|
-
if (profile.bindings.kimi !== "threadwire-v1") throw new Error("Workspace profile configuration is invalid")
|
|
51
|
-
bindings = /** @type {{kimi: "threadwire-v1"}} */ ({kimi: "threadwire-v1"})
|
|
52
|
-
}
|
|
53
|
-
profiles[name] = {repositoryRoot, cwd: resolve(cwd), providers, ...(bindings === undefined ? {} : {bindings})}
|
|
54
|
-
}
|
|
55
|
-
if (!Object.hasOwn(profiles, value.defaultProfile)) {
|
|
56
|
-
throw new Error("Workspace profile configuration is invalid")
|
|
57
|
-
}
|
|
58
|
-
return {version: value.version, defaultProfile: value.defaultProfile, profiles}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Resolve a reviewed workspace profile to a validated in-container workspace.
|
|
63
|
-
* @param {{provider: "codex" | "claude" | "kimi" | "opencode", profile?: string, binding?: unknown}} selection
|
|
64
|
-
* @param {WorkspaceProfileOperations} [operations]
|
|
65
|
-
* @returns {Promise<ResolvedWorkspaceProfile>}
|
|
66
|
-
*/
|
|
67
|
-
export async function resolveWorkspaceProfile(selection, operations = {}) {
|
|
68
|
-
const raw = await (operations.readProfiles ?? readProfiles)()
|
|
69
|
-
const config = parseWorkspaceProfiles(raw)
|
|
70
|
-
const profileName = selection.profile ?? config.defaultProfile
|
|
71
|
-
const profile = config.profiles[profileName]
|
|
72
|
-
if (!profile) throw new Error("--workspace-profile must name a configured workspace profile")
|
|
73
|
-
if (!profile.providers.includes(selection.provider)) {
|
|
74
|
-
throw new WorkspaceProviderMismatchError(profileName, selection.provider)
|
|
75
|
-
}
|
|
76
|
-
if (selection.provider === "kimi") {
|
|
77
|
-
if (config.version !== 2 || profile.bindings?.kimi !== "threadwire-v1" || selection.binding === undefined) {
|
|
78
|
-
throw new Error("Kimi requires Threadwire binding schema v1")
|
|
79
|
-
}
|
|
80
|
-
let binding
|
|
81
|
-
try { binding = parseThreadwireBinding(selection.binding) } catch { throw new Error("Kimi requires Threadwire binding schema v1") }
|
|
82
|
-
return {
|
|
83
|
-
profile: profileName,
|
|
84
|
-
repositoryRoot: binding.source.target,
|
|
85
|
-
cwd: binding.runtime.workdir,
|
|
86
|
-
revision: binding.source.revision,
|
|
87
|
-
sourceIdentity: binding.context.digests.content.slice("sha256:".length),
|
|
88
|
-
binding
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
const directoryExists = operations.directoryExists ?? defaultDirectoryExists
|
|
92
|
-
if (!await directoryExists(profile.repositoryRoot) || !await directoryExists(profile.cwd)) {
|
|
93
|
-
throw new Error(`Workspace profile ${profileName} workspace is unavailable`)
|
|
94
|
-
}
|
|
95
|
-
let provenance
|
|
96
|
-
try {
|
|
97
|
-
provenance = await (operations.resolveGitProvenance ?? defaultResolveGitProvenance)(profile.cwd)
|
|
98
|
-
} catch {
|
|
99
|
-
throw new Error(`Workspace profile ${profileName} workspace is unavailable`)
|
|
100
|
-
}
|
|
101
|
-
if (provenance.repositoryRoot !== profile.repositoryRoot) {
|
|
102
|
-
throw new Error(`Workspace profile ${profileName} workspace is unavailable`)
|
|
103
|
-
}
|
|
104
|
-
return {
|
|
105
|
-
profile: profileName,
|
|
106
|
-
repositoryRoot: profile.repositoryRoot,
|
|
107
|
-
cwd: profile.cwd,
|
|
108
|
-
revision: provenance.revision,
|
|
109
|
-
sourceIdentity: provenance.sourceIdentity
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export class WorkspaceProviderMismatchError extends Error {
|
|
114
|
-
/** @param {string} profileName @param {string} provider */
|
|
115
|
-
constructor(profileName, provider) {
|
|
116
|
-
super(`Workspace profile ${profileName} does not allow provider ${provider}`)
|
|
117
|
-
this.name = "WorkspaceProviderMismatchError"
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** @returns {Promise<unknown>} */
|
|
122
|
-
async function readProfiles() {
|
|
123
|
-
return JSON.parse(await readFile(DEFAULT_PROFILES_PATH, "utf8"))
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** @param {string} path @returns {Promise<boolean>} */
|
|
127
|
-
async function defaultDirectoryExists(path) {
|
|
128
|
-
try {
|
|
129
|
-
const metadata = await import("node:fs/promises").then(({stat}) => stat(path))
|
|
130
|
-
return metadata.isDirectory()
|
|
131
|
-
} catch {
|
|
132
|
-
return false
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** @param {string} cwd @returns {Promise<GitProvenance>} */
|
|
137
|
-
async function defaultResolveGitProvenance(cwd) {
|
|
138
|
-
const repositoryRoot = await gitText(cwd, ["rev-parse", "--show-toplevel"])
|
|
139
|
-
const revision = await gitText(cwd, ["rev-parse", "HEAD"])
|
|
140
|
-
const sourceIdentity = await readSourceIdentity(repositoryRoot, cwd)
|
|
141
|
-
if (!REVISION_PATTERN.test(revision) || !SOURCE_IDENTITY_PATTERN.test(sourceIdentity)) throw new Error("invalid workspace provenance")
|
|
142
|
-
return {repositoryRoot, revision, sourceIdentity}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** @param {string} cwd @param {string[]} arguments_ */
|
|
146
|
-
async function gitText(cwd, arguments_) {
|
|
147
|
-
const {stdout} = await execFile("git", ["-C", cwd, ...arguments_], {encoding: "utf8"})
|
|
148
|
-
return stdout.trim()
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** @param {string} repositoryRoot @param {string} cwd */
|
|
152
|
-
async function readSourceIdentity(repositoryRoot, cwd) {
|
|
153
|
-
try {
|
|
154
|
-
return (await readFile(join(repositoryRoot, ".threadwire-source-identity"), "utf8")).trim()
|
|
155
|
-
} catch {
|
|
156
|
-
return gitText(cwd, ["rev-parse", "HEAD^{tree}"])
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** @param {unknown} value */
|
|
161
|
-
function parseProviders(value) {
|
|
162
|
-
if (!Array.isArray(value) || value.length === 0) throw new Error("Workspace profile configuration is invalid")
|
|
163
|
-
/** @type {("codex" | "claude" | "kimi" | "opencode")[]} */
|
|
164
|
-
const providers = []
|
|
165
|
-
for (const entry of value) {
|
|
166
|
-
if (typeof entry !== "string") {
|
|
167
|
-
throw new Error("Workspace profile configuration is invalid")
|
|
168
|
-
}
|
|
169
|
-
const provider = /** @type {(typeof PROVIDERS)[number]} */ (entry)
|
|
170
|
-
if (!PROVIDERS.includes(provider) || providers.includes(provider)) {
|
|
171
|
-
throw new Error(typeof entry === "string" ? `Workspace profile configuration contains unknown provider ${entry}` : "Workspace profile configuration is invalid")
|
|
172
|
-
}
|
|
173
|
-
providers.push(provider)
|
|
174
|
-
}
|
|
175
|
-
return providers
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** @param {unknown} value */
|
|
179
|
-
function absoluteNormalizedPath(value) {
|
|
180
|
-
if (typeof value !== "string") throw new Error("Workspace profile configuration is invalid")
|
|
181
|
-
const normalized = resolve(value)
|
|
182
|
-
if (!normalized.startsWith(sep) || value !== normalized) {
|
|
183
|
-
throw new Error("Workspace profile configuration is invalid")
|
|
184
|
-
}
|
|
185
|
-
return normalized
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** @param {unknown} value */
|
|
189
|
-
function absoluteAbsolutePath(value) {
|
|
190
|
-
if (typeof value !== "string") throw new Error("Workspace profile configuration is invalid")
|
|
191
|
-
const normalized = resolve(value)
|
|
192
|
-
if (!normalized.startsWith(sep)) throw new Error("Workspace profile configuration is invalid")
|
|
193
|
-
return value
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** @param {string} root @param {string} child */
|
|
197
|
-
function pathWithin(root, child) {
|
|
198
|
-
return child === root || child.startsWith(`${root}${sep}`)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
202
|
-
function isRecord(value) {
|
|
203
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** @param {Record<string, unknown>} value @param {string[]} allowedKeys */
|
|
207
|
-
function assertExactKeys(value, allowedKeys) {
|
|
208
|
-
const allowed = new Set(allowedKeys)
|
|
209
|
-
for (const key of Object.keys(value)) {
|
|
210
|
-
if (!allowed.has(key)) throw new Error("Workspace profile configuration is invalid")
|
|
211
|
-
}
|
|
212
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 2,
|
|
3
|
-
"defaultProfile": "container-runtime",
|
|
4
|
-
"profiles": {
|
|
5
|
-
"container-runtime": {
|
|
6
|
-
"repositoryRoot": "/workspace/threadwire",
|
|
7
|
-
"cwd": "/workspace/threadwire",
|
|
8
|
-
"providers": ["codex", "kimi"],
|
|
9
|
-
"bindings": {"kimi": "threadwire-v1"}
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
}
|