@robsonai/emilyremote 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -1,125 +1,91 @@
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
1
+ # EmilyRemote 0.4.0
2
+
3
+ EmilyRemote lets a controller assign work to an installed device runtime. This
4
+ stable release packages the compiled **device-side** SDK for **Linux x64 with
5
+ glibc 2.38 or newer**. Installation and launch require **Node.js 22 or newer and
6
+ npm**. A separately installed Python interpreter is not needed.
7
+
8
+ The package contains a native archive and a small, intentionally readable
9
+ JavaScript installer. The archive contains compiled SDK modules and required
10
+ runtime libraries, including CPython. It carries no SDK Python source or wheel.
11
+ Compiled binaries remain inspectable and can be reverse engineered.
12
+
13
+ ## Install and check
14
+
15
+ Install into a separate local prefix so an existing deployment keeps its command
16
+ and state. Both `emilyremote` and `emilyremote-native` invoke the same launcher.
17
+
18
+ ```sh
19
+ umask 077
20
+ install_root="$HOME/.local/share/robsonai/emilyremote-0.4"
21
+ npm install --prefix "$install_root/npm" --registry=https://registry.npmjs.org \
22
+ --ignore-scripts --no-audit --no-fund @robsonai/emilyremote@0.4.0
23
+ "$install_root/npm/node_modules/.bin/emilyremote" setup --home "$install_root/state"
24
+ "$install_root/npm/node_modules/.bin/emilyremote" --home "$install_root/state" status
25
+ "$install_root/npm/node_modules/.bin/emilyremote" --home "$install_root/state" self-check
21
26
  ```
22
27
 
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
28
+ The stable npm channel is `@robsonai/emilyremote@latest`. The commands above pin
29
+ this documented release. `setup` selects the packaged archive, checks its pinned
30
+ SHA-256 and complete inventory, and runs the native self-check before activation.
31
+ Setup needs no network after npm installation. npm installation runs no lifecycle
32
+ setup or native executable. Setup does not enroll a device, start a service,
33
+ connect to a controller, install a model or change system packages.
34
+
35
+ ## Upgrading from 0.3.0
36
+
37
+ **Version 0.4.0 changes platform support and installation state.** A global npm
38
+ upgrade replaces the existing `emilyremote` command with this native launcher.
39
+ The separate-prefix installation above avoids replacing that command. This
40
+ release does not migrate the Python runtime, service configuration, controller
41
+ roles or state from 0.3.0. Setup refuses a home containing that installation;
42
+ choose a new native home and configure it deliberately.
43
+
44
+ The earlier `EMILYREMOTE_HOME` variable is not read. The native default remains
45
+ `~/.local/share/robsonai/emilyremote-native`, or
46
+ `$XDG_DATA_HOME/robsonai/emilyremote-native` when configured. `--home` or
47
+ `EMILYREMOTE_NATIVE_HOME` selects another native location. Both command aliases
48
+ use the same selected native home.
49
+
50
+ npm rejects installation on macOS, Windows, Linux ARM and other unsupported
51
+ platforms. These devices must retain their existing compatible release; no native
52
+ build for them is included here. Do not use `--force` to bypass platform checks.
53
+ Existing published versions, including 0.3.0, are not removed or revoked by this
54
+ release. Arduino-class microcontrollers do not run this Linux host package.
55
+
56
+ ## Runtime and installation boundaries
57
+
58
+ The device runtime supports shell/file tasks, PTY, tunnels, serial work and
59
+ configured local-agent activity. It includes no hosted controller, Emily account
60
+ credentials, model weights or optional `pymavlink` dependency. Device operation
61
+ requires your controller configuration and authorization. The runtime uses its
62
+ process owner's OS permissions; it is not a sandbox.
63
+
64
+ Use a native home owned by your user with trustworthy, non-symlinked ancestors.
65
+ Setup uses an exclusive lock, a private archive snapshot and isolated release
66
+ directories. It rejects traversal, duplicate paths, links, special files, unlisted
67
+ payloads and source-bearing extensions. Each launch verifies the active inventory.
68
+ The active pointer changes only after native self-check succeeds. Caught
69
+ activation failures restore the previous pointer; interrupted locks and failed
70
+ release artifacts remain for inspection. Upgrading npm alone does not activate a
71
+ different native runtime. There is no Python/source fallback.
72
+
73
+ An explicit local archive remains supported, with a mandatory externally
74
+ verified SHA-256:
75
+
76
+ ```sh
77
+ emilyremote setup --bundle /absolute/path/to/reviewed-native.tar.gz \
78
+ --sha256 THE_EXACT_EXTERNALLY_VERIFIED_SHA256 --home /your/private/native-home
90
79
  ```
91
80
 
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.
81
+ Replace the placeholder with a trusted 64-character digest. A hash verifies exact
82
+ bytes, not the publisher. The packaged hash is pinned in this installer; explicit
83
+ archives depend on your trusted digest source. No separate release-signature
84
+ verification or automatic download/update endpoint is configured. Native command
85
+ arguments are passed without a shell, and Python-path/dynamic-loader injection
86
+ variables are removed from the child environment.
87
+
88
+ The release includes the reviewed dependency inventory and upstream notices.
89
+ Release validation covers actual bundled installation and native functional and
90
+ security proofs. The stable channel is not a claim of qualification across
91
+ unsupported platforms or every fleet configuration.
@@ -0,0 +1,50 @@
1
+ # Native bundle contract — schema 1
2
+
3
+ A `.tar.gz` archive in standard USTAR format contains regular files/directories,
4
+ with no leading `./` root. Only relative paths are accepted. Links, PAX/GNU
5
+ extensions, special files, duplicate names, unsafe modes and trailing nonzero
6
+ content are rejected. Current limits: 256 MiB compressed, 512 MiB expanded,
7
+ 20,000 entries, 2 MiB release manifest.
8
+
9
+ The root contains `emilyremote-runtime`, its dependency libraries and
10
+ `native-release.json`. The executable receives normal SDK arguments directly;
11
+ `emilyremote-runtime self-check` must emit JSON with `ok: true` and the exact
12
+ runtime `version`. It must not require a separately installed Python interpreter.
13
+
14
+ ```json
15
+ {
16
+ "schema": "emilyremote.native-release/1",
17
+ "version": "0.4.0",
18
+ "platform": "linux",
19
+ "arch": "x64",
20
+ "entrypoint": "emilyremote-runtime",
21
+ "build_manifest": "build-info.json",
22
+ "minimum_glibc": "2.38",
23
+ "files": {
24
+ "emilyremote-runtime": {"sha256": "ACTUAL_EXECUTABLE_SHA256", "mode": 493},
25
+ "build-info.json": {"sha256": "ACTUAL_BUILD_MANIFEST_SHA256", "mode": 420}
26
+ }
27
+ }
28
+ ```
29
+
30
+ This example is illustrative, not a valid signed or hashed release. Mode values
31
+ are JSON integers (493 = octal 0755, 420 = octal 0644). The optional glibc value
32
+ must come from actual build dependencies. The file inventory must contain every
33
+ regular archive file except `native-release.json` itself, including all library
34
+ files and the build manifest. Directory entries must be parents of listed files.
35
+ All hashes are lowercase SHA-256. ELF class, byte order and machine type must
36
+ match the declared Linux target.
37
+
38
+ The build manifest is hashed as an ordinary payload file. It should record
39
+ version, target, compiler/toolchain identity and relevant reproducible build
40
+ inputs without workstation paths, credentials, proprietary source, caches or
41
+ unnecessary user information. Its public contents need a separate review; a
42
+ correct digest does not make private information safe to publish.
43
+
44
+ The default archive SHA-256 is pinned in the npm installer alongside its exact
45
+ bundled file path. For an explicitly supplied local archive, its SHA-256 must be
46
+ supplied through `setup --sha256`, with the file supplied through `--bundle`.
47
+ Receipt fields retain that archive digest, release-manifest digest and
48
+ build-manifest digest, and distinguish bundled from operator-supplied archives.
49
+ There is no separate signed manifest or release key verification; no independent
50
+ publisher-authentication claim is made.
package/lib/cli.cjs CHANGED
@@ -1,282 +1,346 @@
1
1
  'use strict';
2
2
 
3
+ // Public JavaScript installation/launch plumbing. SDK implementation is compiled
4
+ // in the separately inventoried vendor runtime, never shipped here as source.
3
5
  const fs = require('node:fs');
4
- const os = require('node:os');
5
6
  const path = require('node:path');
7
+ const os = require('node:os');
6
8
  const crypto = require('node:crypto');
9
+ const zlib = require('node:zlib');
7
10
  const { spawn, spawnSync } = require('node:child_process');
8
11
 
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}
12
+ const VERSION = '0.4.0';
13
+ const MAX_BUNDLE = 256 * 1024 * 1024;
14
+ const MAX_EXPANDED = 512 * 1024 * 1024;
15
+ const MANIFEST = 'native-release.json';
16
+ const BUILDS = Object.freeze({ 'linux-x64': 'available', 'linux-arm64': 'unbuilt', 'darwin-x64': 'unbuilt', 'darwin-arm64': 'unbuilt', 'win32-x64': 'unbuilt', 'win32-arm64': 'unbuilt' });
17
+ // This digest is pinned from the separately reviewed native release artifact.
18
+ // Publishing a new bundle requires updating this pin and qualifying the package.
19
+ const BUNDLED = Object.freeze({ 'linux-x64': Object.freeze({
20
+ file: 'vendor/robsonai-emilyremote-native-0.4.0-linux-x64.tar.gz',
21
+ sha256: 'dea2851f6a06203ca0512e0c994ac73b94c4b019fa14cf16a4bf090bc4aafffc',
22
+ }) });
23
+ const HELP = `EmilyRemote native runtime installer ${VERSION}
16
24
 
17
- emilyremote setup [--python PATH] [--home DIRECTORY]
18
- emilyremote [--home DIRECTORY] <SDK command> [arguments...]
25
+ emilyremote setup [--home DIRECTORY]
26
+ emilyremote setup --bundle FILE.tar.gz --sha256 HEX [--home DIRECTORY]
19
27
  emilyremote status [--home DIRECTORY]
28
+ emilyremote platforms
29
+ emilyremote [--home DIRECTORY] <runtime command> [arguments...]
20
30
 
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.
31
+ emilyremote-native is an alias for the same command and native installation.
24
32
 
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.
33
+ Setup selects the packaged Linux x64 native bundle and checks its pinned SHA-256.
34
+ An explicit local --bundle requires an externally supplied --sha256. No host
35
+ Python interpreter, wheel, download host, or source fallback is used. Installing
36
+ this npm package runs no lifecycle setup. Other platform builds are unavailable
37
+ until built and qualified. The small public installer is readable JavaScript;
38
+ compiled binaries can still be inspected and reverse engineered.
34
39
  `;
35
40
 
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) };
41
+ function hash(data) { return crypto.createHash('sha256').update(data).digest('hex'); }
42
+ function sha(file) { return hash(fs.readFileSync(file)); }
43
+ function exists(file) { try { fs.lstatSync(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; } }
44
+ function cleanEnv(source = process.env) {
45
+ const env = { ...source };
46
+ for (const key of Object.keys(env)) if (/^(PYTHON|PIP_|NODE_OPTIONS$|NODE_PATH$|LD_PRELOAD$|LD_LIBRARY_PATH$|DYLD_)/i.test(key)) delete env[key];
47
+ env.PYTHONDONTWRITEBYTECODE = '1';
48
+ return env;
50
49
  }
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');
50
+ function platformTarget(platform = process.platform, arch = process.arch) {
51
+ const target = `${platform}-${arch}`;
52
+ if (!Object.hasOwn(BUILDS, target)) throw new Error(`Unsupported native platform ${target}; no source fallback is available`);
53
+ return target;
57
54
  }
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;
55
+ function requireBuilt(target) {
56
+ if (BUILDS[target] !== 'available') throw new Error(`Native build ${target} is unavailable in this release. Only Linux x64 is packaged; no source fallback is available`);
69
57
  }
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;
58
+ function resolveBundle(options, target = platformTarget(), packageRoot = path.resolve(__dirname, '..')) {
59
+ if (options.bundle !== undefined || options.sha256 !== undefined) {
60
+ if (!options.bundle || !/^[a-fA-F0-9]{64}$/.test(options.sha256 || '')) throw new Error('An explicit --bundle requires an explicit 64-character --sha256; both options must be provided together');
61
+ return { bundle: path.resolve(options.bundle), sha256: options.sha256.toLowerCase() };
89
62
  }
63
+ const bundled = BUNDLED[target];
64
+ if (!bundled) throw new Error(`No bundled native runtime is available for ${target}; no source fallback is available`);
65
+ return { bundle: path.join(packageRoot, bundled.file), sha256: bundled.sha256 };
90
66
  }
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; }
67
+ function defaultHome(env = process.env) {
68
+ if (env.EMILYREMOTE_NATIVE_HOME) return path.resolve(env.EMILYREMOTE_NATIVE_HOME);
69
+ if (process.platform === 'win32') return path.join(env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'RobsonAI', 'EmilyRemoteNative');
70
+ if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'RobsonAI', 'EmilyRemoteNative');
71
+ return path.join(env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'robsonai', 'emilyremote-native');
72
+ }
73
+ function checkPath(target, { ownerLeaf = true, missing = true } = {}) {
74
+ target = path.resolve(target);
75
+ const chain = [];
76
+ for (let current = target;; current = path.dirname(current)) { chain.unshift(current); if (path.dirname(current) === current) break; }
77
+ for (const current of chain) {
78
+ let stat;
79
+ try { stat = fs.lstatSync(current); } catch (error) { if (missing && error.code === 'ENOENT') continue; throw error; }
80
+ const leaf = current === target;
81
+ if (stat.isSymbolicLink()) throw new Error(`Symlink refused in native installation: ${current}`);
82
+ if (!leaf && !stat.isDirectory()) throw new Error(`Installation ancestor is not a directory: ${current}`);
83
+ if (process.platform === 'win32') throw new Error('Windows native installation is unbuilt; ACL qualification is required first');
84
+ const owners = leaf && ownerLeaf ? [process.getuid()] : [0, process.getuid()];
85
+ if (!owners.includes(stat.uid)) throw new Error(`Unsafe installation owner: ${current}`);
86
+ const stickyParent = !leaf && stat.isDirectory() && stat.uid === 0 && (stat.mode & 0o1000);
87
+ if ((stat.mode & 0o022) && !stickyParent) throw new Error(`Installation path is writable by other users: ${current}`);
88
+ if (leaf && !stat.isDirectory() && !stat.isFile()) throw new Error(`Unsupported installation file type: ${current}`);
89
+ }
90
+ return target;
91
+ }
92
+ function ensureDir(directory) {
93
+ directory = checkPath(directory);
94
+ const pending = [];
95
+ for (let current = directory; !exists(current); current = path.dirname(current)) pending.unshift(current);
96
+ for (const current of pending) {
97
+ try { fs.mkdirSync(current, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
98
+ checkPath(current, { missing: false });
105
99
  }
100
+ if (!fs.statSync(directory).isDirectory()) throw new Error(`Expected installation directory: ${directory}`);
101
+ }
102
+ function syncDir(directory) {
103
+ if (process.platform !== 'win32') { const fd = fs.openSync(directory, 'r'); try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } }
106
104
  }
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`;
105
+ function atomicWrite(file, bytes) {
106
+ checkPath(file); ensureDir(path.dirname(file));
107
+ const temporary = `${file}.${crypto.randomBytes(10).toString('hex')}.tmp`;
111
108
  let fd;
112
109
  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
- }
110
+ fd = fs.openSync(temporary, 'wx', 0o600); fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
111
+ checkPath(file); fs.renameSync(temporary, file); syncDir(path.dirname(file));
112
+ } finally { if (fd !== undefined) fs.closeSync(fd); if (exists(temporary)) fs.unlinkSync(temporary); }
122
113
  }
114
+ function writeJson(file, value) { atomicWrite(file, Buffer.from(JSON.stringify(value, null, 2) + '\n')); }
115
+ function optionalBytes(file) { checkPath(file); return exists(file) ? fs.readFileSync(file) : null; }
116
+ function restore(file, bytes) { if (bytes === null) { checkPath(file); if (exists(file)) fs.unlinkSync(file); syncDir(path.dirname(file)); } else atomicWrite(file, bytes); }
123
117
 
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 || '';
118
+ function snapshotBundle(source, destination, expectedSHA) {
119
+ if (!/^[a-fA-F0-9]{64}$/.test(expectedSHA || '')) throw new Error('An explicit 64-character --sha256 is required for the native bundle');
120
+ if (fs.lstatSync(source).isSymbolicLink()) throw new Error('Native bundle source must not be a symlink');
121
+ checkPath(destination); ensureDir(path.dirname(destination));
122
+ let input, output, created = false;
123
+ const digest = crypto.createHash('sha256'); let size = 0;
124
+ try {
125
+ input = fs.openSync(source, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
126
+ if (!fs.fstatSync(input).isFile()) throw new Error('Native bundle source must be a regular file');
127
+ output = fs.openSync(destination, 'wx', 0o600); created = true;
128
+ const chunk = Buffer.allocUnsafe(1024 * 1024);
129
+ for (;;) {
130
+ const count = fs.readSync(input, chunk, 0, chunk.length, null); if (!count) break;
131
+ size += count; if (size > MAX_BUNDLE) throw new Error('Native bundle exceeds the 256 MiB compressed size limit');
132
+ digest.update(chunk.subarray(0, count));
133
+ let written = 0; while (written < count) written += fs.writeSync(output, chunk, written, count - written);
134
+ }
135
+ fs.fsyncSync(output); fs.closeSync(output); output = undefined;
136
+ const actual = digest.digest('hex');
137
+ if (actual !== expectedSHA.toLowerCase()) throw new Error(`Native bundle SHA-256 mismatch: expected ${expectedSHA}, got ${actual}`);
138
+ fs.chmodSync(destination, 0o400);
139
+ return { path: destination, sha256: actual, bytes: size };
140
+ } catch (error) { if (output !== undefined) { fs.closeSync(output); output = undefined; } if (created && exists(destination)) fs.unlinkSync(destination); throw error; }
141
+ finally { if (input !== undefined) fs.closeSync(input); if (output !== undefined) fs.closeSync(output); }
130
142
  }
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}`); }
143
+ function tarString(block, offset, length) { return block.subarray(offset, offset + length).toString('utf8').replace(/\0.*$/s, ''); }
144
+ function tarOctal(block, offset, length) {
145
+ const value = tarString(block, offset, length).trim();
146
+ if (!/^[0-7]*$/.test(value)) throw new Error('Non-octal tar numeric field is unsupported');
147
+ const result = Number.parseInt(value || '0', 8);
148
+ if (!Number.isSafeInteger(result)) throw new Error('Unsafe tar numeric value');
149
+ return result;
150
+ }
151
+ function safeRelative(name) {
152
+ if (typeof name !== 'string' || !name || name.includes('\\') || name.includes(':') || name.startsWith('/') || /[\x00-\x1f\x7f]/.test(name)) throw new Error(`Unsafe native bundle path: ${JSON.stringify(name)}`);
153
+ const pieces = name.split('/');
154
+ if (pieces.some((part) => !part || part === '.' || part === '..')) throw new Error(`Unsafe native bundle path: ${name}`);
155
+ if (pieces.some((part) => part.startsWith('.'))) throw new Error(`Hidden path is not permitted in the native payload: ${name}`);
156
+ if (/\.(?:py|pyc|pyo|pyi|whl|map|c|cc|cpp|cxx|h|hpp|rs|go|java|js|mjs|cjs|ts|tsx)$/i.test(name)) throw new Error(`Source-bearing file is not permitted in the native payload: ${name}`);
157
+ return name;
158
+ }
159
+ function parseTar(gzip) {
160
+ if (gzip.length > MAX_BUNDLE) throw new Error('Native bundle exceeds compressed size limit');
161
+ const data = zlib.gunzipSync(gzip, { maxOutputLength: MAX_EXPANDED });
162
+ const entries = new Map(); let offset = 0, ended = false;
163
+ while (offset + 512 <= data.length) {
164
+ const header = data.subarray(offset, offset + 512); offset += 512;
165
+ if (header.every((byte) => byte === 0)) { ended = true; break; }
166
+ if (entries.size >= 20000) throw new Error('Native bundle contains too many entries');
167
+ const expected = tarOctal(header, 148, 8);
168
+ let actual = 0; for (let index = 0; index < 512; index++) actual += index >= 148 && index < 156 ? 32 : header[index];
169
+ if (actual !== expected) throw new Error('Native tar header checksum mismatch');
170
+ const magic = tarString(header, 257, 6); if (magic !== 'ustar') throw new Error('Native bundle requires standard USTAR format');
171
+ const prefix = tarString(header, 345, 155);
172
+ const raw = `${prefix ? prefix + '/' : ''}${tarString(header, 0, 100)}`;
173
+ const type = header[156] === 0 ? '0' : String.fromCharCode(header[156]);
174
+ if (!['0', '5'].includes(type)) throw new Error('Native bundle links, PAX extensions and special files are forbidden');
175
+ const name = safeRelative(type === '5' ? raw.replace(/\/$/, '') : raw);
176
+ if (entries.has(name)) throw new Error(`Duplicate native bundle path: ${name}`);
177
+ const mode = tarOctal(header, 100, 8);
178
+ if (mode & 0o7022) throw new Error(`Unsafe native file mode: ${name}`);
179
+ if (type === '0' && !(mode & 0o400)) throw new Error(`Native file is not readable by its owner: ${name}`);
180
+ const size = tarOctal(header, 124, 12);
181
+ if (type === '5' && size !== 0) throw new Error('Native tar directory contains data');
182
+ if (offset + size > data.length) throw new Error('Truncated native tar member');
183
+ entries.set(name, { name, type, mode, data: data.subarray(offset, offset + size) });
184
+ offset += Math.ceil(size / 512) * 512;
145
185
  }
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('; ')}`);
186
+ if (!ended || data.subarray(offset).some((byte) => byte !== 0)) throw new Error('Native tar archive has missing end markers or trailing content');
187
+ return entries;
147
188
  }
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 };
189
+ function verifyManifest(entries, target = platformTarget(), expectedVersion = VERSION) {
190
+ const entry = entries.get(MANIFEST);
191
+ if (!entry || entry.type !== '0' || entry.data.length > 2 * 1024 * 1024) throw new Error('Native release manifest is missing or too large');
192
+ let manifest; try { manifest = JSON.parse(entry.data.toString('utf8')); } catch { throw new Error('Native release manifest is not valid JSON'); }
193
+ if (manifest.schema !== 'emilyremote.native-release/1' || manifest.version !== expectedVersion) throw new Error('Native manifest schema/version does not match the expected release');
194
+ if (`${manifest.platform}-${manifest.arch}` !== target) throw new Error(`Native bundle platform does not match host ${target}`);
195
+ if (manifest.entrypoint !== 'emilyremote-runtime') throw new Error('Unexpected native runtime entrypoint');
196
+ if (!manifest.files || Array.isArray(manifest.files) || typeof manifest.files !== 'object') throw new Error('Native manifest requires a file inventory');
197
+ if (Object.hasOwn(manifest.files, MANIFEST)) throw new Error('Manifest must not recursively inventory itself');
198
+ if (typeof manifest.build_manifest !== 'string' || !Object.hasOwn(manifest.files, manifest.build_manifest)) throw new Error('Native build manifest must be present in the hashed inventory');
199
+ for (const [name, expected] of Object.entries(manifest.files)) {
200
+ safeRelative(name);
201
+ const file = entries.get(name);
202
+ if (!file || file.type !== '0') throw new Error(`Native manifest file missing: ${name}`);
203
+ if (!expected || !/^[a-f0-9]{64}$/.test(expected.sha256 || '') || !Number.isInteger(expected.mode)) throw new Error(`Invalid native inventory entry: ${name}`);
204
+ if (expected.mode !== file.mode || (expected.mode & 0o7022)) throw new Error(`Native inventory mode mismatch: ${name}`);
205
+ if (hash(file.data) !== expected.sha256) throw new Error(`Native inventory SHA-256 mismatch: ${name}`);
206
+ }
207
+ for (const [name, file] of entries) {
208
+ if (file.type === '0' && name !== MANIFEST && !Object.hasOwn(manifest.files, name)) throw new Error(`Unlisted native payload file: ${name}`);
209
+ if (file.type === '5' && !Object.keys(manifest.files).some((item) => item.startsWith(name + '/'))) throw new Error(`Unlisted native payload directory: ${name}`);
210
+ }
211
+ const executable = entries.get(manifest.entrypoint);
212
+ if (!executable || !(executable.mode & 0o100)) throw new Error('Native entrypoint is missing or not executable');
213
+ if (manifest.platform === 'linux' && (executable.data.length < 20 || !executable.data.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) || executable.data[4] !== 2 || executable.data[5] !== 1 || executable.data.readUInt16LE(18) !== (manifest.arch === 'x64' ? 62 : 183))) throw new Error('Native entrypoint is not the expected ELF architecture');
214
+ if (manifest.minimum_glibc) {
215
+ const host = process.report?.getReport().header.glibcVersionRuntime;
216
+ const parts = (text) => String(text).split('.').map(Number);
217
+ if (!/^\d+\.\d+$/.test(manifest.minimum_glibc) || !host) throw new Error('This native bundle requires a compatible glibc host');
218
+ const required = parts(manifest.minimum_glibc), actual = parts(host);
219
+ if (actual[0] < required[0] || (actual[0] === required[0] && actual[1] < required[1])) throw new Error(`Native bundle requires glibc >=${manifest.minimum_glibc}`);
220
+ }
221
+ return { manifest, manifest_sha256: hash(entry.data), build_manifest_sha256: manifest.files[manifest.build_manifest].sha256 };
163
222
  }
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));
223
+ function extract(entries, root) {
224
+ ensureDir(root);
225
+ for (const [name, item] of entries) {
226
+ const destination = path.join(root, ...name.split('/'));
227
+ checkPath(destination);
228
+ if (item.type === '5') ensureDir(destination);
229
+ else { ensureDir(path.dirname(destination)); const fd = fs.openSync(destination, 'wx', item.mode); try { fs.writeFileSync(fd, item.data); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } fs.chmodSync(destination, item.mode); }
230
+ }
231
+ syncDir(root);
167
232
  }
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;
233
+ function installedEntries(root) {
234
+ checkPath(root, { missing: false }); const entries = new Map(); let total = 0;
235
+ function visit(directory) {
236
+ for (const name of fs.readdirSync(directory)) {
237
+ const file = path.join(directory, name); checkPath(file, { missing: false });
238
+ const info = fs.lstatSync(file); const relative = path.relative(root, file).split(path.sep).join('/'); safeRelative(relative);
239
+ if (entries.size >= 20000) throw new Error('Installed runtime contains too many files');
240
+ if (info.isDirectory()) { entries.set(relative, { name: relative, type: '5', mode: info.mode & 0o777, data: Buffer.alloc(0) }); visit(file); }
241
+ else if (info.isFile()) { total += info.size; if (total > MAX_EXPANDED) throw new Error('Installed runtime exceeds the expanded size limit'); entries.set(relative, { name: relative, type: '0', mode: info.mode & 0o7777, data: fs.readFileSync(file) }); }
242
+ else throw new Error(`Unsupported installed native file: ${relative}`);
243
+ }
177
244
  }
178
- let runtime;
245
+ visit(root); return entries;
246
+ }
247
+ function verifyInstalled(home) {
248
+ const activePath = path.join(home, 'native', 'active.json'); checkPath(activePath, { missing: false });
249
+ const active = JSON.parse(fs.readFileSync(activePath, 'utf8'));
250
+ if (active.schema !== 'emilyremote.native-installation/1' || active.state !== 'READY' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(active.version || '')) throw new Error('Native installation is not READY with an exact version');
251
+ const target = platformTarget(); requireBuilt(target);
252
+ if (active.target !== target || !/^[a-f0-9]{64}$/.test(active.bundle_sha256 || '')) throw new Error('Native installation identity is invalid');
253
+ if (typeof active.runtime_relative !== 'string' || path.isAbsolute(active.runtime_relative)) throw new Error('Invalid native runtime path');
254
+ const root = path.resolve(home, active.runtime_relative); const base = path.join(path.resolve(home), 'native', 'releases') + path.sep;
255
+ if (!root.startsWith(base) || !root.endsWith(path.sep + 'runtime')) throw new Error('Native runtime path escapes its installation');
256
+ const verified = verifyManifest(installedEntries(root), target, active.version);
257
+ if (verified.manifest_sha256 !== active.manifest_sha256 || verified.build_manifest_sha256 !== active.build_manifest_sha256) throw new Error('Installed native manifest identity changed');
258
+ return { active, root, ...verified };
259
+ }
260
+ function selfCheck(runtime, runner) {
261
+ const result = runner(path.join(runtime, 'emilyremote-runtime'), ['self-check'], { shell: false, encoding: 'utf8', timeout: 60000, maxBuffer: 2 * 1024 * 1024, env: cleanEnv(), cwd: runtime, windowsHide: true });
262
+ if (result.error || result.status !== 0) throw new Error(`Native runtime self-check failed: ${result.error?.message || String(result.stderr || result.stdout || result.signal || result.status).slice(-2000)}`);
263
+ let info; try { info = JSON.parse(result.stdout); } catch { throw new Error('Native self-check did not return JSON'); }
264
+ if (info.ok !== true || info.version !== VERSION) throw new Error('Native self-check version/readiness mismatch');
265
+ return info;
266
+ }
267
+ async function setup(home, options, { runner = spawnSync, log = console.log } = {}) {
268
+ const target = platformTarget(); requireBuilt(target); home = path.resolve(home);
269
+ const selectedBundle = resolveBundle(options, target);
270
+ checkPath(home);
271
+ for (const legacy of ['active.json', 'receipts', 'runtimes']) {
272
+ const legacyPath = path.join(home, legacy); checkPath(legacyPath);
273
+ if (exists(legacyPath)) throw new Error('This directory contains an existing non-native installation; choose a separate EmilyRemoteNative home');
274
+ }
275
+ const base = path.join(home, 'native'); checkPath(base);
276
+ for (const leaf of ['active.json', '.install-lock', 'releases']) checkPath(path.join(base, leaf));
277
+ ensureDir(base); const lock = path.join(base, '.install-lock');
278
+ try { fs.mkdirSync(lock, { mode: 0o700 }); } catch (error) { if (error.code === 'EEXIST') throw new Error(`A native installer is running or was interrupted: inspect ${lock}; no lock was removed`); throw error; }
279
+ const token = crypto.randomBytes(16).toString('hex'); let attempt, activeChanged = false, oldActive;
179
280
  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) }));
281
+ writeJson(path.join(lock, 'owner.json'), { pid: process.pid, token, started_at: new Date().toISOString() });
282
+ const activePath = path.join(base, 'active.json'); oldActive = optionalBytes(activePath);
283
+ if (oldActive !== null) verifyInstalled(home);
284
+ attempt = path.join(base, 'releases', crypto.randomUUID()); ensureDir(attempt);
285
+ const snapshot = snapshotBundle(selectedBundle.bundle, path.join(attempt, 'bundle.tar.gz'), selectedBundle.sha256);
286
+ const entries = parseTar(fs.readFileSync(snapshot.path)); const verified = verifyManifest(entries, target);
287
+ const runtime = path.join(attempt, 'runtime'); extract(entries, runtime);
288
+ const self_check = selfCheck(runtime, runner);
289
+ const reverified = verifyManifest(installedEntries(runtime), target);
290
+ if (reverified.manifest_sha256 !== verified.manifest_sha256) throw new Error('Runtime changed its manifest during self-check');
291
+ const active = { schema: 'emilyremote.native-installation/1', state: 'READY', version: VERSION, target,
292
+ runtime_relative: path.relative(home, runtime), bundle_sha256: snapshot.sha256, bundle_bytes: snapshot.bytes,
293
+ manifest_sha256: verified.manifest_sha256, build_manifest_sha256: verified.build_manifest_sha256,
294
+ installed_at: new Date().toISOString(), self_check,
295
+ trust: options.bundle !== undefined ? 'Operator-supplied archive SHA-256; no publisher signature was verified' : 'Bundled archive SHA-256 pinned in the npm installer; no separate publisher signature was verified',
296
+ bundle_source: options.bundle !== undefined ? 'operator-supplied' : 'npm-bundled', service_started: false, enrolled: false };
297
+ writeJson(path.join(attempt, 'receipt.json'), active);
298
+ activeChanged = true; writeJson(activePath, active);
299
+ log(JSON.stringify({ state: 'READY', version: VERSION, runtime, bundle_sha256: active.bundle_sha256, active: activePath }));
229
300
  return 0;
230
301
  } 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
- }
302
+ if (activeChanged) restore(path.join(base, 'active.json'), oldActive);
303
+ if (attempt) { try { writeJson(path.join(attempt, 'failure.json'), { state: 'FAILED', error: error.message, previous_active_preserved: oldActive !== null, at: new Date().toISOString() }); } catch { /* Preserve original failure. */ } }
240
304
  throw error;
241
305
  } finally {
242
- fs.rmSync(lock, { recursive: true, force: true });
306
+ const ownerPath = path.join(lock, 'owner.json'); checkPath(ownerPath);
307
+ if (exists(ownerPath)) { const owner = JSON.parse(fs.readFileSync(ownerPath)); if (owner.token !== token) throw new Error('Native installer lock ownership changed; not removing it'); fs.unlinkSync(ownerPath); }
308
+ fs.rmdirSync(lock); syncDir(base);
243
309
  }
244
310
  }
245
-
246
- function delegate(runtime, args, spawner = spawn) {
311
+ function parseArgs(argv) {
312
+ const result = { args: [] };
313
+ for (let index = 0; index < argv.length; index++) {
314
+ if (argv[index] === '--') { result.args.push(...argv.slice(index + 1)); break; }
315
+ if (['--home', '--bundle', '--sha256'].includes(argv[index])) {
316
+ const key = argv[index].slice(2); if (!argv[index + 1] || argv[index + 1].startsWith('--') || result[key] !== undefined) throw new Error(`Invalid or duplicate --${key}`);
317
+ result[key] = argv[++index];
318
+ } else result.args.push(argv[index]);
319
+ }
320
+ result.command = result.args.shift();
321
+ if ((result.bundle || result.sha256) && result.command !== 'setup') throw new Error('--bundle and --sha256 are setup-only options');
322
+ return result;
323
+ }
324
+ function delegate(executable, args) {
247
325
  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}`)); });
326
+ const child = spawn(executable, args, { shell: false, stdio: 'inherit', env: cleanEnv(), windowsHide: true });
327
+ const interrupt = () => child.kill('SIGINT'); const terminate = () => child.kill('SIGTERM');
328
+ process.on('SIGINT', interrupt); process.on('SIGTERM', terminate);
329
+ const cleanup = () => { process.off('SIGINT', interrupt); process.off('SIGTERM', terminate); };
330
+ child.once('error', (error) => { cleanup(); reject(error); });
256
331
  child.once('exit', (code, signal) => { cleanup(); resolve(code === null ? signal === 'SIGINT' ? 130 : 143 : code); });
257
332
  });
258
333
  }
259
-
260
334
  async function main(argv = process.argv.slice(2)) {
335
+ if (Number(process.versions.node.split('.')[0]) < 22) throw new Error('This native installer requires Node.js 22 or newer; no runtimes were changed');
261
336
  const parsed = parseArgs(argv);
262
337
  if (!parsed.command || ['--help', '-h', 'help'].includes(parsed.command)) { console.log(HELP); return 0; }
263
338
  if (parsed.command === '--version') { console.log(VERSION); return 0; }
339
+ if (parsed.command === 'platforms') { console.log(JSON.stringify(BUILDS, null, 2)); return 0; }
264
340
  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]);
341
+ if (parsed.command === 'setup') { if (parsed.args.length) throw new Error('Unknown setup arguments; use --help'); return setup(home, parsed); }
342
+ const installed = verifyInstalled(home);
343
+ if (parsed.command === 'status') { console.log(JSON.stringify({ ...installed.active, installer_version: VERSION, runtime: installed.root }, null, 2)); return 0; }
344
+ return delegate(path.join(installed.root, installed.manifest.entrypoint), [parsed.command, ...parsed.args]);
279
345
  }
280
-
281
- module.exports = { main, parseArgs, defaultHome, cleanEnv, checkHomeAncestors, checkHomePath, runChecked, discoverPython, readReceipt,
282
- verifyInstalled, setup, delegate, pythonPath, VERSION, WHEEL_SHA, WHEEL, receiptPath };
346
+ module.exports = { VERSION, BUILDS, BUNDLED, main, parseArgs, platformTarget, resolveBundle, cleanEnv, checkPath, atomicWrite, snapshotBundle, parseTar, verifyManifest, verifyInstalled, setup };
package/package.json CHANGED
@@ -1,13 +1,34 @@
1
1
  {
2
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",
3
+ "version": "0.4.0",
4
+ "description": "RobsonAI EmilyRemote compiled device runtime for Linux x64",
5
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"
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "bin": {
10
+ "emilyremote": "bin/emilyremote-native.cjs",
11
+ "emilyremote-native": "bin/emilyremote-native.cjs"
12
+ },
13
+ "files": [
14
+ "bin/emilyremote-native.cjs",
15
+ "lib/cli.cjs",
16
+ "README.md",
17
+ "RUNTIME_FORMAT.md",
18
+ "vendor/robsonai-emilyremote-native-0.4.0-linux-x64.tar.gz"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test test/*.test.cjs"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "tag": "latest"
26
+ },
27
+ "author": "RobsonAI",
28
+ "os": [
29
+ "linux"
30
+ ],
31
+ "cpu": [
32
+ "x64"
33
+ ]
13
34
  }
package/PROVENANCE.json DELETED
@@ -1,18 +0,0 @@
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
- }
@@ -1,48 +0,0 @@
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))
File without changes