@robsonai/emilyremote 0.3.0

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,18 @@
1
+ {
2
+ "schema": "emilyremote.npm-provenance/1",
3
+ "display_name": "EmilyRemote",
4
+ "npm_package_name": "@robsonai/emilyremote",
5
+ "npm_name_status": "requested organization target; robsonai organization ownership and publication are pending verification",
6
+ "primary_command": "emilyremote",
7
+ "prior_distribution": {
8
+ "name": "@martinrobson/dialhome@0.2.0",
9
+ "status": "published; anonymous version metadata and tarball verified",
10
+ "tarball_sha256": "dfd56b3bd0f8e57b07559bf43551c05c58957732cd69e70c37a5fbbf86506326"
11
+ },
12
+ "sdk_version": "0.3.0",
13
+ "wheel": "vendor/robsonai_emilyremote-0.3.0-py3-none-any.whl",
14
+ "wheel_sha256": "e730b011b844dc70442fa793d29c98eb9df8dea5341ebbb49ba38fd73da0269e",
15
+ "source_changes": "Version 0.3.0 uses the emilyremote Python namespace and robsonai-emilyremote distribution, with release security fixes. Prior release test results do not certify this build.",
16
+ "scope": "Host Python runtime only; optional dependencies, Android APK, and microcontroller firmware are not installed by this npm wrapper",
17
+ "sdk_source": "RobsonAI EmilyRemote 0.3.0 release source, rebuilt after namespace and security changes"
18
+ }
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # EmilyRemote by RobsonAI
2
+
3
+ EmilyRemote installs the RobsonAI EmilyRemote 0.3.0 host SDK from a bundled,
4
+ verified Python wheel. The Python namespace is `emilyremote`, the Python
5
+ distribution is `robsonai-emilyremote`, and the command is `emilyremote`.
6
+ The requested npm publication target is **`@robsonai/emilyremote`**. The display
7
+ name is **EmilyRemote**; npm package names use lowercase. Organization ownership
8
+ and publication are pending verification. The examples below become public
9
+ installation commands after publication.
10
+
11
+ ## Install and initialize
12
+
13
+ Requires Node.js 18 or newer and Python 3.11 or newer with `venv` and `ensurepip`.
14
+ The wrapper has no external Node dependencies. The SDK core has no external
15
+ Python dependencies.
16
+
17
+ ```bash
18
+ npm install -g @robsonai/emilyremote@0.3.0
19
+ emilyremote setup --python python3
20
+ emilyremote self-check
21
+ ```
22
+
23
+ Or use npx:
24
+
25
+ ```bash
26
+ npx --yes --package=@robsonai/emilyremote@0.3.0 emilyremote setup --python python3
27
+ npx --yes --package=@robsonai/emilyremote@0.3.0 emilyremote self-check
28
+ ```
29
+
30
+ Installing the npm package runs no lifecycle hook. `setup` is an explicit action:
31
+ it creates a private, versioned virtualenv and installs the bundled wheel using
32
+ `pip --isolated --no-index --no-deps`. It checks the wheel SHA-256, installed
33
+ metadata, every installed SDK Python file, and the SDK self-check before writing
34
+ an atomic READY receipt. A failed attempt is retained for diagnosis and never
35
+ published as a ready installation. A setup lock prevents simultaneous installers.
36
+ If a process dies during setup, inspect the reported lock before removing it.
37
+
38
+ Setup does not install system Python, Node, optional dependencies, operating
39
+ system services, enroll a node, configure credentials, or start a worker.
40
+ On Debian/Ubuntu, Python may require its matching `python3-venv` system package.
41
+ The wrapper reports missing prerequisites and does not install them implicitly.
42
+
43
+ ## Isolated installation or local tarball
44
+
45
+ ```bash
46
+ npm install --prefix ./npm-runtime --ignore-scripts --no-audit --no-fund ./robsonai-emilyremote-0.3.0.tgz
47
+ ./npm-runtime/node_modules/.bin/emilyremote setup --home ./emilyremote-state --python /usr/bin/python3
48
+ ./npm-runtime/node_modules/.bin/emilyremote --home ./emilyremote-state self-check
49
+ ./npm-runtime/node_modules/.bin/emilyremote --home ./emilyremote-state status
50
+ ```
51
+
52
+ `--home` or `EMILYREMOTE_HOME` selects the installation directory. Otherwise the
53
+ defaults are:
54
+
55
+ | Platform | Default |
56
+ | --- | --- |
57
+ | Linux | `$XDG_DATA_HOME/robsonai/emilyremote`, otherwise `~/.local/share/robsonai/emilyremote` |
58
+ | macOS | `~/Library/Application Support/RobsonAI/EmilyRemote` |
59
+ | Windows | `%LOCALAPPDATA%\RobsonAI\EmilyRemote` |
60
+
61
+ Choose a directory owned by your account with trusted parent directories.
62
+ POSIX setup rejects writable shared ancestors, except root-owned sticky temporary
63
+ directories, and rejects symlinked ancestors. Windows ACL behavior still needs
64
+ native qualification; use the default per-user location.
65
+
66
+ Use the same home for setup and subsequent commands. A new npm invocation can
67
+ reuse the same verified runtime. `setup --python` accepts an executable path,
68
+ including spaces, rather than a shell command. Windows setup also probes the
69
+ `py -3` launcher when no executable is selected. PowerShell can invoke the npm
70
+ generated `emilyremote.cmd` shim; use a Python executable path for `--python`.
71
+ Only the `emilyremote` command is installed. The explicit npx `--package` form
72
+ selects that command directly. For an unpublished local tarball, use
73
+ `npx --offline --yes --package=./robsonai-emilyremote-0.3.0.tgz emilyremote --help`.
74
+
75
+ ## Upgrading from the earlier package
76
+
77
+ The historical `@martinrobson/dialhome@0.2.0` distribution remains separate.
78
+ Version 0.3.0 installs the fully renamed EmilyRemote package and command without
79
+ adding a `dialhome` alias. Use `EMILYREMOTE_HOME` or the new default directory;
80
+ `DIALHOME_HOME` is not read. Keep old installation files and operational state
81
+ for reference until migration is verified. Create fresh runtime/node state and
82
+ explicitly re-enroll as needed; do not assume old identity databases, receipt
83
+ schemas, or running services migrate automatically. Installing this version
84
+ does not remove the prior package or change its services.
85
+
86
+ ## SDK commands
87
+
88
+ ```bash
89
+ emilyremote --home ./emilyremote-state node --config /absolute/path/node.json
90
+ ```
91
+
92
+ Arguments are passed directly without a shell, using isolated Python mode.
93
+ The caller's ordinary OS environment remains available to the SDK; Python path
94
+ injection and pip index environment variables are removed. `--` stops wrapper
95
+ option parsing. Node shutdown signals are forwarded to the Python process.
96
+
97
+ Only commands explicitly requested by the caller run. The SDK's shell capability
98
+ uses that process owner's OS permissions; this wrapper is not a sandbox. The
99
+ wrapper's `status` reports package readiness, not a running service or enrollment.
100
+
101
+ ## Supported scope and validation
102
+
103
+ This package installs the Python host runtime on systems that can provide the
104
+ prerequisites. Linux package installation and self-check are verified for this
105
+ release. macOS and Windows paths and launcher handling are implemented but still
106
+ require native qualification. Core installation
107
+ does not include the optional `cryptography`, `pyserial`, or `pymavlink` packages,
108
+ local model weights, Android APK, Arduino/ESP32 firmware, or a controller service.
109
+ It makes no claim that Emily can dispatch a task until that integration is tested.
110
+
111
+ ## Draft publication procedure
112
+
113
+ 1. Verify the authenticated account has publish rights for the `robsonai`
114
+ organization and target `@robsonai/emilyremote@0.3.0`. Review licensing before
115
+ publishing; the package currently declares `UNLICENSED`.
116
+ 2. Run `npm test`, `npm pack --json`, and test installation of the resulting
117
+ tarball into a fresh prefix and fresh EmilyRemote home.
118
+ 3. Review the packed file list and provenance. It contains the SDK wheel and
119
+ wrapper, with no node credentials or private keys.
120
+ 4. From the verified package directory, use `npm publish --access public` only
121
+ after publication is authorized and the registry authentication is ready.
122
+ 5. Verify the registry tarball integrity and repeat installation using the public
123
+ package name. Do not describe the npm URL as live before that check succeeds.
124
+
125
+ `PROVENANCE.json` records the release wheel identity and historical distribution.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ require('../lib/cli.cjs').main().then((code) => { process.exitCode = code; }).catch((error) => {
4
+ console.error(`EmilyRemote: ${error.message}`);
5
+ process.exitCode = 1;
6
+ });
package/lib/cli.cjs ADDED
@@ -0,0 +1,282 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+ const { spawn, spawnSync } = require('node:child_process');
8
+
9
+ const VERSION = '0.3.0';
10
+ const WHEEL_SHA = 'e730b011b844dc70442fa793d29c98eb9df8dea5341ebbb49ba38fd73da0269e';
11
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
12
+ const WHEEL = path.join(PACKAGE_ROOT, 'vendor', 'robsonai_emilyremote-0.3.0-py3-none-any.whl');
13
+ const VERIFY = path.join(PACKAGE_ROOT, 'scripts', 'verify_install.py');
14
+
15
+ const HELP = `EmilyRemote by RobsonAI ${VERSION}
16
+
17
+ emilyremote setup [--python PATH] [--home DIRECTORY]
18
+ emilyremote [--home DIRECTORY] <SDK command> [arguments...]
19
+ emilyremote status [--home DIRECTORY]
20
+
21
+ Setup explicitly installs the bundled core SDK into your own versioned Python
22
+ virtualenv. Python 3.11+ with venv/ensurepip is required. Installation is offline.
23
+ Installing this npm package does not start setup, services, or enrollment.
24
+
25
+ Examples:
26
+ emilyremote setup --python python3
27
+ emilyremote self-check
28
+ emilyremote node --config /path/to/node.json
29
+
30
+ --home overrides EMILYREMOTE_HOME and the platform's per-user data directory.
31
+ --python selects a Python executable only for setup. Use -- to stop wrapper
32
+ option parsing. Other arguments pass directly to the SDK without a shell.
33
+ Optional update/serial/vehicle dependencies are not bundled.
34
+ `;
35
+
36
+ function parseArgs(argv) {
37
+ let home, python;
38
+ const rest = [];
39
+ for (let i = 0; i < argv.length; i++) {
40
+ if (argv[i] === '--') { rest.push(...argv.slice(i + 1)); break; }
41
+ if (argv[i] === '--home' || argv[i] === '--python') {
42
+ const flag = argv[i];
43
+ if (!argv[i + 1] || argv[i + 1].startsWith('--')) throw new Error(`${flag} requires a value`);
44
+ if (flag === '--home') { if (home !== undefined) throw new Error('Duplicate --home'); home = argv[++i]; }
45
+ else { if (python !== undefined) throw new Error('Duplicate --python'); python = argv[++i]; }
46
+ } else rest.push(argv[i]);
47
+ }
48
+ if (python !== undefined && rest[0] !== 'setup') throw new Error('--python is supported only for setup');
49
+ return { home, python, command: rest[0], args: rest.slice(1) };
50
+ }
51
+
52
+ function defaultHome(env = process.env, platform = process.platform, userHome = os.homedir()) {
53
+ if (env.EMILYREMOTE_HOME) return path.resolve(env.EMILYREMOTE_HOME);
54
+ if (platform === 'win32') return path.join(env.LOCALAPPDATA || path.join(userHome, 'AppData', 'Local'), 'RobsonAI', 'EmilyRemote');
55
+ if (platform === 'darwin') return path.join(userHome, 'Library', 'Application Support', 'RobsonAI', 'EmilyRemote');
56
+ return path.join(env.XDG_DATA_HOME || path.join(userHome, '.local', 'share'), 'robsonai', 'emilyremote');
57
+ }
58
+
59
+ function sha(file) { return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); }
60
+ function checkWheel() { if (sha(WHEEL) !== WHEEL_SHA) throw new Error('Bundled SDK wheel failed its SHA-256 check; reinstall this npm package'); }
61
+ function pythonPath(runtime, platform = process.platform) { return path.join(runtime, platform === 'win32' ? 'Scripts' : 'bin', platform === 'win32' ? 'python.exe' : 'python'); }
62
+ function cleanEnv(env = process.env) {
63
+ const copy = { ...env };
64
+ // SDK children retain normal OS environment, but never inherit Python path
65
+ // injection or pip index settings from the calling npm process.
66
+ for (const key of Object.keys(copy)) if (/^(PYTHON|PIP_)/i.test(key)) delete copy[key];
67
+ copy.PYTHONDONTWRITEBYTECODE = '1';
68
+ return copy;
69
+ }
70
+ function privateDir(directory) { fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); }
71
+ function checkHomeAncestors(home) {
72
+ // A private leaf is insufficient if another account can replace a parent.
73
+ // Root-owned sticky temporary directories protect an owned child from rename.
74
+ let current = path.dirname(path.resolve(home));
75
+ while (true) {
76
+ try {
77
+ const stat = fs.lstatSync(current);
78
+ if (stat.isSymbolicLink()) throw new Error(`EmilyRemote home requires real directory ancestors; resolve this symlink first: ${current}`);
79
+ if (!stat.isDirectory()) throw new Error(`EmilyRemote home ancestor is not a directory: ${current}`);
80
+ if (process.platform !== 'win32') {
81
+ if (![0, process.getuid()].includes(stat.uid)) throw new Error(`EmilyRemote home ancestor is not owned by this user or root: ${current}`);
82
+ const protectedTemporary = stat.uid === 0 && (stat.mode & 0o1000);
83
+ if ((stat.mode & 0o022) && !protectedTemporary) throw new Error(`EmilyRemote home ancestor is writable by other users: ${current}`);
84
+ }
85
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
86
+ const parent = path.dirname(current);
87
+ if (parent === current) break;
88
+ current = parent;
89
+ }
90
+ }
91
+ function checkHomePath(home, target) {
92
+ checkHomeAncestors(home);
93
+ const relative = path.relative(home, target);
94
+ if (relative.startsWith('..' + path.sep) || relative === '..' || path.isAbsolute(relative)) throw new Error('Path escapes EmilyRemote home');
95
+ let current = home;
96
+ for (const segment of ['', ...relative.split(path.sep).filter(Boolean)]) {
97
+ if (segment) current = path.join(current, segment);
98
+ try {
99
+ const stat = fs.lstatSync(current);
100
+ if (stat.isSymbolicLink()) throw new Error(`Symlink refused inside EmilyRemote home: ${current}`);
101
+ if (process.platform !== 'win32' && stat.uid !== process.getuid()) throw new Error(`EmilyRemote state must belong to the current user: ${current}`);
102
+ if (process.platform !== 'win32' && (stat.mode & 0o022)) throw new Error(`EmilyRemote state must not be writable by other users: ${current}`);
103
+ }
104
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
105
+ }
106
+ }
107
+ function homeDir(home, target) { checkHomePath(home, target); privateDir(target); }
108
+ function writeJsonAtomic(destination, value) {
109
+ privateDir(path.dirname(destination));
110
+ const temp = `${destination}.${crypto.randomBytes(8).toString('hex')}.tmp`;
111
+ let fd;
112
+ try {
113
+ fd = fs.openSync(temp, 'wx', 0o600);
114
+ fs.writeFileSync(fd, JSON.stringify(value, null, 2) + '\n');
115
+ fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
116
+ fs.renameSync(temp, destination);
117
+ if (process.platform !== 'win32') { const parent = fs.openSync(path.dirname(destination), 'r'); try { fs.fsyncSync(parent); } finally { fs.closeSync(parent); } }
118
+ } finally {
119
+ if (fd !== undefined) fs.closeSync(fd);
120
+ if (fs.existsSync(temp)) fs.unlinkSync(temp);
121
+ }
122
+ }
123
+
124
+ function runChecked(executable, args, options = {}, runner = spawnSync) {
125
+ const result = runner(executable, args, { shell: false, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024,
126
+ timeout: 120000, windowsHide: true, env: cleanEnv(), ...options });
127
+ if (result.error) throw new Error(`Could not run ${path.basename(executable)}: ${result.error.code || result.error.message}`);
128
+ if (result.status !== 0) throw new Error(`Command failed (${result.status === null ? result.signal : result.status}): ${(result.stderr || result.stdout || '').trim().slice(-3000)}`);
129
+ return result.stdout || '';
130
+ }
131
+ function discoverPython(selected, runner = spawnSync) {
132
+ const candidates = selected ? [{ executable: selected, prefix: [] }] : process.platform === 'win32'
133
+ ? [{ executable: 'py', prefix: ['-3'] }, { executable: 'python', prefix: [] }]
134
+ : [{ executable: 'python3', prefix: [] }, { executable: 'python', prefix: [] }];
135
+ const problems = [];
136
+ for (const candidate of candidates) {
137
+ try {
138
+ const output = runChecked(candidate.executable, [...candidate.prefix, '-I', '-B', '-c',
139
+ 'import json,sys; print(json.dumps({"version":list(sys.version_info[:3]),"executable":sys.executable}))'], { timeout: 15000 }, runner);
140
+ const info = JSON.parse(output);
141
+ if (info.version[0] !== 3 || info.version[1] < 11) throw new Error(`Python ${info.version.join('.')} is too old`);
142
+ if (!path.isAbsolute(info.executable)) throw new Error('Python did not report an absolute executable path');
143
+ return info;
144
+ } catch (error) { problems.push(`${candidate.executable}: ${error.message}`); }
145
+ }
146
+ throw new Error(`Python 3.11+ is required. Install it with venv/ensurepip support, then run emilyremote setup --python /absolute/path/to/python. ${problems.join('; ')}`);
147
+ }
148
+
149
+ function receiptPath(home) { return path.join(home, 'receipts', `${VERSION}.json`); }
150
+ function readReceipt(home) {
151
+ const file = receiptPath(home);
152
+ checkHomePath(home, file);
153
+ if (!fs.existsSync(file)) throw new Error('SDK is not installed here. Run emilyremote setup first');
154
+ const receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
155
+ if (receipt.state !== 'READY' || receipt.version !== VERSION || receipt.wheel_sha256 !== WHEEL_SHA) throw new Error('Installation receipt is not READY for this SDK version; run emilyremote setup');
156
+ if (typeof receipt.runtime_relative !== 'string' || path.isAbsolute(receipt.runtime_relative)) throw new Error('Invalid runtime path in installation receipt');
157
+ const root = path.resolve(home, 'runtimes', VERSION);
158
+ const runtime = path.resolve(home, receipt.runtime_relative);
159
+ if (!runtime.startsWith(root + path.sep) || runtime === root) throw new Error('Runtime path escapes the versioned installation directory');
160
+ checkHomePath(home, runtime);
161
+ if (!fs.existsSync(runtime) || !fs.realpathSync(runtime).startsWith(fs.realpathSync(root) + path.sep)) throw new Error('Runtime directory is missing or escapes its installation');
162
+ return { receipt, runtime };
163
+ }
164
+ function verifyInstalled(runtime, runner = spawnSync) {
165
+ checkWheel();
166
+ return JSON.parse(runChecked(pythonPath(runtime), ['-I', '-B', VERIFY, '--wheel', WHEEL, '--runtime', runtime], { timeout: 30000 }, runner));
167
+ }
168
+
169
+ async function setup(home, selectedPython, { runner = spawnSync, log = console.log } = {}) {
170
+ checkWheel();
171
+ homeDir(home, home);
172
+ const lock = path.join(home, '.setup-lock');
173
+ checkHomePath(home, lock);
174
+ try { fs.mkdirSync(lock, { mode: 0o700 }); } catch (error) {
175
+ if (error.code === 'EEXIST') throw new Error(`Setup is already running or was interrupted. Inspect ${lock} before removing that lock and trying again`);
176
+ throw error;
177
+ }
178
+ let runtime;
179
+ try {
180
+ writeJsonAtomic(path.join(lock, 'owner.json'), { pid: process.pid, started_at: new Date().toISOString() });
181
+ try {
182
+ const existing = readReceipt(home);
183
+ verifyInstalled(existing.runtime, runner);
184
+ log(JSON.stringify({ state: 'READY', already_installed: true, version: VERSION, runtime: existing.runtime, receipt: receiptPath(home) }));
185
+ return 0;
186
+ } catch (error) {
187
+ if (fs.existsSync(receiptPath(home))) {
188
+ checkHomePath(home, receiptPath(home));
189
+ log(`Existing installation needs repair: ${error.message}`);
190
+ // A failed replacement must never leave an invalid READY receipt.
191
+ fs.renameSync(receiptPath(home), path.join(home, 'receipts', `${VERSION}-invalidated-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.json`));
192
+ }
193
+ }
194
+ const python = discoverPython(selectedPython, runner);
195
+ const attempt = `${Date.now()}-${crypto.randomBytes(6).toString('hex')}`;
196
+ runtime = path.join(home, 'runtimes', VERSION, attempt);
197
+ homeDir(home, path.dirname(runtime));
198
+ checkHomePath(home, runtime);
199
+ const temporary = path.join(home, 'tmp', attempt);
200
+ homeDir(home, temporary);
201
+ const environment = { ...cleanEnv(), TMPDIR: temporary, TMP: temporary, TEMP: temporary,
202
+ // pip compares against Python's os.devnull exactly; Node's Windows
203
+ // spelling (\\\\.\\nul) differs from Python's "nul".
204
+ PIP_CONFIG_FILE: process.platform === 'win32' ? 'nul' : '/dev/null' };
205
+ log(`Installing EmilyRemote ${VERSION} offline into ${runtime}`);
206
+ try {
207
+ runChecked(python.executable, ['-I', '-B', '-m', 'venv', runtime], { env: environment }, runner);
208
+ } catch (error) {
209
+ throw new Error(`Unable to create the private virtualenv. Python must include venv and ensurepip (on Debian/Ubuntu the matching python3-venv package). No system packages were installed. ${error.message}`);
210
+ }
211
+ const executable = pythonPath(runtime);
212
+ const pipReport = path.join(home, 'receipts', `${attempt}-pip.json`);
213
+ homeDir(home, path.dirname(pipReport));
214
+ checkHomePath(home, pipReport);
215
+ const pipOutput = runChecked(executable, ['-I', '-B', '-m', 'pip', '--isolated', 'install',
216
+ '--no-index', '--no-deps', '--no-compile', '--no-cache-dir', '--disable-pip-version-check',
217
+ '--report', pipReport, WHEEL], { env: environment }, runner);
218
+ fs.writeFileSync(path.join(home, 'receipts', `${attempt}-install.log`), pipOutput, { mode: 0o600 });
219
+ const verification = verifyInstalled(runtime, runner);
220
+ const selfCheck = JSON.parse(runChecked(executable, ['-I', '-B', '-m', 'emilyremote', 'self-check'], { env: environment }, runner));
221
+ if (selfCheck.ok !== true || selfCheck.version !== VERSION) throw new Error('Installed SDK self-check did not pass');
222
+ const receipt = { schema: 'emilyremote.npm-installation/1', state: 'READY', version: VERSION,
223
+ installed_at: new Date().toISOString(), wheel_sha256: WHEEL_SHA,
224
+ runtime_relative: path.relative(home, runtime), python: python.version, verification, self_check: selfCheck,
225
+ pip_report_relative: path.relative(home, pipReport),
226
+ scope: 'Core package installed only; no service, enrollment, task dispatch, or optional dependencies' };
227
+ writeJsonAtomic(receiptPath(home), receipt);
228
+ log(JSON.stringify({ state: 'READY', version: VERSION, runtime, receipt: receiptPath(home) }));
229
+ return 0;
230
+ } catch (error) {
231
+ if (runtime) {
232
+ const failure = path.join(home, 'receipts', `${path.basename(runtime)}-failed.json`);
233
+ try {
234
+ checkHomePath(home, failure);
235
+ writeJsonAtomic(failure, { state: 'FAILED', version: VERSION, runtime_relative: path.relative(home, runtime),
236
+ failed_at: new Date().toISOString(), error: error.message,
237
+ note: 'This attempt was not published as a READY installation. Partial files retained for diagnosis.' });
238
+ } catch (_) { /* Preserve the original error; never write through an unsafe path. */ }
239
+ }
240
+ throw error;
241
+ } finally {
242
+ fs.rmSync(lock, { recursive: true, force: true });
243
+ }
244
+ }
245
+
246
+ function delegate(runtime, args, spawner = spawn) {
247
+ return new Promise((resolve, reject) => {
248
+ const child = spawner(pythonPath(runtime), ['-I', '-B', '-m', 'emilyremote', ...args],
249
+ { shell: false, stdio: 'inherit', env: cleanEnv(), windowsHide: true });
250
+ const forward = (signal) => { try { child.kill(signal); } catch (_) {} };
251
+ const onInt = () => forward('SIGINT');
252
+ const onTerm = () => forward('SIGTERM');
253
+ process.on('SIGINT', onInt); process.on('SIGTERM', onTerm);
254
+ const cleanup = () => { process.off('SIGINT', onInt); process.off('SIGTERM', onTerm); };
255
+ child.once('error', (error) => { cleanup(); reject(new Error(`Unable to launch installed SDK: ${error.message}`)); });
256
+ child.once('exit', (code, signal) => { cleanup(); resolve(code === null ? signal === 'SIGINT' ? 130 : 143 : code); });
257
+ });
258
+ }
259
+
260
+ async function main(argv = process.argv.slice(2)) {
261
+ const parsed = parseArgs(argv);
262
+ if (!parsed.command || ['--help', '-h', 'help'].includes(parsed.command)) { console.log(HELP); return 0; }
263
+ if (parsed.command === '--version') { console.log(VERSION); return 0; }
264
+ const home = path.resolve(parsed.home || defaultHome());
265
+ if (parsed.command === 'setup') {
266
+ if (parsed.args.includes('--help') || parsed.args.includes('-h')) { console.log(HELP); return 0; }
267
+ if (parsed.args.length) throw new Error('Unknown setup arguments; use emilyremote setup --help');
268
+ return setup(home, parsed.python);
269
+ }
270
+ const installed = readReceipt(home);
271
+ const verification = verifyInstalled(installed.runtime);
272
+ if (parsed.command === 'status') {
273
+ if (parsed.args.length) throw new Error('status does not accept SDK arguments');
274
+ console.log(JSON.stringify({ state: 'READY', version: VERSION, runtime: installed.runtime,
275
+ receipt: receiptPath(home), verification, service_started_by_wrapper: false }, null, 2));
276
+ return 0;
277
+ }
278
+ return delegate(installed.runtime, [parsed.command, ...parsed.args]);
279
+ }
280
+
281
+ module.exports = { main, parseArgs, defaultHome, cleanEnv, checkHomeAncestors, checkHomePath, runChecked, discoverPython, readReceipt,
282
+ verifyInstalled, setup, delegate, pythonPath, VERSION, WHEEL_SHA, WHEEL, receiptPath };
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@robsonai/emilyremote",
3
+ "version": "0.3.0",
4
+ "description": "EmilyRemote by RobsonAI: explicit offline installer and command wrapper for the EmilyRemote host SDK",
5
+ "license": "UNLICENSED",
6
+ "engines": {"node": ">=18"},
7
+ "bin": {"emilyremote": "bin/emilyremote.cjs"},
8
+ "files": ["bin/", "lib/", "scripts/", "vendor/", "README.md", "PROVENANCE.json"],
9
+ "scripts": {"test": "node --test test/*.test.cjs"},
10
+ "publishConfig": {"access": "public"},
11
+ "keywords": ["emilyremote", "robsonai", "edge", "sdk"],
12
+ "author": "RobsonAI"
13
+ }
@@ -0,0 +1,48 @@
1
+ """Read-only installed-wheel verification. Invoked with isolated Python -I -B."""
2
+ import argparse
3
+ import hashlib
4
+ import importlib.metadata
5
+ import importlib.util
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import sys
10
+ import zipfile
11
+
12
+ EXPECTED_SHA = "e730b011b844dc70442fa793d29c98eb9df8dea5341ebbb49ba38fd73da0269e"
13
+ parser = argparse.ArgumentParser()
14
+ parser.add_argument("--wheel", required=True)
15
+ parser.add_argument("--runtime", required=True)
16
+ args = parser.parse_args()
17
+ assert sys.version_info >= (3, 11), "Python 3.11+ is required"
18
+ wheel = Path(args.wheel)
19
+ assert hashlib.sha256(wheel.read_bytes()).hexdigest() == EXPECTED_SHA, "Bundled wheel mismatch"
20
+ runtime = Path(args.runtime).resolve()
21
+ assert Path(sys.prefix).resolve() == runtime, "Python is not running in the selected virtualenv"
22
+ assert sys.prefix != sys.base_prefix, "Global Python is not an isolated runtime"
23
+ assert importlib.metadata.version("robsonai-emilyremote") == "0.3.0", "Installed metadata version mismatch"
24
+ spec = importlib.util.find_spec("emilyremote")
25
+ assert spec and spec.origin and spec.origin.endswith("__init__.py"), "SDK did not resolve to Python source"
26
+ package = Path(spec.origin).resolve().parent
27
+ assert runtime in package.parents, "SDK import came from outside its virtualenv"
28
+ hashes = {}
29
+ with zipfile.ZipFile(wheel) as archive:
30
+ for name in archive.namelist():
31
+ if not name.startswith("emilyremote/") or not name.endswith(".py"):
32
+ continue
33
+ relative = name[len("emilyremote/"):]
34
+ actual = package / relative
35
+ assert package in actual.resolve().parents, "Source path escapes installed SDK"
36
+ expected = hashlib.sha256(archive.read(name)).hexdigest()
37
+ assert hashlib.sha256(actual.read_bytes()).hexdigest() == expected, "Installed source mismatch: " + name
38
+ hashes[name] = expected
39
+ actual_names = {"emilyremote/" + p.relative_to(package).as_posix() for p in package.rglob("*.py")}
40
+ assert actual_names == set(hashes), "Installed SDK source set differs from the wheel"
41
+ all_files = {"emilyremote/" + p.relative_to(package).as_posix() for p in package.rglob("*") if p.is_file()}
42
+ assert all_files == set(hashes), "Unexpected installed SDK files (including bytecode or native modules)"
43
+ import emilyremote
44
+ assert emilyremote.__version__ == "0.3.0", "Runtime version mismatch"
45
+ print(json.dumps({"ok": True, "version": emilyremote.__version__, "python": sys.version.split()[0],
46
+ "executable": sys.executable, "runtime": str(runtime), "package": str(package),
47
+ "wheel_sha256": EXPECTED_SHA, "source_hashes": hashes,
48
+ "uid": os.getuid() if hasattr(os, "getuid") else None}, sort_keys=True))