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.
- package/README.md +108 -11
- package/bin/martty.js +52 -5
- package/lib/acp-client.js +296 -20
- package/lib/acp-registry.snapshot.json +1450 -0
- package/lib/acp-session-config.js +23 -0
- package/lib/acp-session-plan.js +10 -1
- package/lib/acp-session-stats.js +11 -0
- package/lib/acp-session-status.js +70 -25
- package/lib/agent.js +24 -4
- package/lib/boot.js +29 -5
- package/lib/command-args.js +28 -0
- package/lib/download.js +165 -0
- package/lib/harness-discovery.js +41 -0
- package/lib/harness-package.js +200 -0
- package/lib/harness-registry.js +468 -0
- package/lib/harness-removal.js +88 -0
- package/lib/harness-view.js +639 -46
- package/lib/harnesses.js +1053 -41
- package/lib/index.js +4 -0
- package/lib/mux.js +23 -0
- package/lib/plan-view.js +18 -3
- package/lib/status-view.js +2 -0
- package/lib/tui-commands.js +7 -1
- package/lib/tui-overlay.js +27 -5
- package/package.json +7 -4
- package/vendor/darwin-arm64/martty +0 -0
- package/vendor/darwin-x64/martty +0 -0
- package/vendor/linux-arm64/martty +0 -0
- package/vendor/linux-x64/martty +0 -0
- package/vendor/win32-x64/martty.exe +0 -0
|
@@ -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
|
+
}
|