@parcha/agentrun-cli 0.0.0-stage → 0.1.1

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 CHANGED
@@ -1,3 +1,43 @@
1
- # Temporary Holding Version
1
+ # AgentRun npm launcher
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@parcha/agentrun-cli` is a dependency-free Node.js launcher for the AgentRun Python CLI. It requires Node.js 20 or newer. The release manifest must explicitly verify the current Linux architecture; releases marked `libc: "glibc"` require glibc. macOS and Windows are not supported by this launcher release.
4
+
5
+ ```bash
6
+ npx --yes --package https://github.com/Parcha-ai/agentrun/releases/download/cli-v0.1.10/parcha-agentrun-cli-0.1.1.tgz agentrun connect
7
+ npx --yes --package https://github.com/Parcha-ai/agentrun/releases/download/cli-v0.1.10/parcha-agentrun-cli-0.1.1.tgz agentrun status
8
+ ```
9
+
10
+ Launcher 0.1.1 defaults to CLI 0.1.10 from the public `Parcha-ai/agentrun` GitHub release tagged `cli-v0.1.10`. This default is pinned: it does not follow GitHub's latest release. `--cli-version <version>` selects the matching `cli-v<version>` tag. A completed default or explicitly pinned installation works offline without contacting the release server.
11
+
12
+ Use `--source https://YOUR-CONSOLE/agentrun-cli` or `AGENTRUN_CLI_SOURCE` to override GitHub with an explicit release directory. Put launcher options before the CLI command; all arguments from the first CLI argument onward are forwarded unchanged. A leading `--` ends launcher option parsing. The Python CLI inherits stdin, stdout, stderr, and the current working directory, including terminal interaction.
13
+
14
+ ## Installation and reuse
15
+
16
+ In default GitHub mode, the launcher downloads `release.json` and its named artifacts from `https://github.com/Parcha-ai/agentrun/releases/download/cli-v<version>/`. Each direct release-asset request may follow one redirect to exactly `https://release-assets.githubusercontent.com/`, with no credentials or explicit port. Other destinations and further redirects are refused.
17
+
18
+ Explicit sources retain their own directory layout: `release.json` without a CLI version, or `releases/<version>.json` with `--cli-version`. All artifact filenames resolve in the selected source directory. Explicit sources refuse every redirect, including when the supplied source happens to be on GitHub.
19
+
20
+ The launcher verifies the SHA-256 of the named CLI wheel, requirements file, and compressed `uv` bootstrap before executing the bootstrap. It also verifies the expanded bootstrap hash. Release artifacts must be plain filenames. HTTPS is required, and downloads have size and time limits. HTTP is accepted only for loopback addresses used in local verification.
21
+
22
+ The verified `uv` executable provisions Python 3.12, synchronizes dependencies with `--require-hashes --only-binary :all:`, and installs the verified local CLI wheel without resolving additional dependencies. Initial installation therefore also requires access to the managed Python distribution and dependency index. Dependency consistency, CLI module import, and installed-version checks must pass before the installation is marked ready.
23
+
24
+ Runtime files live under `~/.local/share/agentrun`, or the absolute directory named by `AGENTRUN_INSTALL_DIR`. This root must belong to the current user, have no group or other permissions, and must not be a symlink. Python distributions, dependency cache, and complete CLI environments remain outside the npm cache. Removing the npm cache does not remove the runtime used by the CLI or its background processes.
25
+
26
+ Each source, platform, architecture, and manifest has a separate stable environment. The environment stays at its final path throughout installation. Concurrent installs of the same release are serialized. Completed installations are reused; old installations and their background processes are retained. The launcher runs Python with `-I` and clears Python path overrides and inherited installer configuration.
27
+
28
+ Explicit-source invocations without `--cli-version` fetch the current manifest each time. Default GitHub invocations and explicitly pinned versions reuse their completed cached installation without a release-server connection. When using `npx`, npm may separately need network access to obtain the launcher package itself.
29
+
30
+ ## Recovery
31
+
32
+ A failed installation removes only the directory created by that attempt, so the next invocation can retry. Hard termination can leave an incomplete environment or installation lock. The launcher reports its path and leaves it untouched. After confirming that no installation uses it, inspect and move the incomplete directory aside or remove the abandoned lock, then retry. The launcher does not kill other processes or automatically remove old environments.
33
+
34
+ The launcher does not enable adapters, startup behavior, or other CLI consent choices. Existing CLI commands own those actions.
35
+
36
+ ## Local verification
37
+
38
+ ```bash
39
+ npm test
40
+ npm pack --dry-run
41
+ ```
42
+
43
+ The subprocess tests serve release fixtures over local HTTP and HTTPS servers and use a fixture executable at the external `uv` boundary. The HTTPS cases route sockets to loopback through a test-only Node agent preload while preserving the requested URLs, hostnames, SNI, and certificate verification. The test certificate and key are public fixture material, trusted only by those child processes; fixtures are excluded from the npm package. Tests cover GitHub pinning and redirect boundaries, checksums, source overrides, path validation, private roots, concurrent installation, failed-install retry, persistent and offline reuse, argument and stream forwarding, signals, and readiness checks. A real bootstrap/Python installation is a separate release acceptance check.
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { constants } from 'node:fs';
5
+ import { access, chmod, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
6
+ import http from 'node:http';
7
+ import https from 'node:https';
8
+ import { homedir } from 'node:os';
9
+ import { isAbsolute, join, resolve } from 'node:path';
10
+ import { setTimeout as delay } from 'node:timers/promises';
11
+ import { gunzipSync } from 'node:zlib';
12
+
13
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
14
+ const versionPattern = /^\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?(?:\.post\d+)?$/;
15
+ const hashPattern = /^[a-f0-9]{64}$/;
16
+ const defaultCliVersion = '0.1.10';
17
+ const limits = { manifest: 512 * 1024, requirements: 8 * 1024 * 1024, artifact: 64 * 1024 * 1024, executable: 128 * 1024 * 1024 };
18
+ let activeChild;
19
+ let activeRequest;
20
+ let receivedSignal;
21
+
22
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
23
+ process.on(signal, () => {
24
+ receivedSignal ??= signal;
25
+ if (activeChild) activeChild.kill(signal);
26
+ if (activeRequest) activeRequest.destroy(new Error(`Interrupted by ${signal}`));
27
+ });
28
+ }
29
+
30
+ function interrupted() {
31
+ if (receivedSignal) throw new Error(`Interrupted by ${receivedSignal}`);
32
+ }
33
+
34
+ function options(args) {
35
+ let source = process.env.AGENTRUN_CLI_SOURCE;
36
+ let version;
37
+ let index = 0;
38
+ for (; index < args.length; index++) {
39
+ const arg = args[index];
40
+ if (arg === '--') { index++; break; }
41
+ if (!/^--(?:source|cli-version)(?:=|$)/.test(arg)) break;
42
+ const equal = arg.indexOf('=');
43
+ const name = equal < 0 ? arg : arg.slice(0, equal);
44
+ const value = equal < 0 ? args[++index] : arg.slice(equal + 1);
45
+ if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`);
46
+ if (name === '--source') source = value;
47
+ else version = value;
48
+ }
49
+ if (version && !versionPattern.test(version)) throw new Error('Invalid --cli-version');
50
+ const github = !source;
51
+ if (github) {
52
+ version ??= defaultCliVersion;
53
+ source = `https://github.com/Parcha-ai/agentrun/releases/download/cli-v${version}/`;
54
+ }
55
+ return { source: releaseSource(source), version, github, args: args.slice(index) };
56
+ }
57
+
58
+ function releaseSource(value) {
59
+ if (/[\\%?#\s]/.test(value) || /(?:^|\/)\.{1,2}(?:\/|$)/.test(value)) throw new Error('Invalid release source URL');
60
+ const url = new URL(value);
61
+ const loopback = url.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(url.hostname);
62
+ if ((url.protocol !== 'https:' && !loopback) || url.username || url.password) throw new Error('Release source must use HTTPS (HTTP is allowed only on loopback) without credentials');
63
+ if (!/^\/[A-Za-z0-9_./~-]*$/.test(url.pathname)) throw new Error('Invalid release source path');
64
+ url.pathname = url.pathname.replace(/\/+$/, '') + '/';
65
+ return url.href;
66
+ }
67
+
68
+ function artifact(value, label) {
69
+ if (!value || typeof value !== 'object' || typeof value.filename !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(value.filename) || typeof value.sha256 !== 'string' || !hashPattern.test(value.sha256)) {
70
+ throw new Error(`Invalid ${label} filename or SHA-256`);
71
+ }
72
+ return value;
73
+ }
74
+
75
+ function manifestFrom(bytes, expectedVersion) {
76
+ const m = JSON.parse(bytes.toString('utf8'));
77
+ if (m.launcher_schema !== 1 || !versionPattern.test(m.version) || (expectedVersion && m.version !== expectedVersion)) throw new Error('Unsupported or mismatched release manifest version');
78
+ if (m.python_version !== '3.12' || typeof m.requires_python !== 'string') throw new Error('Unsupported Python release requirement');
79
+ if (process.platform !== 'linux' || !Array.isArray(m.verified_os) || !m.verified_os.includes(process.platform)) throw new Error('This release is not verified for this operating system');
80
+ artifact(m, 'wheel');
81
+ if (m.filename !== `agentrun_cli-${m.version}-py3-none-any.whl`) throw new Error('Invalid CLI wheel filename');
82
+ artifact(m.requirements, 'requirements');
83
+ if (m.requirements.filename !== `requirements-${m.requirements.sha256}.txt`) throw new Error('Invalid requirements filename');
84
+ const bootstrap = artifact(m.bootstrap?.[`${process.platform}-${process.arch}`], 'platform bootstrap');
85
+ if (!/^uv-\d+\.\d+\.\d+-linux-(?:x64|arm64)\.gz$/.test(bootstrap.filename) || !hashPattern.test(bootstrap.uncompressed_sha256)) throw new Error('Invalid bootstrap archive');
86
+ if (bootstrap.libc === 'glibc' && !process.report.getReport().header.glibcVersionRuntime) throw new Error('This release requires glibc; musl/Alpine is not supported');
87
+ if (bootstrap.libc !== undefined && bootstrap.libc !== 'glibc') throw new Error('Unsupported bootstrap libc');
88
+ return m;
89
+ }
90
+
91
+ function githubRedirect(url, location) {
92
+ const asset = /^https:\/\/github\.com\/Parcha-ai\/agentrun\/releases\/download\/cli-v([^/]+)\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.exec(url);
93
+ if (!asset || !versionPattern.test(asset[1]) || typeof location !== 'string' || !location.startsWith('https://release-assets.githubusercontent.com/') || /[\\\s#]/.test(location)) {
94
+ throw new Error('Release redirect rejected: only the pinned GitHub release asset CDN is allowed');
95
+ }
96
+ const target = new URL(location);
97
+ if (target.protocol !== 'https:' || target.hostname !== 'release-assets.githubusercontent.com' || target.port || target.username || target.password || target.hash) {
98
+ throw new Error('Release redirect rejected: invalid GitHub asset CDN URL');
99
+ }
100
+ return target.href;
101
+ }
102
+
103
+ function download(url, maxBytes, allowGithubRedirect = false) {
104
+ interrupted();
105
+ return new Promise((resolveDownload, reject) => {
106
+ const client = url.startsWith('https:') ? https : http;
107
+ const request = client.get(url, { headers: { 'Accept-Encoding': 'identity' } }, (response) => {
108
+ response.on('error', reject);
109
+ if (allowGithubRedirect && [301, 302, 303, 307, 308].includes(response.statusCode)) {
110
+ try {
111
+ const destination = githubRedirect(url, response.headers.location);
112
+ response.resume();
113
+ request.destroy();
114
+ resolveDownload(download(destination, maxBytes));
115
+ } catch (error) { request.destroy(error); }
116
+ return;
117
+ }
118
+ if (response.statusCode !== 200) {
119
+ response.resume();
120
+ request.destroy(new Error(`Release download rejected HTTP ${response.statusCode}; redirects are not allowed`));
121
+ return;
122
+ }
123
+ if (Number(response.headers['content-length']) > maxBytes) {
124
+ request.destroy(new Error('Release download exceeds size limit'));
125
+ return;
126
+ }
127
+ let size = 0;
128
+ const chunks = [];
129
+ response.on('data', (chunk) => {
130
+ size += chunk.length;
131
+ if (size > maxBytes) request.destroy(new Error('Release download exceeds size limit'));
132
+ else chunks.push(chunk);
133
+ });
134
+ response.on('end', () => resolveDownload(Buffer.concat(chunks)));
135
+ });
136
+ activeRequest = request;
137
+ const timer = setTimeout(() => request.destroy(new Error('Release download timed out')), 60_000);
138
+ request.on('error', reject);
139
+ request.on('close', () => { clearTimeout(timer); if (activeRequest === request) activeRequest = undefined; });
140
+ });
141
+ }
142
+
143
+ async function verifiedDownload(source, spec, maxBytes, allowGithubRedirect) {
144
+ const bytes = await download(new URL(spec.filename, source).href, maxBytes, allowGithubRedirect);
145
+ if (sha256(bytes) !== spec.sha256) throw new Error(`SHA-256 verification failed for ${spec.filename}`);
146
+ return bytes;
147
+ }
148
+
149
+ async function privateDirectory(path) {
150
+ await mkdir(path, { recursive: true, mode: 0o700 });
151
+ const stat = await lstat(path);
152
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) || (process.getuid && stat.uid !== process.getuid())) {
153
+ throw new Error(`Installation directory must be private, owned by you, and not a symlink: ${path}`);
154
+ }
155
+ }
156
+
157
+ async function atomicWrite(path, bytes) {
158
+ const temp = `${path}.${randomUUID()}.tmp`;
159
+ try {
160
+ await writeFile(temp, bytes, { mode: 0o600, flag: 'wx' });
161
+ await rename(temp, path);
162
+ } finally {
163
+ await rm(temp, { force: true });
164
+ }
165
+ }
166
+
167
+ function childEnvironment(root) {
168
+ const env = { ...process.env };
169
+ for (const key of Object.keys(env)) {
170
+ if (key.startsWith('UV_') || key.startsWith('PIP_') || ['PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV', 'CONDA_PREFIX'].includes(key)) delete env[key];
171
+ }
172
+ return { ...env, UV_PYTHON_INSTALL_DIR: join(root, 'python'), UV_CACHE_DIR: join(root, 'cache') };
173
+ }
174
+
175
+ function runChild(command, args, env, cwd, stdio = 'inherit') {
176
+ interrupted();
177
+ return new Promise((resolveChild, reject) => {
178
+ activeChild = spawn(command, args, { env, cwd, stdio, shell: false });
179
+ activeChild.on('error', (error) => { activeChild = undefined; reject(error); });
180
+ activeChild.on('exit', (code, signal) => { activeChild = undefined; resolveChild({ code, signal }); });
181
+ });
182
+ }
183
+
184
+ async function withLock(lock, action) {
185
+ const deadline = Date.now() + 120_000;
186
+ for (;;) {
187
+ interrupted();
188
+ try {
189
+ await mkdir(lock, { mode: 0o700 });
190
+ break;
191
+ } catch (error) {
192
+ if (error.code !== 'EEXIST') throw error;
193
+ await privateDirectory(lock);
194
+ if (Date.now() >= deadline) throw new Error(`Another installation holds ${lock}. If its process has stopped, remove this lock directory and retry.`);
195
+ await delay(150);
196
+ }
197
+ }
198
+ try { return await action(); }
199
+ finally { await rm(lock, { recursive: true, force: true }); }
200
+ }
201
+
202
+ async function ready(install, identity) {
203
+ try {
204
+ const receipt = JSON.parse(await readFile(join(install, 'ready.json'), 'utf8'));
205
+ if (receipt.identity !== identity) return false;
206
+ await access(join(install, 'venv', 'bin', 'python'), constants.X_OK);
207
+ return true;
208
+ } catch (error) {
209
+ if (['ENOENT', 'EACCES'].includes(error.code) || error instanceof SyntaxError) return false;
210
+ throw error;
211
+ }
212
+ }
213
+
214
+ async function installRelease(root, source, sourceRoot, manifestBytes, manifest, allowGithubRedirect) {
215
+ const identity = sha256(Buffer.concat([Buffer.from(`${process.platform}-${process.arch}\n${source}\n`), manifestBytes]));
216
+ const installs = join(sourceRoot, 'installs');
217
+ await privateDirectory(installs);
218
+ const install = join(installs, `${manifest.version}-${identity}`);
219
+ const python = join(install, 'venv', 'bin', 'python');
220
+ await withLock(`${install}.lock`, async () => {
221
+ let created = false;
222
+ try {
223
+ try { await mkdir(install, { mode: 0o700 }); created = true; }
224
+ catch (error) {
225
+ if (error.code !== 'EEXIST') throw error;
226
+ await privateDirectory(install);
227
+ if (await ready(install, identity)) return;
228
+ throw new Error(`An incomplete installation already exists at ${install}. Inspect it and move it aside before retrying; existing runtimes are not removed automatically.`);
229
+ }
230
+ const spec = manifest.bootstrap[`${process.platform}-${process.arch}`];
231
+ const compressed = await verifiedDownload(source, spec, limits.artifact, allowGithubRedirect);
232
+ const uv = gunzipSync(compressed, { maxOutputLength: limits.executable });
233
+ if (sha256(uv) !== spec.uncompressed_sha256) throw new Error('SHA-256 verification failed for uncompressed bootstrap');
234
+ const uvPath = join(install, 'uv');
235
+ await writeFile(uvPath, uv, { mode: 0o700, flag: 'wx' });
236
+ await chmod(uvPath, 0o700);
237
+ const requirements = await verifiedDownload(source, manifest.requirements, limits.requirements, allowGithubRedirect);
238
+ const requirementsPath = join(install, manifest.requirements.filename);
239
+ await writeFile(requirementsPath, requirements, { mode: 0o600, flag: 'wx' });
240
+ const wheel = await verifiedDownload(source, manifest, limits.artifact, allowGithubRedirect);
241
+ const wheelPath = join(install, manifest.filename);
242
+ await writeFile(wheelPath, wheel, { mode: 0o600, flag: 'wx' });
243
+ const env = childEnvironment(root);
244
+ for (const args of [
245
+ ['venv', '--no-project', '--managed-python', '--python', manifest.python_version, join(install, 'venv')],
246
+ ['pip', 'sync', '--require-hashes', '--only-binary', ':all:', '--python', python, requirementsPath],
247
+ ['pip', 'install', '--no-deps', '--no-index', '--python', python, wheelPath],
248
+ ['pip', 'check', '--python', python],
249
+ ]) {
250
+ const result = await runChild(uvPath, ['--no-config', ...args], env, root);
251
+ if (result.code !== 0) throw new Error(`Runtime installation failed (${result.signal ?? result.code})`);
252
+ }
253
+ await access(python, constants.X_OK);
254
+ const smoke = await runChild(python, ['-I', '-c', `import agentrun_cli.cli; from importlib.metadata import version; assert version("agentrun-cli") == ${JSON.stringify(manifest.version)}`], env, root, ['ignore', 'ignore', 'inherit']);
255
+ if (smoke.code !== 0) throw new Error('Installed CLI did not pass the version and import check');
256
+ interrupted();
257
+ await atomicWrite(join(install, 'ready.json'), JSON.stringify({ identity, source, version: manifest.version }));
258
+ } catch (error) {
259
+ if (created) await rm(install, { recursive: true, force: true });
260
+ throw error;
261
+ }
262
+ });
263
+ return python;
264
+ }
265
+
266
+ async function main() {
267
+ const opts = options(process.argv.slice(2));
268
+ if (process.platform !== 'linux') throw new Error('The launcher currently supports verified Linux releases only');
269
+ const configuredRoot = process.env.AGENTRUN_INSTALL_DIR ?? join(homedir(), '.local', 'share', 'agentrun');
270
+ if (!isAbsolute(configuredRoot)) throw new Error('AGENTRUN_INSTALL_DIR must be an absolute path');
271
+ const root = resolve(configuredRoot);
272
+ await privateDirectory(root);
273
+ for (const folder of ['python', 'cache', 'sources']) await privateDirectory(join(root, folder));
274
+ const sourceRoot = join(root, 'sources', sha256(opts.source));
275
+ await privateDirectory(sourceRoot);
276
+ const manifests = join(sourceRoot, 'manifests');
277
+ await privateDirectory(manifests);
278
+ let bytes;
279
+ const cachePath = opts.version ? join(manifests, `${opts.version}.json`) : undefined;
280
+ if (cachePath) {
281
+ try { bytes = await readFile(cachePath); }
282
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
283
+ }
284
+ bytes ??= await download(new URL(!opts.github && opts.version ? `releases/${opts.version}.json` : 'release.json', opts.source).href, limits.manifest, opts.github);
285
+ const manifest = manifestFrom(bytes, opts.version);
286
+ const python = await installRelease(root, opts.source, sourceRoot, bytes, manifest, opts.github);
287
+ await atomicWrite(join(manifests, `${manifest.version}.json`), bytes);
288
+ const result = await runChild(python, ['-I', '-m', 'agentrun_cli', ...opts.args], childEnvironment(root));
289
+ if (result.signal) receivedSignal ??= result.signal;
290
+ else process.exitCode = result.code ?? 1;
291
+ }
292
+
293
+ try { await main(); }
294
+ catch (error) { console.error(`agentrun: ${error.message}`); process.exitCode = 1; }
295
+ if (receivedSignal) {
296
+ process.removeAllListeners(receivedSignal);
297
+ process.kill(process.pid, receivedSignal);
298
+ }
package/package.json CHANGED
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "name": "@parcha/agentrun-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Verified, persistent launcher for the AgentRun Python CLI",
5
+ "type": "module",
6
+ "bin": { "agentrun": "bin/agentrun.mjs" },
7
+ "files": ["bin/agentrun.mjs", "README.md"],
8
+ "engines": { "node": ">=20" },
9
+ "scripts": { "test": "node --test test/*.test.mjs" },
10
+ "license": "UNLICENSED"
11
+ }