threadwire 0.1.11 → 0.1.13
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 +25 -0
- package/README.md +16 -8
- package/TELEGRAM-INGRESS.md +8 -6
- package/docs/container-runtime.md +56 -36
- package/docs/isolated-provider-runtime.md +113 -32
- package/package.json +2 -3
- package/scripts/provider-shims/front-door.sh.template +1 -5
- package/scripts/verify-package.js +2 -3
- package/src/absolute-deadline.js +8 -5
- package/src/activity-log.js +1 -14
- package/src/cli.js +17 -44
- package/src/docker-api.js +100 -14
- package/src/isolated-runtime-client.js +116 -44
- package/src/isolated-runtime.js +673 -64
- package/src/isolated-state.js +62 -11
- package/src/isolated-worker.js +231 -23
- package/src/kimi-model-broker-policy.js +16 -5
- package/src/kimi-model-broker.js +134 -92
- package/src/model-broker-policy.js +11 -8
- package/src/model-broker.js +14 -0
- package/src/providers/index.js +1 -1
- package/src/providers/kimi.js +40 -8
- package/src/run-worker.js +5 -5
- package/src/telegram-ingress/config.js +14 -8
- package/src/telegram-ingress/core.js +32 -28
- package/src/telegram-ingress/http.js +0 -6
- package/src/telegram-webhook.js +13 -2
- package/src/threadwire-binding.js +192 -0
- package/src/workspace-profile.js +0 -189
- package/threadwire.workspace-profiles.json +0 -11
|
@@ -10,7 +10,6 @@ import {kimiSessionEnvelopeId} from "../providers/kimi.js"
|
|
|
10
10
|
import {Relay} from "../relay.js"
|
|
11
11
|
import {runWorker} from "../run-worker.js"
|
|
12
12
|
import {WorkerControl} from "../worker-control.js"
|
|
13
|
-
import {resolveWorkspaceProfile} from "../workspace-profile.js"
|
|
14
13
|
import {buildProviderEnvironment, collectEvidenceRedactions} from "./config.js"
|
|
15
14
|
import {parseCodeCommand, parseEvidenceCommand} from "./command.js"
|
|
16
15
|
|
|
@@ -27,8 +26,9 @@ import {parseCodeCommand, parseEvidenceCommand} from "./command.js"
|
|
|
27
26
|
* Relay?: typeof Relay,
|
|
28
27
|
* processNumber?: number,
|
|
29
28
|
* providerEnvironment?: NodeJS.ProcessEnv,
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* cwd?: string,
|
|
30
|
+
* kimiBinding?: unknown,
|
|
31
|
+
* activity?: Pick<import("../activity-log.js").ActivityLog, "recordStarted" | "recordSession" | "close">,
|
|
32
32
|
* evidenceStore?: import("../evidence-store.js").EvidenceStore,
|
|
33
33
|
* isolatedRuntimeClient?: Pick<import("../isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">,
|
|
34
34
|
* kimiIsolatedRuntimeClient?: Pick<import("../isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">,
|
|
@@ -96,9 +96,6 @@ export function interpretUpdate(update, config) {
|
|
|
96
96
|
*/
|
|
97
97
|
export async function dispatchWorker(job, config, dependencies = {}) {
|
|
98
98
|
if ("type" in job) throw new Error("Worker dispatch requires a worker job")
|
|
99
|
-
if (job.provider === "kimi" && dependencies.kimiIsolatedRuntimeClient === undefined) {
|
|
100
|
-
throw new Error("Kimi isolated runtime is required")
|
|
101
|
-
}
|
|
102
99
|
if (job.provider === "codex" && config.requireCodexIsolation === true
|
|
103
100
|
&& dependencies.isolatedRuntimeClient === undefined) {
|
|
104
101
|
throw new Error("Codex isolated runtime is required")
|
|
@@ -111,32 +108,26 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
111
108
|
const NoticeQueueImpl = dependencies.NoticeQueue ?? NoticeQueue
|
|
112
109
|
const RelayImpl = dependencies.Relay ?? Relay
|
|
113
110
|
const processNumber = dependencies.processNumber ?? process.pid
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
const workspace = await resolveWorkspaceProfile(
|
|
117
|
-
{provider: job.provider},
|
|
118
|
-
dependencies.workspaceProfileOperations
|
|
119
|
-
)
|
|
111
|
+
const cwd = dependencies.cwd ?? process.cwd()
|
|
112
|
+
const kimiBinding = job.provider === "kimi" ? dependencies.kimiBinding : undefined
|
|
120
113
|
const isolatedRuntimeClient = job.provider === "kimi"
|
|
121
114
|
? dependencies.kimiIsolatedRuntimeClient
|
|
122
115
|
: job.provider === "codex" ? dependencies.isolatedRuntimeClient : undefined
|
|
123
|
-
|
|
116
|
+
// Explicit only: never fall back to ambient process.env (avoids secret leakage).
|
|
117
|
+
const providerEnvironment = buildProviderEnvironment(dependencies.providerEnvironment ?? {}, {
|
|
118
|
+
provider: job.provider,
|
|
119
|
+
isolated: isolatedRuntimeClient !== undefined
|
|
120
|
+
})
|
|
121
|
+
const earlyKimiPreflight = job.provider === "kimi" && isolatedRuntimeClient !== undefined
|
|
124
122
|
? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).preflight({
|
|
125
123
|
provider: "kimi",
|
|
126
|
-
profile:
|
|
127
|
-
|
|
128
|
-
cwd: workspace.cwd,
|
|
124
|
+
profile: "explicit-isolation",
|
|
125
|
+
binding: kimiBinding,
|
|
129
126
|
providerArguments: []
|
|
130
127
|
})
|
|
131
128
|
: undefined
|
|
132
129
|
|
|
133
130
|
const provider = createProviderImpl(job.provider, [], job.prompt, undefined, providerEnvironment)
|
|
134
|
-
dependencies.activity?.recordWorkspace(
|
|
135
|
-
workspace.profile,
|
|
136
|
-
workspace.repositoryRoot,
|
|
137
|
-
workspace.revision,
|
|
138
|
-
workspace.sourceIdentity
|
|
139
|
-
)
|
|
140
131
|
const transport = createFetchTransportImpl(config.botToken, undefined, config.telegramRequestTimeoutMs)
|
|
141
132
|
const sender = createTelegramSenderImpl(job.target, transport)
|
|
142
133
|
const control = new WorkerControlImpl({
|
|
@@ -163,9 +154,10 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
163
154
|
try {
|
|
164
155
|
const preflight = earlyKimiPreflight ?? await isolatedRuntimeClient.preflight({
|
|
165
156
|
provider: job.provider,
|
|
166
|
-
profile:
|
|
167
|
-
|
|
168
|
-
|
|
157
|
+
profile: "explicit-isolation",
|
|
158
|
+
...(job.provider === "kimi"
|
|
159
|
+
? {binding: kimiBinding}
|
|
160
|
+
: {repositoryRoot: cwd, cwd}),
|
|
169
161
|
providerArguments: []
|
|
170
162
|
})
|
|
171
163
|
const exitCode = await isolatedRuntimeClient.run({
|
|
@@ -239,8 +231,9 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
239
231
|
const running = runWorkerImpl({
|
|
240
232
|
executable: provider.executable,
|
|
241
233
|
arguments: provider.arguments,
|
|
242
|
-
cwd
|
|
234
|
+
cwd,
|
|
243
235
|
environment: providerEnvironment,
|
|
236
|
+
provider: provider.name,
|
|
244
237
|
parse: provider.parse,
|
|
245
238
|
onEvent: async (event) => control.accept(event),
|
|
246
239
|
onSpawn: (pid) => {
|
|
@@ -260,11 +253,22 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
260
253
|
// reported. Always notify settlement so concurrency slots are released.
|
|
261
254
|
evidenceTransferred = true
|
|
262
255
|
void (async () => {
|
|
256
|
+
/** @type {Error | undefined} */
|
|
257
|
+
let nativeKimiExitFailure
|
|
263
258
|
try {
|
|
264
259
|
try {
|
|
265
|
-
await running
|
|
260
|
+
const exitCode = await running
|
|
261
|
+
if (job.provider === "kimi" && spawnGate.settled) {
|
|
262
|
+
nativeKimiExitFailure = exitCode === 0 ? undefined : new Error(`Kimi worker exited with status ${exitCode}`)
|
|
263
|
+
await control.accept({
|
|
264
|
+
type: "lifecycle",
|
|
265
|
+
phase: exitCode === 0 ? "completed" : "failed",
|
|
266
|
+
summary: exitCode === 0 ? "Kimi worker completed" : "Kimi worker failed"
|
|
267
|
+
})
|
|
268
|
+
if (nativeKimiExitFailure !== undefined) throw nativeKimiExitFailure
|
|
269
|
+
}
|
|
266
270
|
} catch (error) {
|
|
267
|
-
const failure = error instanceof Error ? error : new Error(safeFailureMessage(error))
|
|
271
|
+
const failure = nativeKimiExitFailure ?? (error instanceof Error ? error : new Error(safeFailureMessage(error)))
|
|
268
272
|
if (spawnGate.settled) {
|
|
269
273
|
reportBackgroundFailure(failure)
|
|
270
274
|
} else {
|
|
@@ -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
|
@@ -6,6 +6,7 @@ import {EvidenceStore} from "./evidence-store.js"
|
|
|
6
6
|
import {buildProviderEnvironment, parseIngressConfig, resolveIngressEnvironment} from "./telegram-ingress/config.js"
|
|
7
7
|
import {createWebhookHandler} from "./telegram-ingress/http.js"
|
|
8
8
|
import {isolatedRuntimeClientFromEnvironment, kimiIsolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
|
|
9
|
+
import {parseTrustedThreadwireBinding} from "./threadwire-binding.js"
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Start the standalone Threadwire Telegram webhook service.
|
|
@@ -16,7 +17,7 @@ import {isolatedRuntimeClientFromEnvironment, kimiIsolatedRuntimeClientFromEnvir
|
|
|
16
17
|
* onOperationalError?: (message: string) => void,
|
|
17
18
|
* onWorkerFailure?: (message: string) => void,
|
|
18
19
|
* providerEnvironment?: NodeJS.ProcessEnv,
|
|
19
|
-
* activity?: Pick<ActivityLog, "
|
|
20
|
+
* activity?: Pick<ActivityLog, "recordStarted" | "recordSession" | "close">,
|
|
20
21
|
* evidenceStore?: EvidenceStore,
|
|
21
22
|
* handlerDependencies?: import("./telegram-ingress/http.js").WebhookDependencies
|
|
22
23
|
* }} [options]
|
|
@@ -33,7 +34,10 @@ export async function startTelegramWebhook(options = {}) {
|
|
|
33
34
|
// Strip ingress/Telegram secrets before they enter the worker dependency chain.
|
|
34
35
|
// Never pass ambient process.env through unfiltered.
|
|
35
36
|
const providerEnvironment = buildProviderEnvironment(
|
|
36
|
-
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"}
|
|
37
41
|
)
|
|
38
42
|
const isolatedConfigured = typeof environment.THREADWIRE_ISOLATED_RUNTIME_CONTROL_TOKEN === "string"
|
|
39
43
|
&& environment.THREADWIRE_ISOLATED_RUNTIME_CONTROL_TOKEN.trim().length > 0
|
|
@@ -43,6 +47,12 @@ export async function startTelegramWebhook(options = {}) {
|
|
|
43
47
|
&& environment.THREADWIRE_KIMI_ISOLATED_RUNTIME_CONTROL_TOKEN.trim().length > 0
|
|
44
48
|
const kimiIsolatedRuntimeClient = options.handlerDependencies?.kimiIsolatedRuntimeClient
|
|
45
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)
|
|
46
56
|
const activity = options.activity ?? options.handlerDependencies?.activity ?? new ActivityLog("/var/lib/threadwire/activity/threadwire.jsonl")
|
|
47
57
|
const evidenceStore = options.evidenceStore ?? options.handlerDependencies?.evidenceStore ?? await EvidenceStore.open({
|
|
48
58
|
root: config.evidenceRoot ?? "/var/lib/threadwire/evidence"
|
|
@@ -55,6 +65,7 @@ export async function startTelegramWebhook(options = {}) {
|
|
|
55
65
|
providerEnvironment,
|
|
56
66
|
...(isolatedRuntimeClient === undefined ? {} : {isolatedRuntimeClient}),
|
|
57
67
|
...(kimiIsolatedRuntimeClient === undefined ? {} : {kimiIsolatedRuntimeClient}),
|
|
68
|
+
...(kimiBinding === undefined ? {} : {kimiBinding}),
|
|
58
69
|
onOperationalError,
|
|
59
70
|
onWorkerFailure
|
|
60
71
|
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
|
|
3
|
+
import {createHash} from "node:crypto"
|
|
4
|
+
import {isAbsolute, normalize, relative} from "node:path"
|
|
5
|
+
|
|
6
|
+
const UUID = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/u
|
|
7
|
+
const VOLUME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u
|
|
8
|
+
const REVISION = /^[0-9a-f]{40}$/u
|
|
9
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/u
|
|
10
|
+
const CONTAINER_ID = /^[0-9a-f]{64}$/u
|
|
11
|
+
const POSITIVE_ID = /^[1-9]\d{0,9}$/u
|
|
12
|
+
const MAX_ID = 2_147_483_647
|
|
13
|
+
const MAX_MANIFEST_BYTES = 1_048_576
|
|
14
|
+
const MAX_CONTEXT_ENTRIES = 4_096
|
|
15
|
+
const MAX_CONTEXT_BYTES = 67_108_864
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse the closed Threadwire binding transport schema. This function is the
|
|
19
|
+
* single authority for a binding: callers must not re-parse loose fields.
|
|
20
|
+
* @param {unknown} value
|
|
21
|
+
*/
|
|
22
|
+
export function parseThreadwireBinding(value) {
|
|
23
|
+
if (!record(value) || !exactKeys(value, ["version", "taskId", "source", "context", "runtime", "leaseContainerId"])
|
|
24
|
+
|| value.version !== 1 || !canonicalUuid(value.taskId) || !CONTAINER_ID.test(value.leaseContainerId)) failBinding()
|
|
25
|
+
const source = parseSource(value.source)
|
|
26
|
+
const context = parseContext(value.context)
|
|
27
|
+
const runtime = parseRuntime(value.runtime)
|
|
28
|
+
if (source.volume === context.volume || runtime.workdir !== "/workspace" && !within("/workspace", runtime.workdir)) failBinding()
|
|
29
|
+
return {
|
|
30
|
+
version: 1,
|
|
31
|
+
taskId: value.taskId,
|
|
32
|
+
source,
|
|
33
|
+
context,
|
|
34
|
+
runtime,
|
|
35
|
+
leaseContainerId: value.leaseContainerId
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse the trusted launcher transport without retaining or reporting its raw
|
|
41
|
+
* text. A binding is control metadata, but still never belongs in a provider
|
|
42
|
+
* or worker environment.
|
|
43
|
+
* @param {unknown} value
|
|
44
|
+
*/
|
|
45
|
+
export function parseTrustedThreadwireBinding(value) {
|
|
46
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 32_768) failBinding()
|
|
47
|
+
try { return parseThreadwireBinding(JSON.parse(value)) } catch { failBinding() }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {ReturnType<typeof parseThreadwireBinding>} binding */
|
|
51
|
+
export function canonicalThreadwireBinding(binding) {
|
|
52
|
+
const parsed = parseThreadwireBinding(binding)
|
|
53
|
+
return JSON.stringify({
|
|
54
|
+
version: parsed.version,
|
|
55
|
+
taskId: parsed.taskId,
|
|
56
|
+
source: {
|
|
57
|
+
volume: parsed.source.volume, target: parsed.source.target,
|
|
58
|
+
readOnly: parsed.source.readOnly, revision: parsed.source.revision
|
|
59
|
+
},
|
|
60
|
+
context: {
|
|
61
|
+
volume: parsed.context.volume, target: parsed.context.target,
|
|
62
|
+
readOnly: parsed.context.readOnly,
|
|
63
|
+
digests: {manifest: parsed.context.digests.manifest, content: parsed.context.digests.content},
|
|
64
|
+
imageId: parsed.context.imageId
|
|
65
|
+
},
|
|
66
|
+
runtime: {uid: parsed.runtime.uid, gid: parsed.runtime.gid, workdir: parsed.runtime.workdir},
|
|
67
|
+
leaseContainerId: parsed.leaseContainerId
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @param {ReturnType<typeof parseThreadwireBinding>} binding */
|
|
72
|
+
export function threadwireBindingDigest(binding) {
|
|
73
|
+
return `sha256:${createHash("sha256").update(canonicalThreadwireBinding(binding)).digest("hex")}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A stable identity for state adoption. It intentionally excludes only the
|
|
78
|
+
* mutable source commit and recreatable lease identifier.
|
|
79
|
+
* @param {ReturnType<typeof parseThreadwireBinding>} binding
|
|
80
|
+
*/
|
|
81
|
+
export function resumeTaskIdentity(binding) {
|
|
82
|
+
const parsed = parseThreadwireBinding(binding)
|
|
83
|
+
return JSON.stringify({
|
|
84
|
+
version: parsed.version,
|
|
85
|
+
taskId: parsed.taskId,
|
|
86
|
+
source: {volume: parsed.source.volume, target: parsed.source.target, readOnly: parsed.source.readOnly},
|
|
87
|
+
context: {
|
|
88
|
+
volume: parsed.context.volume, target: parsed.context.target,
|
|
89
|
+
digests: {manifest: parsed.context.digests.manifest, content: parsed.context.digests.content},
|
|
90
|
+
imageId: parsed.context.imageId
|
|
91
|
+
},
|
|
92
|
+
runtime: parsed.runtime
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {Buffer | string} bytes */
|
|
97
|
+
export function parseContextManifest(bytes) {
|
|
98
|
+
const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
|
|
99
|
+
if (buffer.length === 0 || buffer.length > MAX_MANIFEST_BYTES) failManifest()
|
|
100
|
+
let value
|
|
101
|
+
try { value = JSON.parse(buffer.toString("utf8")) } catch { failManifest() }
|
|
102
|
+
if (!record(value) || !exactKeys(value, ["version", "taskId", "imageId", "content", "entries"])
|
|
103
|
+
|| value.version !== 1 || !canonicalUuid(value.taskId) || !DIGEST.test(value.imageId) || !DIGEST.test(value.content)
|
|
104
|
+
|| !Array.isArray(value.entries) || value.entries.length > MAX_CONTEXT_ENTRIES) failManifest()
|
|
105
|
+
let previous
|
|
106
|
+
let total = 0
|
|
107
|
+
const entries = value.entries.map((entry) => {
|
|
108
|
+
if (!record(entry) || !exactKeys(entry, ["path", "type", "mode", "bytes", "sha256"])
|
|
109
|
+
|| !contextPath(entry.path) || entry.type !== "file" || !Number.isSafeInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777 || (entry.mode & 0o222) !== 0
|
|
110
|
+
|| !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || entry.bytes > MAX_CONTEXT_BYTES || !DIGEST.test(entry.sha256)) failManifest()
|
|
111
|
+
if (previous !== undefined && Buffer.compare(Buffer.from(previous), Buffer.from(entry.path)) >= 0) failManifest()
|
|
112
|
+
previous = entry.path
|
|
113
|
+
total += entry.bytes
|
|
114
|
+
if (total > MAX_CONTEXT_BYTES) failManifest()
|
|
115
|
+
return {path: entry.path, type: "file", mode: entry.mode, bytes: entry.bytes, sha256: entry.sha256}
|
|
116
|
+
})
|
|
117
|
+
return {version: 1, taskId: value.taskId, imageId: value.imageId, content: value.content, entries}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @param {ReturnType<typeof parseContextManifest>} manifest */
|
|
121
|
+
export function canonicalContextInventory(manifest) {
|
|
122
|
+
const parsed = parseContextManifest(Buffer.from(JSON.stringify(manifest)))
|
|
123
|
+
return JSON.stringify(parsed.entries.map((entry) => ({
|
|
124
|
+
path: entry.path, type: entry.type, mode: entry.mode, bytes: entry.bytes, sha256: entry.sha256
|
|
125
|
+
})))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @param {ReturnType<typeof parseContextManifest>} manifest */
|
|
129
|
+
export function contextInventoryDigest(manifest) {
|
|
130
|
+
return `sha256:${createHash("sha256").update(canonicalContextInventory(manifest)).digest("hex")}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Bind a parsed manifest to the exact task and immutable image of the active
|
|
135
|
+
* binding before any inventory is accepted.
|
|
136
|
+
* @param {ReturnType<typeof parseContextManifest>} manifest
|
|
137
|
+
* @param {unknown} taskId
|
|
138
|
+
* @param {unknown} imageId
|
|
139
|
+
*/
|
|
140
|
+
export function assertContextManifestIdentity(manifest, taskId, imageId) {
|
|
141
|
+
if (!canonicalUuid(taskId) || typeof imageId !== "string" || !DIGEST.test(imageId)
|
|
142
|
+
|| manifest.taskId !== taskId || manifest.imageId !== imageId) failManifest()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @param {unknown} value */
|
|
146
|
+
function parseSource(value) {
|
|
147
|
+
// readOnly selects the exact admitted mount mode: false is a writable
|
|
148
|
+
// implementation source, true an explicitly read-only review source.
|
|
149
|
+
if (!record(value) || !exactKeys(value, ["volume", "target", "readOnly", "revision"])
|
|
150
|
+
|| !VOLUME.test(value.volume) || value.target !== "/workspace" || typeof value.readOnly !== "boolean" || !REVISION.test(value.revision)) failBinding()
|
|
151
|
+
return {volume: value.volume, target: value.target, readOnly: value.readOnly, revision: value.revision}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @param {unknown} value */
|
|
155
|
+
function parseContext(value) {
|
|
156
|
+
if (!record(value) || !exactKeys(value, ["volume", "target", "readOnly", "digests", "imageId"])
|
|
157
|
+
|| !VOLUME.test(value.volume) || value.target !== "/context" || value.readOnly !== true || !DIGEST.test(value.imageId)
|
|
158
|
+
|| !record(value.digests) || !exactKeys(value.digests, ["manifest", "content"])
|
|
159
|
+
|| !DIGEST.test(value.digests.manifest) || !DIGEST.test(value.digests.content)) failBinding()
|
|
160
|
+
return {
|
|
161
|
+
volume: value.volume, target: value.target, readOnly: value.readOnly,
|
|
162
|
+
digests: {manifest: value.digests.manifest, content: value.digests.content}, imageId: value.imageId
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** @param {unknown} value */
|
|
167
|
+
function parseRuntime(value) {
|
|
168
|
+
if (!record(value) || !exactKeys(value, ["uid", "gid", "workdir"])
|
|
169
|
+
|| !safeContainerId(value.uid) || !safeContainerId(value.gid) || typeof value.workdir !== "string"
|
|
170
|
+
|| !isAbsolute(value.workdir) || normalize(value.workdir) !== value.workdir) failBinding()
|
|
171
|
+
return {uid: value.uid, gid: value.gid, workdir: value.workdir}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @param {unknown} value */
|
|
175
|
+
function safeContainerId(value) { return typeof value === "number" && Number.isSafeInteger(value) && POSITIVE_ID.test(String(value)) && value <= MAX_ID }
|
|
176
|
+
/** @param {unknown} value */
|
|
177
|
+
function canonicalUuid(value) { return typeof value === "string" && UUID.test(value) }
|
|
178
|
+
/** @param {string} root @param {string} path */
|
|
179
|
+
function within(root, path) { const path_ = relative(root, path); return path_ === "" || (!path_.startsWith("..") && !isAbsolute(path_)) }
|
|
180
|
+
/** @param {unknown} value */
|
|
181
|
+
function contextPath(value) {
|
|
182
|
+
return typeof value === "string" && value.length > 0 && value.length <= 1024 && !value.startsWith("/")
|
|
183
|
+
&& !value.includes("\\") && value.split("/").every((part) => part !== "" && part !== "." && part !== "..")
|
|
184
|
+
}
|
|
185
|
+
/** @param {unknown} value @param {string[]} keys */
|
|
186
|
+
function exactKeys(value, keys) { return record(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)) }
|
|
187
|
+
/** @param {unknown} value */
|
|
188
|
+
function record(value) { return typeof value === "object" && value !== null && !Array.isArray(value) }
|
|
189
|
+
/** Fail without reflecting untrusted binding input. */
|
|
190
|
+
function failBinding() { throw new Error("Threadwire binding is invalid") }
|
|
191
|
+
/** Fail without reflecting untrusted manifest input. */
|
|
192
|
+
function failManifest() { throw new Error("Threadwire context manifest is invalid") }
|
package/src/workspace-profile.js
DELETED
|
@@ -1,189 +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
|
-
|
|
10
|
-
const execFile = promisify(nodeExecFile)
|
|
11
|
-
const DEFAULT_PROFILES_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "threadwire.workspace-profiles.json")
|
|
12
|
-
const REVISION_PATTERN = /^[0-9a-f]{40}$/u
|
|
13
|
-
const SOURCE_IDENTITY_PATTERN = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/u
|
|
14
|
-
const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* @typedef {{repositoryRoot: string, cwd: string, providers: ("codex" | "claude" | "kimi" | "opencode")[]}} WorkspaceProfile
|
|
18
|
-
* @typedef {{version: 1, defaultProfile: string, profiles: Record<string, WorkspaceProfile>}} WorkspaceProfilesConfig
|
|
19
|
-
* @typedef {{profile: string, repositoryRoot: string, cwd: string, revision: string, sourceIdentity: string}} ResolvedWorkspaceProfile
|
|
20
|
-
* @typedef {{repositoryRoot: string, revision: string, sourceIdentity: string}} GitProvenance
|
|
21
|
-
* @typedef {{
|
|
22
|
-
* readProfiles?: () => Promise<unknown>,
|
|
23
|
-
* directoryExists?: (path: string) => Promise<boolean>,
|
|
24
|
-
* resolveGitProvenance?: (cwd: string) => Promise<GitProvenance>
|
|
25
|
-
* }} WorkspaceProfileOperations
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/** @param {unknown} value @returns {WorkspaceProfilesConfig} */
|
|
29
|
-
export function parseWorkspaceProfiles(value) {
|
|
30
|
-
if (!isRecord(value) || value.version !== 1 || typeof value.defaultProfile !== "string" || !isRecord(value.profiles)) {
|
|
31
|
-
throw new Error("Workspace profile configuration is invalid")
|
|
32
|
-
}
|
|
33
|
-
assertExactKeys(value, ["version", "defaultProfile", "profiles"])
|
|
34
|
-
/** @type {Record<string, WorkspaceProfile>} */
|
|
35
|
-
const profiles = {}
|
|
36
|
-
for (const [name, profile] of Object.entries(value.profiles)) {
|
|
37
|
-
if (!PROFILE_NAME_PATTERN.test(name) || !isRecord(profile)) throw new Error("Workspace profile configuration is invalid")
|
|
38
|
-
assertExactKeys(profile, ["repositoryRoot", "cwd", "providers"])
|
|
39
|
-
const repositoryRoot = absoluteNormalizedPath(profile.repositoryRoot)
|
|
40
|
-
const cwd = absoluteAbsolutePath(profile.cwd)
|
|
41
|
-
if (cwd !== resolve(cwd) || !pathWithin(repositoryRoot, resolve(cwd))) {
|
|
42
|
-
throw new Error(`Workspace profile ${name} cwd must stay within its repository root`)
|
|
43
|
-
}
|
|
44
|
-
const providers = parseProviders(profile.providers)
|
|
45
|
-
profiles[name] = {repositoryRoot, cwd: resolve(cwd), providers}
|
|
46
|
-
}
|
|
47
|
-
if (!Object.hasOwn(profiles, value.defaultProfile)) {
|
|
48
|
-
throw new Error("Workspace profile configuration is invalid")
|
|
49
|
-
}
|
|
50
|
-
return {version: 1, defaultProfile: value.defaultProfile, profiles}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Resolve a reviewed workspace profile to a validated in-container workspace.
|
|
55
|
-
* @param {{provider: "codex" | "claude" | "kimi" | "opencode", profile?: string}} selection
|
|
56
|
-
* @param {WorkspaceProfileOperations} [operations]
|
|
57
|
-
* @returns {Promise<ResolvedWorkspaceProfile>}
|
|
58
|
-
*/
|
|
59
|
-
export async function resolveWorkspaceProfile(selection, operations = {}) {
|
|
60
|
-
const raw = await (operations.readProfiles ?? readProfiles)()
|
|
61
|
-
const config = parseWorkspaceProfiles(raw)
|
|
62
|
-
const profileName = selection.profile ?? config.defaultProfile
|
|
63
|
-
const profile = config.profiles[profileName]
|
|
64
|
-
if (!profile) throw new Error("--workspace-profile must name a configured workspace profile")
|
|
65
|
-
if (!profile.providers.includes(selection.provider)) {
|
|
66
|
-
throw new WorkspaceProviderMismatchError(profileName, selection.provider)
|
|
67
|
-
}
|
|
68
|
-
const directoryExists = operations.directoryExists ?? defaultDirectoryExists
|
|
69
|
-
if (!await directoryExists(profile.repositoryRoot) || !await directoryExists(profile.cwd)) {
|
|
70
|
-
throw new Error(`Workspace profile ${profileName} workspace is unavailable`)
|
|
71
|
-
}
|
|
72
|
-
let provenance
|
|
73
|
-
try {
|
|
74
|
-
provenance = await (operations.resolveGitProvenance ?? defaultResolveGitProvenance)(profile.cwd)
|
|
75
|
-
} catch {
|
|
76
|
-
throw new Error(`Workspace profile ${profileName} workspace is unavailable`)
|
|
77
|
-
}
|
|
78
|
-
if (provenance.repositoryRoot !== profile.repositoryRoot) {
|
|
79
|
-
throw new Error(`Workspace profile ${profileName} workspace is unavailable`)
|
|
80
|
-
}
|
|
81
|
-
return {
|
|
82
|
-
profile: profileName,
|
|
83
|
-
repositoryRoot: profile.repositoryRoot,
|
|
84
|
-
cwd: profile.cwd,
|
|
85
|
-
revision: provenance.revision,
|
|
86
|
-
sourceIdentity: provenance.sourceIdentity
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export class WorkspaceProviderMismatchError extends Error {
|
|
91
|
-
/** @param {string} profileName @param {string} provider */
|
|
92
|
-
constructor(profileName, provider) {
|
|
93
|
-
super(`Workspace profile ${profileName} does not allow provider ${provider}`)
|
|
94
|
-
this.name = "WorkspaceProviderMismatchError"
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** @returns {Promise<unknown>} */
|
|
99
|
-
async function readProfiles() {
|
|
100
|
-
return JSON.parse(await readFile(DEFAULT_PROFILES_PATH, "utf8"))
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** @param {string} path @returns {Promise<boolean>} */
|
|
104
|
-
async function defaultDirectoryExists(path) {
|
|
105
|
-
try {
|
|
106
|
-
const metadata = await import("node:fs/promises").then(({stat}) => stat(path))
|
|
107
|
-
return metadata.isDirectory()
|
|
108
|
-
} catch {
|
|
109
|
-
return false
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** @param {string} cwd @returns {Promise<GitProvenance>} */
|
|
114
|
-
async function defaultResolveGitProvenance(cwd) {
|
|
115
|
-
const repositoryRoot = await gitText(cwd, ["rev-parse", "--show-toplevel"])
|
|
116
|
-
const revision = await gitText(cwd, ["rev-parse", "HEAD"])
|
|
117
|
-
const sourceIdentity = await readSourceIdentity(repositoryRoot, cwd)
|
|
118
|
-
if (!REVISION_PATTERN.test(revision) || !SOURCE_IDENTITY_PATTERN.test(sourceIdentity)) throw new Error("invalid workspace provenance")
|
|
119
|
-
return {repositoryRoot, revision, sourceIdentity}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** @param {string} cwd @param {string[]} arguments_ */
|
|
123
|
-
async function gitText(cwd, arguments_) {
|
|
124
|
-
const {stdout} = await execFile("git", ["-C", cwd, ...arguments_], {encoding: "utf8"})
|
|
125
|
-
return stdout.trim()
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** @param {string} repositoryRoot @param {string} cwd */
|
|
129
|
-
async function readSourceIdentity(repositoryRoot, cwd) {
|
|
130
|
-
try {
|
|
131
|
-
return (await readFile(join(repositoryRoot, ".threadwire-source-identity"), "utf8")).trim()
|
|
132
|
-
} catch {
|
|
133
|
-
return gitText(cwd, ["rev-parse", "HEAD^{tree}"])
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** @param {unknown} value */
|
|
138
|
-
function parseProviders(value) {
|
|
139
|
-
if (!Array.isArray(value) || value.length === 0) throw new Error("Workspace profile configuration is invalid")
|
|
140
|
-
/** @type {("codex" | "claude" | "kimi" | "opencode")[]} */
|
|
141
|
-
const providers = []
|
|
142
|
-
for (const entry of value) {
|
|
143
|
-
if (typeof entry !== "string") {
|
|
144
|
-
throw new Error("Workspace profile configuration is invalid")
|
|
145
|
-
}
|
|
146
|
-
const provider = /** @type {(typeof PROVIDERS)[number]} */ (entry)
|
|
147
|
-
if (!PROVIDERS.includes(provider) || providers.includes(provider)) {
|
|
148
|
-
throw new Error(typeof entry === "string" ? `Workspace profile configuration contains unknown provider ${entry}` : "Workspace profile configuration is invalid")
|
|
149
|
-
}
|
|
150
|
-
providers.push(provider)
|
|
151
|
-
}
|
|
152
|
-
return providers
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** @param {unknown} value */
|
|
156
|
-
function absoluteNormalizedPath(value) {
|
|
157
|
-
if (typeof value !== "string") throw new Error("Workspace profile configuration is invalid")
|
|
158
|
-
const normalized = resolve(value)
|
|
159
|
-
if (!normalized.startsWith(sep) || value !== normalized) {
|
|
160
|
-
throw new Error("Workspace profile configuration is invalid")
|
|
161
|
-
}
|
|
162
|
-
return normalized
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** @param {unknown} value */
|
|
166
|
-
function absoluteAbsolutePath(value) {
|
|
167
|
-
if (typeof value !== "string") throw new Error("Workspace profile configuration is invalid")
|
|
168
|
-
const normalized = resolve(value)
|
|
169
|
-
if (!normalized.startsWith(sep)) throw new Error("Workspace profile configuration is invalid")
|
|
170
|
-
return value
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** @param {string} root @param {string} child */
|
|
174
|
-
function pathWithin(root, child) {
|
|
175
|
-
return child === root || child.startsWith(`${root}${sep}`)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
179
|
-
function isRecord(value) {
|
|
180
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** @param {Record<string, unknown>} value @param {string[]} allowedKeys */
|
|
184
|
-
function assertExactKeys(value, allowedKeys) {
|
|
185
|
-
const allowed = new Set(allowedKeys)
|
|
186
|
-
for (const key of Object.keys(value)) {
|
|
187
|
-
if (!allowed.has(key)) throw new Error("Workspace profile configuration is invalid")
|
|
188
|
-
}
|
|
189
|
-
}
|