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.
@@ -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
- * workspaceProfileOperations?: import("../workspace-profile.js").WorkspaceProfileOperations,
31
- * activity?: Pick<import("../activity-log.js").ActivityLog, "recordWorkspace" | "recordStarted" | "recordSession" | "close">,
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
- // Explicit only: never fall back to ambient process.env (avoids secret leakage).
115
- const providerEnvironment = buildProviderEnvironment(dependencies.providerEnvironment ?? {})
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
- const earlyKimiPreflight = job.provider === "kimi"
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: workspace.profile,
127
- repositoryRoot: workspace.repositoryRoot,
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: workspace.profile,
167
- repositoryRoot: workspace.repositoryRoot,
168
- cwd: workspace.cwd,
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: workspace.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)
@@ -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, "recordWorkspace" | "recordStarted" | "recordSession" | "close">,
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") }
@@ -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
- }
@@ -1,11 +0,0 @@
1
- {
2
- "version": 1,
3
- "defaultProfile": "container-runtime",
4
- "profiles": {
5
- "container-runtime": {
6
- "repositoryRoot": "/workspace/threadwire",
7
- "cwd": "/workspace/threadwire",
8
- "providers": ["codex", "kimi"]
9
- }
10
- }
11
- }