martty 0.2.34 → 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,200 @@
1
+ import { StringDecoder } from 'node:string_decoder'
2
+ import spawn from 'cross-spawn'
3
+
4
+ const DEFAULT_TIMEOUT_MS = 300_000
5
+ const DETAIL_LIMIT = 240
6
+ const OUTPUT_LIMIT = 8192
7
+
8
+ // Keep parser state across chunks: an OSC clipboard/title payload or split CSI
9
+ // must never become visible text in a progress panel. Partial lines stay bounded.
10
+ function outputLines(onLine) {
11
+ const decoder = new StringDecoder('utf8')
12
+ let state = 'text', line = ''
13
+ const flush = () => {
14
+ const text = line.trim()
15
+ line = ''
16
+ if (text) onLine(text)
17
+ }
18
+ const consume = (text) => {
19
+ for (const char of text) {
20
+ const code = char.codePointAt(0)
21
+ if (state === 'escape') {
22
+ state = char === '[' ? 'csi' : char === ']' ? 'osc'
23
+ : ['P', 'X', '^', '_'].includes(char) ? 'string'
24
+ : code >= 0x20 && code <= 0x2f ? 'intermediate' : 'text'
25
+ } else if (state === 'intermediate') {
26
+ if (code >= 0x30 && code <= 0x7e) state = 'text'
27
+ } else if (state === 'csi') {
28
+ if (code >= 0x40 && code <= 0x7e) state = 'text'
29
+ else if (code === 0x1b) state = 'escape'
30
+ } else if (state === 'osc' || state === 'string') {
31
+ if (code === 0x07 || code === 0x9c) state = 'text'
32
+ else if (code === 0x1b) state = 'stringEscape'
33
+ } else if (state === 'stringEscape') {
34
+ state = char === '\\' ? 'text' : code === 0x1b ? 'stringEscape' : 'string'
35
+ } else if (code === 0x1b) state = 'escape'
36
+ else if (code === 0x9b) state = 'csi'
37
+ else if (code === 0x9d) state = 'osc'
38
+ else if ([0x90, 0x98, 0x9e, 0x9f].includes(code)) state = 'string'
39
+ else if (char === '\n' || char === '\r') flush()
40
+ else if (char === '\t') { if (line.length < OUTPUT_LIMIT) line += ' ' }
41
+ else if (code >= 0x20 && !(code >= 0x7f && code <= 0x9f)
42
+ && !/[\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u.test(char)
43
+ && line.length < OUTPUT_LIMIT) line += char
44
+ }
45
+ }
46
+ return {
47
+ write(chunk) { consume(decoder.write(chunk)) },
48
+ end() { consume(decoder.end()); flush() },
49
+ }
50
+ }
51
+
52
+ function safeText(value, limit = DETAIL_LIMIT) {
53
+ const lines = []
54
+ const output = outputLines((line) => lines.push(line))
55
+ output.write(Buffer.from(String(value ?? '').slice(0, OUTPUT_LIMIT)))
56
+ output.end()
57
+ return lines.join(' ').slice(0, limit)
58
+ }
59
+
60
+ function cancellationError() {
61
+ const error = new Error('Harness package preparation cancelled')
62
+ error.name = 'AbortError'
63
+ return error
64
+ }
65
+
66
+ function signalProcessTree(child, signal) {
67
+ if (!child.pid) return
68
+ try { process.kill(-child.pid, signal) }
69
+ catch (error) {
70
+ if (error.code !== 'ESRCH') {
71
+ try { child.kill(signal) } catch { /* The process may already have exited. */ }
72
+ }
73
+ }
74
+ }
75
+
76
+ async function stopProcessTree(child) {
77
+ if (!child.pid) return
78
+ if (process.platform === 'win32') {
79
+ // Killing cmd.exe alone leaves npm/uv and their children running. taskkill
80
+ // accepts the numeric PID directly; no shell or user-controlled arguments.
81
+ await new Promise((resolve) => {
82
+ let killer
83
+ try {
84
+ killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
85
+ stdio: 'ignore', windowsHide: true,
86
+ })
87
+ } catch { resolve(); return }
88
+ const timer = setTimeout(() => { killer.kill(); resolve() }, 2000)
89
+ const done = () => { clearTimeout(timer); resolve() }
90
+ killer.once('error', done)
91
+ killer.once('close', done)
92
+ })
93
+ try { child.kill() } catch { /* Already reaped by taskkill. */ }
94
+ return
95
+ }
96
+ signalProcessTree(child, 'SIGTERM')
97
+ // Always finish killing the owned group, even when its leader exits first and
98
+ // descendants have closed their pipes. Do not return while installers survive.
99
+ await new Promise((resolve) => setTimeout(resolve, 200))
100
+ signalProcessTree(child, 'SIGKILL')
101
+ }
102
+
103
+ /**
104
+ * Populate the package runner's cache without invoking the ACP entry point.
105
+ * npm 7+ hashes --package specs exactly as normal npx invocations; stdin is not
106
+ * a TTY, so first install needs no --yes flag. uvx --from builds the same package
107
+ * environment before running Python (including package@version / @latest).
108
+ * This never changes the saved recipe, selects a Harness, or inherits the TTY.
109
+ */
110
+ export async function prepareHarnessPackage(entry, options = {}) {
111
+ const distribution = entry?.distribution
112
+ const spec = distribution?.args?.[0]
113
+ if (!['npx', 'uvx'].includes(distribution?.type) || typeof spec !== 'string'
114
+ || !spec.trim() || spec.startsWith('-') || /[\x00-\x1f\x7f]/.test(spec)) {
115
+ throw new Error('Harness package recipe must specify an npx or uvx package')
116
+ }
117
+ if (options.signal?.aborted) throw cancellationError()
118
+ const runner = entry.runner ?? distribution.command ?? distribution.type
119
+ const args = distribution.type === 'npx'
120
+ ? ['--package', spec, '--', process.execPath, '-e', '']
121
+ : ['--from', spec, '--', 'python', '-c', '']
122
+ const progress = (phase, detail) => options.onProgress?.({ phase, detail: safeText(detail) })
123
+ progress('preparing', `Preparing ${entry.label ?? entry.id ?? spec}`)
124
+ if (options.signal?.aborted) throw cancellationError()
125
+
126
+ await new Promise((resolve, reject) => {
127
+ let child, timer, failure, stopping, closed = false, settled = false
128
+ const output = { stdout: '', stderr: '' }
129
+ const cleanup = () => {
130
+ clearTimeout(timer)
131
+ options.signal?.removeEventListener('abort', abort)
132
+ }
133
+ const finish = () => {
134
+ if (!closed || stopping || settled) return
135
+ settled = true
136
+ cleanup()
137
+ if (failure) reject(failure)
138
+ else resolve()
139
+ }
140
+ const stop = (error) => {
141
+ if (settled || failure) return
142
+ failure = error
143
+ clearTimeout(timer)
144
+ stopping = stopProcessTree(child).finally(() => { stopping = undefined; finish() })
145
+ }
146
+ const abort = () => stop(cancellationError())
147
+ const collect = (stream, line) => {
148
+ output[stream] = `${output[stream]}${line}\n`.slice(-OUTPUT_LIMIT / 2)
149
+ if (failure) return
150
+ try { progress('download', line) }
151
+ catch (error) { stop(new Error(safeText(error?.message ?? error, OUTPUT_LIMIT))) }
152
+ }
153
+ const stdout = outputLines((line) => collect('stdout', line))
154
+ const stderr = outputLines((line) => collect('stderr', line))
155
+ try {
156
+ child = (options.spawnImpl ?? spawn)(runner, args, {
157
+ cwd: options.cwd,
158
+ env: { ...process.env, ...distribution.env, ...entry.env, ...options.env },
159
+ stdio: ['ignore', 'pipe', 'pipe'],
160
+ windowsHide: true,
161
+ detached: process.platform !== 'win32',
162
+ })
163
+ } catch (error) {
164
+ reject(new Error(`Could not start package runner: ${safeText(error?.message ?? error)}`))
165
+ return
166
+ }
167
+ child.stdout?.on('data', (chunk) => stdout.write(chunk))
168
+ child.stderr?.on('data', (chunk) => stderr.write(chunk))
169
+ child.stdout?.on('error', (error) => stop(new Error(safeText(error.message))))
170
+ child.stderr?.on('error', (error) => stop(new Error(safeText(error.message))))
171
+ child.once('error', (error) => {
172
+ stop(new Error(`Could not start package runner: ${safeText(error.message)}`))
173
+ })
174
+ child.once('close', (code, signal) => {
175
+ stdout.end(); stderr.end()
176
+ closed = true
177
+ if (!failure && code !== 0) {
178
+ const tail = `${output.stdout}${output.stderr}`.trim()
179
+ stop(new Error(`Harness package preparation ${signal ? `stopped by ${safeText(signal)}` : `exited with code ${code}`}${tail ? `\n${tail}` : ''}`))
180
+ }
181
+ if (!stopping && child.pid && process.platform !== 'win32') {
182
+ // An installer may spawn a worker with ignored stdio then exit first.
183
+ // A closed leader does not imply that its owned process group is empty.
184
+ try {
185
+ process.kill(-child.pid, 0)
186
+ stopping = stopProcessTree(child).finally(() => { stopping = undefined; finish() })
187
+ } catch { /* The owned process group has already exited. */ }
188
+ }
189
+ finish()
190
+ })
191
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
192
+ timer = setTimeout(() => stop(new Error('Harness package preparation timed out')), timeoutMs)
193
+ options.signal?.addEventListener('abort', abort, { once: true })
194
+ // Cover cancellation from a synchronous spawn hook before the listener.
195
+ if (options.signal?.aborted) abort()
196
+ })
197
+ if (options.signal?.aborted) throw cancellationError()
198
+ progress('complete', 'Package ready')
199
+ return entry
200
+ }
@@ -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
+ }