martty 0.2.33 → 0.2.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,468 @@
1
+ /** ACP Registry loading, normalization, and managed binary installation. */
2
+
3
+ import { createHash, randomUUID } from 'node:crypto'
4
+ import {
5
+ chmodSync,
6
+ createReadStream,
7
+ existsSync,
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ renameSync,
11
+ rmSync,
12
+ statSync,
13
+ readFileSync,
14
+ writeFileSync,
15
+ } from 'node:fs'
16
+ import path from 'node:path'
17
+ import { spawn } from 'node:child_process'
18
+ import { downloadFile } from './download.js'
19
+
20
+ export const ACP_REGISTRY_URL = 'https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json'
21
+
22
+ function registryCachePath(options) {
23
+ return typeof options.settingsPath === 'string'
24
+ ? path.join(path.dirname(options.settingsPath), 'cache', 'acp-registry.json') : undefined
25
+ }
26
+
27
+ /** Last validated catalog, or the official bundled snapshot on first/offline launch. */
28
+ export function readAcpRegistrySnapshot(options = {}) {
29
+ const cachePath = registryCachePath(options)
30
+ if (cachePath !== undefined) {
31
+ try {
32
+ if (statSync(cachePath).size > 16 * 1024 * 1024) throw new Error('oversized cache')
33
+ const cached = JSON.parse(readFileSync(cachePath, 'utf8'))
34
+ if (cached.source === (options.registryUrl ?? ACP_REGISTRY_URL) && Array.isArray(cached.catalog?.agents)) {
35
+ return normalizeAcpRegistry(cached.catalog, options)
36
+ }
37
+ } catch { /* Missing/corrupt cache cannot block the bundled catalog. */ }
38
+ }
39
+ return normalizeAcpRegistry(JSON.parse(readFileSync(new URL('./acp-registry.snapshot.json', import.meta.url), 'utf8')), options)
40
+ }
41
+
42
+ function cacheRegistry(value, options) {
43
+ const cachePath = registryCachePath(options)
44
+ if (cachePath === undefined) return
45
+ const temporary = `${cachePath}.${randomUUID()}.tmp`
46
+ try {
47
+ mkdirSync(path.dirname(cachePath), { recursive: true })
48
+ writeFileSync(temporary, JSON.stringify({ source: options.registryUrl ?? ACP_REGISTRY_URL, catalog: value }))
49
+ renameSync(temporary, cachePath)
50
+ } catch { /* Read-only storage must not discard a successful network result. */ }
51
+ finally { try { rmSync(temporary, { force: true }) } catch { /* best effort */ } }
52
+ }
53
+
54
+ const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024
55
+ const TARGETS = Object.freeze({
56
+ 'darwin-arm64': 'darwin-aarch64',
57
+ 'darwin-x64': 'darwin-x86_64',
58
+ 'linux-arm64': 'linux-aarch64',
59
+ 'linux-x64': 'linux-x86_64',
60
+ 'win32-arm64': 'windows-aarch64',
61
+ 'win32-x64': 'windows-x86_64',
62
+ })
63
+
64
+ function stringArray(value) {
65
+ return Array.isArray(value) && value.every((item) => typeof item === 'string')
66
+ ? [...value]
67
+ : []
68
+ }
69
+
70
+ function stringEnvironment(value) {
71
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
72
+ const entries = Object.entries(value)
73
+ .filter(([key, item]) => key.length > 0 && typeof item === 'string')
74
+ return Object.fromEntries(entries)
75
+ }
76
+
77
+ export function registryPlatformKey(platform = process.platform, arch = process.arch) {
78
+ return TARGETS[`${platform}-${arch}`]
79
+ }
80
+
81
+ /**
82
+ * Convert the public ACP Registry schema into Martty launch distributions.
83
+ * Unsupported platform-specific binaries are omitted; package distributions
84
+ * remain available on every platform.
85
+ */
86
+ export function normalizeAcpRegistry(value, options = {}) {
87
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return []
88
+ if (!Array.isArray(value.agents)) return []
89
+ const target = registryPlatformKey(options.platform, options.arch)
90
+ return value.agents.flatMap((agent) => {
91
+ if (agent === null || typeof agent !== 'object' || Array.isArray(agent)) return []
92
+ if (typeof agent.id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(agent.id)) return []
93
+ if (typeof agent.name !== 'string' || agent.name.trim().length === 0) return []
94
+ if (typeof agent.version !== 'string' || agent.version.trim().length === 0) return []
95
+ const distribution = agent.distribution
96
+ if (distribution === null || typeof distribution !== 'object' || Array.isArray(distribution)) {
97
+ return []
98
+ }
99
+ const distributions = []
100
+ for (const type of ['npx', 'uvx']) {
101
+ const spec = distribution[type]
102
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) continue
103
+ if (typeof spec.package !== 'string' || spec.package.trim().length === 0) continue
104
+ distributions.push({
105
+ type,
106
+ command: type,
107
+ args: [spec.package, ...stringArray(spec.args)],
108
+ env: stringEnvironment(spec.env),
109
+ })
110
+ }
111
+ const binary = target === undefined ? undefined : distribution.binary?.[target]
112
+ if (binary !== null && typeof binary === 'object' && !Array.isArray(binary)
113
+ && typeof binary.archive === 'string' && binary.archive.length > 0
114
+ && typeof binary.cmd === 'string' && binary.cmd.length > 0) {
115
+ distributions.push({
116
+ type: 'binary',
117
+ target,
118
+ command: binary.cmd,
119
+ args: stringArray(binary.args),
120
+ env: stringEnvironment(binary.env),
121
+ archive: binary.archive,
122
+ ...(typeof binary.sha256 === 'string' && binary.sha256.length > 0
123
+ ? { sha256: binary.sha256.toLowerCase() }
124
+ : {}),
125
+ })
126
+ }
127
+ if (distributions.length === 0) return []
128
+ return [{
129
+ id: agent.id,
130
+ label: agent.name,
131
+ version: agent.version,
132
+ description: typeof agent.description === 'string' ? agent.description : '',
133
+ distributions,
134
+ }]
135
+ })
136
+ }
137
+
138
+ function timeoutSignal(timeoutMs, externalSignal, operation) {
139
+ const controller = new AbortController()
140
+ const cancel = () => {
141
+ const error = new Error(`${operation} cancelled`)
142
+ error.name = 'AbortError'
143
+ controller.abort(error)
144
+ }
145
+ if (externalSignal?.aborted) cancel()
146
+ else externalSignal?.addEventListener('abort', cancel, { once: true })
147
+ let timer
148
+ const reset = (duration = timeoutMs) => {
149
+ clearTimeout(timer)
150
+ if (duration !== undefined && !controller.signal.aborted) {
151
+ timer = setTimeout(() => controller.abort(new Error(`${operation} timed out`)), duration)
152
+ }
153
+ }
154
+ reset()
155
+ return {
156
+ signal: controller.signal,
157
+ reset,
158
+ cancel() {
159
+ clearTimeout(timer)
160
+ externalSignal?.removeEventListener('abort', cancel)
161
+ },
162
+ }
163
+ }
164
+
165
+ function abortable(promise, signal) {
166
+ if (signal.aborted) {
167
+ // The operation may synchronously abort its owner and return an already
168
+ // rejected promise. Consume it even though cancellation wins the race.
169
+ Promise.resolve(promise).catch(() => {})
170
+ return Promise.reject(signal.reason)
171
+ }
172
+ return new Promise((resolve, reject) => {
173
+ const onAbort = () => {
174
+ signal.removeEventListener('abort', onAbort)
175
+ reject(signal.reason)
176
+ }
177
+ signal.addEventListener('abort', onAbort, { once: true })
178
+ Promise.resolve(promise).then(resolve, reject).finally(() => {
179
+ signal.removeEventListener('abort', onAbort)
180
+ })
181
+ })
182
+ }
183
+
184
+ async function* responseChunks(response, signal) {
185
+ if (typeof response.body?.getReader !== 'function') {
186
+ signal.throwIfAborted()
187
+ yield Buffer.from(await abortable(response.arrayBuffer(), signal))
188
+ return
189
+ }
190
+ const reader = response.body.getReader()
191
+ try {
192
+ for (;;) {
193
+ signal.throwIfAborted()
194
+ const { done, value } = await abortable(reader.read(), signal)
195
+ if (done) return
196
+ yield Buffer.from(value)
197
+ }
198
+ } finally {
199
+ // Cancel the body as well as the request: injected fetch implementations may
200
+ // return a Response whose stream is not connected to the request signal.
201
+ await reader.cancel().catch(() => {})
202
+ reader.releaseLock()
203
+ }
204
+ }
205
+
206
+ export async function fetchAcpRegistry(options = {}) {
207
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch
208
+ if (typeof fetchImpl !== 'function') throw new Error('ACP Registry requires fetch support')
209
+ const timeout = timeoutSignal(options.timeoutMs ?? 8_000, options.signal, 'ACP Registry request')
210
+ try {
211
+ timeout.signal.throwIfAborted()
212
+ const response = await abortable(fetchImpl(options.registryUrl ?? ACP_REGISTRY_URL, {
213
+ headers: { accept: 'application/json' },
214
+ signal: timeout.signal,
215
+ }), timeout.signal)
216
+ if (response?.ok !== true) {
217
+ throw new Error(`ACP Registry returned HTTP ${response?.status ?? 'error'}`)
218
+ }
219
+ let value
220
+ try {
221
+ if (typeof response.body?.getReader === 'function') {
222
+ const chunks = []
223
+ let size = 0
224
+ for await (const chunk of responseChunks(response, timeout.signal)) {
225
+ size += chunk.length
226
+ if (size > 16 * 1024 * 1024) throw new Error('catalog is larger than 16 MiB')
227
+ chunks.push(chunk)
228
+ }
229
+ value = JSON.parse(Buffer.concat(chunks).toString('utf8'))
230
+ } else {
231
+ value = await abortable(response.json(), timeout.signal)
232
+ }
233
+ } catch (error) {
234
+ throw new Error(`ACP Registry returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`)
235
+ }
236
+ if (!Array.isArray(value?.agents)) throw new Error('ACP Registry returned an invalid catalog')
237
+ const records = normalizeAcpRegistry(value, options)
238
+ if (records.length === 0 && Array.isArray(value?.agents) && value.agents.length > 0) {
239
+ throw new Error('ACP Registry has no distributions for this platform')
240
+ }
241
+ cacheRegistry(value, options)
242
+ return records
243
+ } catch (error) {
244
+ if (timeout.signal.aborted) throw timeout.signal.reason
245
+ if (error instanceof Error && error.message.startsWith('ACP Registry')) throw error
246
+ throw new Error(`could not fetch ACP Registry: ${error instanceof Error ? error.message : String(error)}`)
247
+ } finally {
248
+ timeout.cancel()
249
+ }
250
+ }
251
+
252
+ function safeComponent(value, label) {
253
+ if (typeof value !== 'string' || value.length === 0 || value === '.' || value === '..'
254
+ || value.includes('/') || value.includes('\\')) {
255
+ throw new Error(`invalid ${label} in ACP Registry`)
256
+ }
257
+ return value.replace(/[^a-zA-Z0-9._+-]/g, '-')
258
+ }
259
+
260
+ function relativeCommand(value) {
261
+ if (typeof value !== 'string' || value.length === 0) {
262
+ throw new Error('binary distribution has no command')
263
+ }
264
+ const normalized = value.replaceAll('\\', '/').replace(/^\.\//, '')
265
+ if (path.posix.isAbsolute(normalized)
266
+ || normalized.split('/').some((part) => part === '..' || part.length === 0)) {
267
+ throw new Error('binary command must stay inside the installed archive')
268
+ }
269
+ return normalized.split('/')
270
+ }
271
+
272
+ function containedPath(root, parts) {
273
+ const resolved = path.resolve(root, ...parts)
274
+ const prefix = `${path.resolve(root)}${path.sep}`
275
+ if (!resolved.startsWith(prefix)) throw new Error('binary command escapes the install directory')
276
+ return resolved
277
+ }
278
+
279
+ export function managedBinaryPath(entry, options = {}) {
280
+ const distribution = entry?.distribution
281
+ if (distribution?.type !== 'binary') return undefined
282
+ if (typeof options.settingsPath !== 'string' || options.settingsPath.length === 0) return undefined
283
+ const id = safeComponent(entry.id, 'agent id')
284
+ const version = safeComponent(entry.version, 'agent version')
285
+ const target = safeComponent(distribution.target, 'binary target')
286
+ const commandParts = relativeCommand(distribution.command)
287
+ const installRoot = options.installRoot ?? path.join(path.dirname(options.settingsPath), 'bin')
288
+ return containedPath(path.join(installRoot, id, version, target), commandParts)
289
+ }
290
+
291
+ function archiveSuffix(url) {
292
+ let pathname = ''
293
+ try {
294
+ pathname = new URL(url).pathname.toLowerCase()
295
+ } catch {
296
+ throw new Error('binary distribution has an invalid archive URL')
297
+ }
298
+ for (const suffix of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst', '.tgz', '.zip', '.tar']) {
299
+ if (pathname.endsWith(suffix)) return suffix
300
+ }
301
+ throw new Error('binary distribution uses an unsupported archive format')
302
+ }
303
+
304
+ function runArchiveTool(command, args, action, signal) {
305
+ signal.throwIfAborted()
306
+ return new Promise((resolve, reject) => {
307
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
308
+ let stdout = ''
309
+ let stderr = ''
310
+ let failure
311
+ const stop = () => child.kill('SIGKILL')
312
+ signal.addEventListener('abort', stop, { once: true })
313
+ child.stdout.setEncoding('utf8').on('data', (chunk) => {
314
+ stdout += chunk
315
+ if (stdout.length > 4 * 1024 * 1024) {
316
+ failure = new Error('archive file listing is too large')
317
+ stop()
318
+ }
319
+ })
320
+ child.stderr.setEncoding('utf8').on('data', (chunk) => {
321
+ stderr = (stderr + chunk).slice(-65_536)
322
+ })
323
+ child.on('error', (error) => { failure = error })
324
+ child.on('close', (code) => {
325
+ signal.removeEventListener('abort', stop)
326
+ if (signal.aborted) reject(signal.reason)
327
+ else if (failure !== undefined || code !== 0) {
328
+ reject(new Error(`could not ${action} binary archive: ${failure?.message ?? (stderr.trim() || `exit ${code}`)}`))
329
+ } else resolve(stdout)
330
+ })
331
+ })
332
+ }
333
+
334
+ function validateArchiveEntries(entries) {
335
+ for (const raw of entries.split(/\r?\n/).filter(Boolean)) {
336
+ const entry = raw.replaceAll('\\', '/')
337
+ if (entry.startsWith('/') || /^[a-zA-Z]:\//.test(entry)
338
+ || entry.split('/').some((part) => part === '..')) {
339
+ throw new Error('binary archive contains a path outside its install directory')
340
+ }
341
+ }
342
+ }
343
+
344
+ async function defaultExtractArchive(archivePath, destination, archiveUrl, { signal }) {
345
+ const suffix = archiveSuffix(archiveUrl)
346
+ if (suffix === '.zip' && process.platform !== 'win32') {
347
+ validateArchiveEntries(await runArchiveTool('unzip', ['-Z1', archivePath], 'inspect', signal))
348
+ await runArchiveTool('unzip', ['-q', archivePath, '-d', destination], 'extract', signal)
349
+ return
350
+ }
351
+ const tar = process.platform === 'win32' ? 'tar.exe' : 'tar'
352
+ validateArchiveEntries(await runArchiveTool(tar, ['-tf', archivePath], 'inspect', signal))
353
+ await runArchiveTool(tar, ['-xf', archivePath, '-C', destination], 'extract', signal)
354
+ }
355
+
356
+ function executable(command) {
357
+ try {
358
+ return statSync(command).isFile()
359
+ } catch {
360
+ return false
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Download an official binary distribution into Martty's own data directory.
366
+ * The settings path anchors the default at $MARTTY_HOME/bin.
367
+ */
368
+ export async function installRegistryBinary(entry, options = {}) {
369
+ if (options.signal?.aborted) {
370
+ const error = new Error('binary installation cancelled')
371
+ error.name = 'AbortError'
372
+ throw error
373
+ }
374
+ const distribution = entry?.distribution
375
+ if (distribution?.type !== 'binary') throw new Error('registry entry is not a binary distribution')
376
+ if (typeof options.settingsPath !== 'string' || options.settingsPath.length === 0) {
377
+ throw new Error('binary installation needs a Martty settings path')
378
+ }
379
+ const id = safeComponent(entry.id, 'agent id')
380
+ const version = safeComponent(entry.version, 'agent version')
381
+ const target = safeComponent(distribution.target, 'binary target')
382
+ const commandParts = relativeCommand(distribution.command)
383
+ const installRoot = options.installRoot ?? path.join(path.dirname(options.settingsPath), 'bin')
384
+ const installDir = path.join(installRoot, id, version, target)
385
+ const installedCommand = containedPath(installDir, commandParts)
386
+ if (executable(installedCommand)) {
387
+ options.onProgress?.({ phase: 'complete' })
388
+ return {
389
+ id: entry.id,
390
+ label: entry.label,
391
+ command: installedCommand,
392
+ args: [...distribution.args],
393
+ env: { ...distribution.env },
394
+ }
395
+ }
396
+ if (existsSync(installDir)) {
397
+ throw new Error(`incomplete binary installation already exists at ${installDir}`)
398
+ }
399
+
400
+ const suffix = archiveSuffix(distribution.archive)
401
+ const parent = path.dirname(installDir)
402
+ mkdirSync(parent, { recursive: true })
403
+ const temporary = mkdtempSync(path.join(parent, '.install-'))
404
+ try {
405
+ const archivePath = path.join(temporary, `download${suffix}`)
406
+ await (options.downloadFile ?? downloadFile)(distribution.archive, archivePath, {
407
+ signal: options.signal,
408
+ onProgress: options.onProgress,
409
+ connectTimeoutMs: options.connectTimeoutMs ?? options.timeoutMs ?? 30_000,
410
+ idleTimeoutMs: options.idleTimeoutMs ?? options.timeoutMs ?? 60_000,
411
+ maxBytes: MAX_ARCHIVE_BYTES,
412
+ })
413
+ options.signal?.throwIfAborted()
414
+ // Validate independently of the transport before trusting an archive.
415
+ if (statSync(archivePath).size > MAX_ARCHIVE_BYTES) throw new Error('binary archive is larger than 512 MiB')
416
+ if (typeof distribution.sha256 === 'string') {
417
+ const hash = createHash('sha256')
418
+ for await (const chunk of createReadStream(archivePath, { signal: options.signal })) hash.update(chunk)
419
+ const actual = hash.digest('hex')
420
+ if (actual !== distribution.sha256.toLowerCase()) {
421
+ throw new Error(`binary checksum mismatch: expected ${distribution.sha256}, received ${actual}`)
422
+ }
423
+ }
424
+
425
+ const extracted = path.join(temporary, 'payload')
426
+ mkdirSync(extracted)
427
+ // Archive inspection and extraction share a ten-minute default deadline;
428
+ // callers can override it, and cancellation still interrupts the extractor.
429
+ const extraction = timeoutSignal(options.extractTimeoutMs ?? 10 * 60_000, options.signal, 'binary extraction')
430
+ try {
431
+ options.onProgress?.({ phase: 'extract' })
432
+ extraction.signal.throwIfAborted()
433
+ if (options.extractArchive !== undefined) {
434
+ await abortable(options.extractArchive(archivePath, extracted, distribution.archive, {
435
+ signal: extraction.signal,
436
+ }), extraction.signal)
437
+ } else {
438
+ // The native extractor waits for the terminated child to close before
439
+ // cleanup, including on Windows where open files cannot be removed.
440
+ await defaultExtractArchive(archivePath, extracted, distribution.archive, { signal: extraction.signal })
441
+ }
442
+ options.onProgress?.({ phase: 'verify' })
443
+ extraction.signal.throwIfAborted()
444
+ const extractedCommand = containedPath(extracted, commandParts)
445
+ if (!existsSync(extractedCommand)) {
446
+ throw new Error(`binary archive does not contain ${distribution.command}`)
447
+ }
448
+ if (process.platform !== 'win32') chmodSync(extractedCommand, 0o755)
449
+ if (!executable(extractedCommand)) throw new Error('binary command is not an executable file')
450
+ renameSync(extracted, installDir)
451
+ } catch (error) {
452
+ if (extraction.signal.aborted) throw extraction.signal.reason
453
+ throw error
454
+ } finally {
455
+ extraction.cancel()
456
+ }
457
+ } finally {
458
+ rmSync(temporary, { recursive: true, force: true })
459
+ }
460
+ options.onProgress?.({ phase: 'complete' })
461
+ return {
462
+ id: entry.id,
463
+ label: entry.label,
464
+ command: installedCommand,
465
+ args: [...distribution.args],
466
+ env: { ...distribution.env },
467
+ }
468
+ }
@@ -0,0 +1,88 @@
1
+ /** Removal is intentionally limited to saved recipes and exact private binary installations. */
2
+ import { existsSync, lstatSync, readdirSync, realpathSync, renameSync, rmSync } from 'node:fs'
3
+ import { randomUUID } from 'node:crypto'
4
+ import path from 'node:path'
5
+ import { savedHarnesses, removeHarnessConfiguration } from './harnesses.js'
6
+
7
+ const inside = (root, value) => value === root || value.startsWith(root + path.sep)
8
+ const references = entry => [entry.command, ...(entry.args ?? []), ...Object.entries(entry.env ?? {})
9
+ .flatMap(([key, value]) => key.toUpperCase() === 'PATH' ? value.split(path.delimiter) : [value])]
10
+ .filter(value => typeof value === 'string')
11
+ .map(value => !path.isAbsolute(value) && value.startsWith('--') && value.includes('=') ? value.slice(value.indexOf('=') + 1) : value)
12
+ .filter(value => path.isAbsolute(value))
13
+
14
+ function hasLink(directory) {
15
+ if (lstatSync(directory).isSymbolicLink()) return true
16
+ if (!lstatSync(directory).isDirectory()) return false
17
+ return readdirSync(directory).some(name => hasLink(path.join(directory, name)))
18
+ }
19
+
20
+ function privateInstallation(settingsPath, entry) {
21
+ const root = path.resolve(path.dirname(settingsPath), 'bin')
22
+ const canonicalRoot = existsSync(root) ? realpathSync(root) : root
23
+ const candidate = references(entry).find(value => inside(root, path.resolve(value)) || inside(canonicalRoot, path.resolve(value)))
24
+ if (!candidate) return { resources: [], cleanupReason: 'No private binary installation. External programs and shared npx/uvx caches are kept.' }
25
+ const parts = path.relative(inside(root, path.resolve(candidate)) ? root : canonicalRoot, path.resolve(candidate)).split(path.sep)
26
+ // The installer owns bin/<registry-id>/<version>/<platform>, never the whole bin or id directory.
27
+ if (parts.length < 4 || parts.slice(0, 3).some(part => !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(part))) {
28
+ return { resources: [], cleanupReason: 'The path is not a recognized private binary installation.' }
29
+ }
30
+ const directory = path.join(root, ...parts.slice(0, 3))
31
+ let cursor = directory
32
+ while (true) {
33
+ try {
34
+ if (lstatSync(cursor).isSymbolicLink()) return { resources: [], cleanupReason: 'A symbolic link is present; resource cleanup is disabled.' }
35
+ } catch (error) { if (error.code !== 'ENOENT') throw error }
36
+ const parent = path.dirname(cursor)
37
+ if (cursor === path.dirname(root)) break
38
+ cursor = parent
39
+ }
40
+ if (!existsSync(directory)) return { resources: [], cleanupReason: 'Private installation is already absent.' }
41
+ if (hasLink(directory)) return { resources: [], cleanupReason: 'The installation contains a symbolic link; resource cleanup is disabled.' }
42
+ return { resources: [directory] }
43
+ }
44
+
45
+ export function planHarnessRemoval(settingsPath, id, options = {}) {
46
+ const entries = savedHarnesses(settingsPath)
47
+ const entry = entries.find(entry => entry.id === id)
48
+ if (!entry) throw new Error('Only a saved Harness configuration can be removed')
49
+ if (options.isCurrent?.(entry)) throw new Error('Switch to another Harness before removing the current Harness')
50
+ const forced = [options.forcedHarness, ...(options.defaults ?? []).filter(entry => entry.source === 'forced')].filter(Boolean)
51
+ if (forced.some(value => value.id === id || value.command === entry.command)) throw new Error('This Harness is product-forced and cannot be removed here')
52
+ const installation = privateInstallation(settingsPath, entry)
53
+ if (installation.resources.length) {
54
+ const directory = installation.resources[0]
55
+ const shared = [...entries.filter(value => value.id !== id), ...(options.defaults ?? []), ...forced]
56
+ .some(value => references(value).some(ref => {
57
+ const resolved = existsSync(ref) ? realpathSync(ref) : path.resolve(ref)
58
+ return inside(directory, path.resolve(ref)) || inside(realpathSync(directory), resolved)
59
+ }))
60
+ if (shared) return { entry, resources: [], cleanupReason: 'The installation is shared by another Harness configuration; its resources must be kept.' }
61
+ }
62
+ return { entry, ...installation }
63
+ }
64
+
65
+ export function removeHarness(settingsPath, expected, options = {}) {
66
+ const plan = planHarnessRemoval(settingsPath, expected.entry.id, options)
67
+ if (JSON.stringify(plan.entry) !== JSON.stringify(expected.entry)) throw new Error('Harness configuration changed; review removal again')
68
+ if (options.cleanup && plan.cleanupReason) throw new Error(plan.cleanupReason)
69
+ if (options.cleanup && JSON.stringify(plan.resources) !== JSON.stringify(expected.resources)) throw new Error('Installation paths changed; review removal again')
70
+ // Move the exact validated installation aside before changing settings. No external cache or ancestor is deleted.
71
+ const moved = []
72
+ try {
73
+ if (options.cleanup) for (const resource of plan.resources) {
74
+ const quarantine = path.join(path.dirname(resource), `.removed-${randomUUID()}`)
75
+ renameSync(resource, quarantine)
76
+ moved.push({ resource, quarantine })
77
+ }
78
+ removeHarnessConfiguration(settingsPath, plan.entry)
79
+ } catch (error) {
80
+ for (const { resource, quarantine } of moved.reverse()) renameSync(quarantine, resource)
81
+ throw error
82
+ }
83
+ for (const { quarantine } of moved) {
84
+ try { rmSync(quarantine, { recursive: true }) }
85
+ catch (error) { throw new Error(`Configuration removed, but cleanup failed at ${quarantine}: ${error.message}`) }
86
+ }
87
+ return { entry: plan.entry, removed: moved.map(({ resource }) => resource) }
88
+ }