diff --git a/.gitignore b/.gitignore index 6d410ea..0a72038 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,11 @@ # ── Node.js ─────────────────────────────────────────────────────── node_modules/ npm-debug.log* + +# Provisioning/test artifacts (never commit drive credentials) +/.test-results.log +/.drive-test-*/ +/.partition-test-*/ +/.provision-test-*/ +/.verification/ +__pycache__/ diff --git a/README.md b/README.md index 815be3f..fcb3c3c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,41 @@ # Claude Code Portable — One USB. Any Model. +> **Windows “format the disk” prompt: click Cancel.** +> **macOS “disk not readable” prompt: click Ignore.** Never initialize or format +> a partition from these prompts: you could destroy AI-MAC (APFS) or AI-LINUX (ext4). + + +## Partitioned portable AI drive + +This fork now uses the shared **AI-SHARED (exFAT), AI-WIN (NTFS), AI-MAC +(APFS), AI-LINUX (ext4)** layout. Start with [START-HERE.md](START-HERE.md) +for provisioning, verified pinned downloads, first-time subscription login, +launchers, audit commands and platform limitations. The complete contract is +[docs/DRIVE-SPEC.md](docs/DRIVE-SPEC.md). + +Provisioning uses native GPT type GUIDs and sets bit 63 (no default drive letter) +only on AI-MAC/AI-LINUX; no hidden/read-only flags are set. After creating APFS +with diskutil, follow the [post-format verification/reapply steps](START-HERE.md) +before using Windows. Preservation of that GPT attribute by diskutil is not +guaranteed by its manual, and OS warning prompts can still occur. + +**Hardware:** SATA-to-USB 3.0 adapters/enclosures use a +[5 Gbps USB link](https://usb.org/document-library/inter-chip-supplement-usb-revision-30-specification-revision-102) +(actual transfers are slower). Prefer UASP + TRIM-capable chipsets/firmware, +verify support for the host OS, and protect a bare 2.5-inch drive with an +enclosure. Check SSD versus HDD in Optimize Drives or +[CrystalDiskInfo](https://crystalmark.info/en/software/CrystalDiskInfo/); +see [hardware notes](START-HERE.md#hardware-notes) for bridge limitations. + +Claude Code and OpenAI Codex run as official native binaries for x64 and arm64 +on all three OSes. Node is retained only for the existing dashboard and its +SDK/provider adapters. Launch from `AI-SHARED/launch/`; installation and updates +are prep-machine operations under `provision/`. The older single-partition +bootstrap instructions below describe the upstream dashboard and are superseded +by START-HERE for installation and launching this fork. Upstream MIT attribution +is preserved. + + > **Run Claude Code from a USB drive or any folder—no global installation required. Connect to multiple AI providers, including OpenRouter, Google Gemini, NVIDIA NIM, custom APIs, and more.** > > Plug in. Launch. Code. Take it anywhere. diff --git a/RESUME.bat b/RESUME.bat index 2d5861e..3d71cb0 100644 --- a/RESUME.bat +++ b/RESUME.bat @@ -1,2 +1,2 @@ @echo off -call "%~dp0START.bat" resume %* +call "%~dp0START.bat" claude --resume %* diff --git a/START-HERE.md b/START-HERE.md new file mode 100644 index 0000000..d6eb4f0 --- /dev/null +++ b/START-HERE.md @@ -0,0 +1,282 @@ +# Portable AI drive + +> **Protect the drive: if Windows offers to format any partition, click Cancel.** +> **If macOS says the disk is “not readable”, click Ignore.** Do not initialize, +> erase or format it through that prompt; doing so can destroy AI-MAC or AI-LINUX. +> A filesystem unsupported by the current host is not necessarily damaged. + + +This fork of [techjarves/ClaudeCode-Portable](https://github.com/techjarves/ClaudeCode-Portable) +uses official, unmodified Claude Code and OpenAI Codex releases. The upstream +MIT license and attribution are retained in LICENSE. The acceptance contract +is [docs/DRIVE-SPEC.md](docs/DRIVE-SPEC.md). + +## Prepare the drive (prep machine only) + +Back up the device. Use a GPT drive with these labels, in this order: + +| Label | Format | Suggested capacity | Contents | +| --- | --- | --- | --- | +| AI-SHARED | exFAT | 8–16 GB | Launchers, credentials, notes, checksums, audit logs | +| AI-WIN | NTFS | A third of the remainder | Windows x64/arm64 binaries and state | +| AI-MAC | APFS | A third of the remainder | macOS arm64/x64 binaries and state | +| AI-LINUX | ext4 | The remaining space | Linux x64/arm64 binaries and state | + +The helper assigns explicit GPT type GUIDs: + +| Partition | GPT type GUID | Attribute bits set by helper | +| --- | --- | --- | +| 1 AI-SHARED | `EBD0A0A2-B9E5-4433-87C0-68B6B72699C7` (Microsoft basic data) | None | +| 2 AI-WIN | `EBD0A0A2-B9E5-4433-87C0-68B6B72699C7` (Microsoft basic data) | None | +| 3 AI-MAC | `7C3457EF-0000-11AA-AA11-00306543ECAC` (Apple APFS) | 63 | +| 4 AI-LINUX | `0FC63DAF-8483-4772-8E79-3D69D8477DE4` (Linux filesystem data) | 63 | + +Bit 63 is the no-default-drive-letter flag (`0x8000000000000000`). APFS/ext4 +are never marked Microsoft basic data. AI-SHARED and AI-WIN remain eligible for +normal Windows drive letters. No hidden or read-only bits are set. +[Microsoft documents](https://learn.microsoft.com/en-us/windows/win32/api/winioctl/ns-winioctl-partition_information_gpt) +the basic-data type and no-drive-letter behavior for newly seen/moved disks; +this is not a cross-platform promise to suppress all dialogs, nor a way to +remove previously remembered drive-letter assignments. Correct type GUIDs are +the primary distinction for foreign filesystems. Continue to Cancel/Ignore +unexpected prompts; bit 63 is not a macOS prompt-suppression mechanism. + +The Linux helper needs Python 3.12+, util-linux, sgdisk, exfatprogs, ntfs-3g, +parted and e2fsprogs. Inspect devices yourself with `lsblk -o PATH,MODEL,SERIAL,SIZE,MOUNTPOINTS`. +Preview using `sh provision/partition-linux.sh --device /dev/sdX --dry-run`. +To erase a deliberately selected, unmounted, non-system disk, run the same +command explicitly as root without `--dry-run`. It requires a terminal and an +exact typed `MODEL / SERIAL`. Missing, null or whitespace-only model/serial +values are refused cleanly; there is no identity bypass. It refuses mounted disks, swap and unprovable system +disk topology, and rechecks before writing. It never invokes sudo. **Never use a +real device for tests.** Device hot-swapping during partitioning is unsafe. + +Partition 3 remains an unformatted APFS placeholder. On a Mac, inspect +`diskutil list` to identify that drive's third partition, then run exactly: + +```sh +diskutil eraseVolume APFS AI-MAC /dev/diskNs3 +``` + +Replace `diskNs3` only after matching the physical device and partition. Mount +the volumes on a prep machine that can write their native filesystem; do not +attempt to write APFS from Linux. Normal launch requires no admin rights. + +**After the Mac APFS step, verify the partition map again before using Windows.** +The [Apple-authored diskutil manual (Xcode man-page mirror)](https://keith.github.io/xcode-man-pages/diskutil.8.html) +says `eraseVolume` keeps an existing partition while formatting it, and that +`format` controls its partition type. It does **not** promise to preserve GPT +attribute bit 63. Whether a particular macOS version resets that bit has not +been verified here; do not assume preservation or claim a guaranteed reset. + +Return to the Linux prep machine, match the physical disk by model/serial, +unmount its volumes, and inspect `sgdisk --print /dev/sdX` and the four entries: + +```sh +sgdisk --info=1 --info=2 --info=3 --info=4 /dev/sdX +``` + +Only if the same four-partition layout is intact (3 is the APFS physical store, +4 is ext4), explicitly as root reapply the types and flag without formatting: + +```sh +sgdisk --typecode=3:7C3457EF-0000-11AA-AA11-00306543ECAC \ + --typecode=4:0FC63DAF-8483-4772-8E79-3D69D8477DE4 \ + --attributes=3:set:63 --attributes=4:set:63 /dev/sdX +sgdisk --info=1 --info=2 --info=3 --info=4 /dev/sdX +``` + +Substitute the verified whole disk, not a partition or APFS synthesized device. +Stop if partition numbering/layout changed. Expect the GUIDs above, bit 63 set +on 3/4 (normally flags `8000000000000000`) and unset on 1/2 (normally zero). +Investigate unexpected hidden/read-only or other flags; do not blindly clear +them. These metadata commands leave filesystem contents intact when applied to +the correct entries. **Do not rerun the partitioning helper after formatting: +it erases the drive.** The [sgdisk author's manual](https://www.rodsbooks.com/gdisk/sgdisk.html) +confirms full GUIDs for `--typecode`, per-bit `--attributes=N:set:63`, and +`--info` inspection. Check again after later repartitioning/formatting tools. +Physical Mac-to-Linux-to-Windows round trips remain a manual verification item. + +From this repository, provision each OS/architecture on suitable prep hosts: + +```sh +python3 provision/download.py --shared /path/to/AI-SHARED \ + --native /path/to/AI-LINUX --target linux-x64 \ + --node-keyring /path/to/trusted-node-release-keys.gpg +``` + +Repeat for `linux-arm64`, `darwin-x64`, `darwin-arm64`, `win32-x64`, and +`win32-arm64`, using the corresponding native partition. Python accepts Windows +paths too. Prep dependencies: Python 3.12+, GnuPG (`gpg`, `gpgv`), npm, +`cosign` for the published Linux Codex signatures, and `7z` for Portable Git. +Use a trusted Node release keyring prepared following +[Node's release-key instructions](https://github.com/nodejs/release-keys). +Export trusted imported keys using `gpg --export > trusted-node-release-keys.gpg`. +The downloader requires this keyring and fails closed if signature checking fails. + +Pins and HTTPS asset URLs are in `provision/assets.json`: Claude 2.1.247, +Codex 0.161.0, Node 24.21.0, Portable Git 2.56.0.2. Claude archives must match +both pinned SHA256 values and the signed official SHASUMS256.txt. The vendored +Claude key is checked against the official fingerprint. Linux Codex archives +also require their published Sigstore bundle, GitHub workflow identity and OIDC +issuer. Node uses its signed checksum manifest. Codex and Git SHA256 pins come +from the official GitHub release asset digests. Full Codex packages retain +`bin/`, `codex-path/`, `codex-resources/`, symlinks and companion executables. +The Linux CLI builds use musl; the optional dashboard's Node requires glibc. +Portable Git x64 is shared by Windows targets (Windows ARM64 x64 emulation is +required for Git). No vendor executable is patched or installed globally. + +On a Windows prep host, also compile the small process supervisor once: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File provision/windows-supervisor.ps1 -Native W:\ +``` + +`W:\` means the actual AI-WIN drive. It creates `tools/DriveChild.dll` so target +hosts need no compiler or compiler temp files. Then rerun the downloader with +`--checksums-only` for **both** Windows targets to record the DLL. Checksums under +AI-SHARED cover installed files relative to their native partition, and retain +the Claude release checksum manifest and signature. Reprovisioning an existing +architecture requires closing sessions and moving its `bin/-` aside; +the downloader refuses to overwrite a live installation. + +Linux ext4 permissions stay private to the provisioning UID. Before moving to a +host with a different UID, explicitly transfer ownership of `state/` and `tmp/` +on the prep machine (`chown -R TARGET_UID:TARGET_GID /mount/AI-LINUX/state /mount/AI-LINUX/tmp`). +The launcher reports a write/ownership failure instead of silently using host +storage. Avoid world-writable credential directories. exFAT cannot enforce mode +600; shared credentials are readable to anyone who can access the volume. + +## Hardware notes + +A SATA-to-USB 3.0 adapter/enclosure has a **5 Gbps USB link**, not guaranteed +5 Gbps file throughput; the drive, host port and cable may be slower. +[USB-IF documents the USB 3.0 rate](https://usb.org/document-library/inter-chip-supplement-usb-revision-30-specification-revision-102). +Prefer UASP plus explicitly TRIM-capable bridge chipsets/firmware for an SSD; +UASP alone does not prove TRIM pass-through. Confirm support for your enclosure +and host OS, as [adapter vendors note it is product/OS dependent](https://www.startech.com/en-ca/hdd/s2510bpu33). +Use a protective enclosure for a bare 2.5-inch SATA drive. Check whether it is +actually an SSD or HDD using Windows **Defragment and Optimize Drives** (Media +type), or the model/media information in +[CrystalDiskInfo](https://crystalmark.info/en/software/CrystalDiskInfo/). +Some USB bridges limit drive identification; confirm against the drive's label +or manufacturer model specification if the reported type is unavailable. + +## Run and sign in + +Open `AI-SHARED/launch/windows.cmd`, `macos.command` or `linux.sh`. Terminal +invocation preserves the current project directory; double-clicking uses the +working directory selected by the OS. For explicit projects, invoke from that +project's terminal, for example `/mount/AI-SHARED/launch/linux.sh claude`. +Extra arguments are passed through, e.g. `linux.sh codex exec 'explain this repo'`. +The launcher finds AI-SHARED relative to itself and the native partition by label. +Do not attach drives with duplicate labels. Environment overrides apply only to +children, never shell profiles or the registry. Host API keys/base URLs are +removed. HOME, XDG, temp, Claude state and Codex state point to the native volume. +CODEX_HOME and CODEX_SQLITE_HOME are under `state/codex`, never shared exFAT or tmp. + +### WSL2 Mode (Linux in Windows Subsystem for Linux) + +When running inside WSL2 (e.g. Ubuntu on Windows), execute the Linux launcher directly: + +```sh +/mnt/d/launch/linux.sh # replace 'd' with your AI-SHARED drive letter +``` + +The launcher automatically detects the WSL2 environment: +- **Official Linux CLIs**: Uses the official Linux `claude` and `codex` CLIs installed in your WSL environment (e.g. via `npm install -g @anthropic-ai/claude-code`). Windows executables (`.exe` / `.cmd` / `/mnt/*` shims) are strictly ignored. +- **Drive-resident ext4 state**: To guarantee zero host footprint, runtime state, caches, temp files, and Codex SQLite databases are stored in a 4GB sparse ext4 image on the drive (`AI-SHARED/state/wsl-state.ext4`). The image is auto-created on first run and mounted via unprivileged `udisksctl` or loop mount. +- **Shared authoritative credentials**: `claude-oauth-token` and `codex-auth.json` on `AI-SHARED/credentials/` remain authoritative and synchronized across Windows, native Linux, macOS, and WSL2. +- **Clean teardown**: On exit, the loop image is unmounted and any temporary mountpoint removed, leaving zero files on the WSL host. +- **Dashboard**: The Node dashboard is scoped to native Windows (`launch/windows.cmd`); inside WSL2, use the CLI options (Claude Code / Codex / Login / Audit). + +Choose **Login setup → Claude subscription**. The official `claude setup-token` +performs OAuth and prints a token. Paste it at the hidden prompt; the launcher +stores `AI-SHARED/credentials/claude-oauth-token` and passes it as +CLAUDE_CODE_OAUTH_TOKEN on future launches. The launcher never prints or logs +this token. The official setup command itself displays it; avoid terminal +recording during login. No third-party subscription OAuth is implemented. + +Choose **Login setup → Codex device login** for `codex --no-daemon login --device-auth`. +If device login is unavailable, explicitly choose Codex browser login. File +credentials live in `AI-SHARED/credentials/codex-auth.json`. Before each session, +that authoritative file replaces native `auth.json`; a missing shared file clears +stale native auth. On exit, newer local auth is copied back using a staged rename. +An atomic shared `credentials/codex-auth.lock/` directory serializes sessions, +including logins, across hosts/OSes. Never delete a live lock. After a crash, +verify all sessions are stopped before recovering auth or removing the lock. +Auth copy-back failures retain the lock. Eject only after all launchers exit. + +Codex's launcher-owned config.toml sets file credentials, update checks off, +analytics off and feedback off; it is rewritten at each session. Use project +config for other preferences. Codex always receives `--no-daemon`. Claude +updates, telemetry and error reporting are disabled. These are defaults, not a +sandbox: CLI arguments and actions can intentionally change behavior. Normal +filesystem access remains bounded by the host user's rights and CLI permissions. + +The **Dashboard** retains Node solely for its HTTP server, UI dependencies, +provider adapters and Agent SDK. Native CLI launch does not need Node/npm. +Dashboard subscription login remains terminal-only: use the native Claude menu +for your subscription; configure an API provider separately for the dashboard. +Its printed localhost URL can be opened manually. Browser state remains on the +host. Runtime repair belongs on the prep machine, not the target host. + +## Audit and limits + +Take metadata snapshots around a session, writing output to AI-SHARED/logs: + +```sh +sh /mount/AI-SHARED/tools/audit/audit.sh snapshot /mount/AI-SHARED/logs/before.txt +# Run a session, then: +sh /mount/AI-SHARED/tools/audit/audit.sh snapshot /mount/AI-SHARED/logs/after.txt +sh /mount/AI-SHARED/tools/audit/audit.sh diff /mount/AI-SHARED/logs/before.txt /mount/AI-SHARED/logs/after.txt +``` + +On Windows use `tools\audit\audit.ps1 snapshot S:\logs\before.txt` and +`audit.ps1 diff S:\logs\before.txt S:\logs\after.txt` (actual shared drive letter). +The menu's `audit` action also passes these arguments through. Snapshots record +path, size and modification time for home dotfiles, app config/cache/data, +macOS Library, Windows APPDATA/LOCALAPPDATA/TEMP, and system temp. Unreadable +paths are skipped; the audit is a heuristic, not proof of zero host writes. +Windows PowerShell long paths on hosts with long-path support disabled remain +unverified and may be skipped by the provider; do not treat a clean report as +coverage of those paths. +Filenames containing newlines can make the text report ambiguous. On Unix, +root symlinks (including macOS /tmp) are followed, but nested directory symlinks +are not. Snapshot scratch and sort spills stay in a private directory beside the +drive output and are removed on success, failure or a handled signal. Optional +`ROOT ...` arguments after the snapshot output restrict the paths scanned. + +- Gatekeeper/SmartScreen can prompt; managed hosts may block execution entirely. +- Linux `noexec` mounts block binaries. Try `udisksctl mount -b /dev/disk/by-label/AI-LINUX`. + If already mounted noexec, ask the host owner to remount that volume with exec; + the launcher never elevates or changes mount policy itself. +- Login browsers, OS services, shell history, security software and project tools + may leave host traces. Full host access means intentional project writes occur. +- An untrusted host can read all attached credentials. Protect the physical drive, + revoke lost tokens, and avoid untrusted machines. +- Unix supervision terminates the session process group; programs deliberately + escaping it (for example by calling setsid) cannot be contained by a portable + shell. Do not launch detached services from a session. Windows uses a + kill-on-close Job Object; hosts forbidding nested jobs will fail the launch. +- Force removal/power loss can interrupt auth sync. Keep backups, stop sessions, + and use the OS eject action. Upstream CLIs and platform behavior still require + manual smoke tests on each OS/architecture. + +Official references: [Claude release integrity](https://code.claude.com/docs/en/setup#binary-integrity-and-code-signing), +[Claude assets](https://github.com/anthropics/claude-code/releases/tag/v2.1.247), +[Codex packages](https://github.com/openai/codex/releases/tag/rust-v0.161.0), +[Codex configuration](https://learn.chatgpt.com/docs/config-file/config-reference). + +## Development checks + +Run `npm test` (all `tests/*.test.mjs`, including mocked drive tests and ten Python +provisioner test functions), then `npm run check`. Python 3.12+ is needed by the +provisioner tests. Run `rg --files -g '*.sh' -g '*.command' -0 | xargs -0 shellcheck -x` +for every shell entry/helper, including upstream scripts. When PowerShell is +available, `pwsh -NoProfile -Command '& ./tests/drive-windows.ps1'` parses all +PowerShell scripts and checks environment construction, volume discovery and C# +supervisor compilation. The npm wrapper skips this gate when pwsh is absent. +On Windows, additionally smoke-test Job Object cleanup and both architectures. +All automated partition tests use fake devices and `--dry-run`; tests download +no release binaries and perform no real login or partition operation. diff --git a/START.bat b/START.bat index ac03c73..5c29805 100644 --- a/START.bat +++ b/START.bat @@ -1,11 +1,3 @@ @echo off -setlocal -powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\bootstrap.ps1" %* -set "PortableExitCode=%errorlevel%" -if not "%PortableExitCode%"=="0" ( - echo. - echo ClaudeCode-Portable could not finish starting. - echo The error above is important. Please take a screenshot or check data\logs\runtime-install.log. - if not defined PORTABLE_AI_NO_PAUSE pause -) -exit /b %PortableExitCode% +call "%~dp0launch\windows.cmd" %* +exit /b %ERRORLEVEL% diff --git a/docs/DRIVE-SPEC.md b/docs/DRIVE-SPEC.md new file mode 100644 index 0000000..379c7ed --- /dev/null +++ b/docs/DRIVE-SPEC.md @@ -0,0 +1,150 @@ +# Portable AI Drive — Shared Partitioned-Drive Spec (v1) + +Shared contract for portable AI drive: +- `nicklongmore86/ClaudeCode-Portable` (production build) + +## Goals +1. Zero installation on the host: no installers, no global npm/pip, no admin + rights required for normal use, no services/daemons registered. +2. Use the user's own subscriptions via the **official, unmodified CLIs**: + - Claude Pro/Max → official Claude Code native binary. + - ChatGPT Plus/Pro → official OpenAI Codex native package. + (No third-party code may perform Claude subscription OAuth — Anthropic only + permits subscription login in the unmodified Claude Code binary.) +3. Full filesystem access on the host, bounded by the host user's permissions. +4. All app state, credentials, caches and temp files on the drive; host writes + minimized and auditable. + +## Partition layout (GPT) + +| # | Label | FS | Size (suggested) | Purpose | +|---|--------------|-------|------------------|---------| +| 1 | `AI-SHARED` | exFAT | 8–16 GB | Front door. Launchers for every OS, README/START-HERE, `credentials/`, checksums, shared notes. Readable+writable by Windows, macOS, Linux without drivers. | +| 2 | `AI-WIN` | NTFS | 30%+ of rest | Windows binaries (x64 + arm64), Portable Git, Windows state/tmp. | +| 3 | `AI-MAC` | APFS | 30%+ of rest | macOS binaries (arm64 + x64), macOS state/tmp. | +| 4 | `AI-LINUX` | ext4 | 30%+ of rest | Linux binaries (x64 + arm64, musl where available), Linux state/tmp. | + +Rationale: exFAT is the only FS every OS reads/writes natively, but it lacks +symlinks/POSIX perms (Codex `CODEX_HOME` breaks on it). Each OS therefore runs +binaries and keeps runtime state on its native FS; only plain files cross OSes +via `AI-SHARED`. + +Partition 1 is first so it is the volume every OS mounts/shows by default. + +### Per-partition directory layout +``` +AI-SHARED/ + START-HERE.md + launch/ + windows.cmd # double-click entry -> windows.ps1 (ExecutionPolicy Bypass, process scope) + windows.ps1 + macos.command # double-clickable on macOS + linux.sh + lib/ # shared launcher helpers (pure sh / ps1; no runtime deps) + drive.sh + session.sh + omnigent-host.sh + credentials/ + claude-oauth-token # from `claude setup-token`; mode 600 where supported + codex-auth.json # SINGLE authoritative Codex auth cache + omnigent-server-url # stored remote server URL; mode 600 + omnigent-hosts.json # per-machine host identity map; mode 600 + checksums/ # SHA256SUMS for every binary on every partition + tools/ + audit/ # host-write audit tool (see below) + omnigent-host # portable Omnigent host agent relative wrapper + logs/ + +AI-WIN/ AI-MAC/ AI-LINUX/ (same shape on each) + bin/-/ # claude, codex package, python standalone runtime + tools/ # e.g. tools/portable-git/, tools/omnigent-runtime/ + state/ + claude/ # CLAUDE_CONFIG_DIR + codex/ # CODEX_HOME (must NOT be under tmp/) + xdg/{config,cache,data,state}/ + / # e.g. goose/ -> GOOSE_PATH_ROOT + tmp/ # TMPDIR / TEMP / TMP, CLAUDE_CODE_TMPDIR +``` + +## Launcher contract (all OSes) +1. Locate `AI-SHARED` from the launcher's own path — never hardcode drive + letters or mount points. +2. Locate the native partition by **volume label**: + - Windows: `Get-Volume -FileSystemLabel AI-WIN` (no admin needed). + - macOS: `/Volumes/AI-MAC` (fallback: `diskutil info` by label). + - Linux: `/dev/disk/by-label/AI-LINUX`; if not mounted, try + `udisksctl mount -b` (no root on desktops); otherwise print exact + instructions and exit non-zero. Never call `sudo` implicitly. +3. Detect OS + arch, pick `bin/-/`; fail loudly if missing. +4. Set env **for the child process only** (never persist to host profile, + registry, shell rc, or PATH): + - `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_TMPDIR`, `CLAUDE_CODE_OAUTH_TOKEN` + (read from `AI-SHARED/credentials/claude-oauth-token`; never echoed/logged) + - `DISABLE_AUTOUPDATER=1`, `DISABLE_UPDATES=1`, `DISABLE_TELEMETRY=1`, + `DISABLE_ERROR_REPORTING=1` + - Windows: `CLAUDE_CODE_GIT_BASH_PATH` → `AI-WIN/tools/portable-git/bin/bash.exe` + - `CODEX_HOME`, `CODEX_SQLITE_HOME`; Codex `config.toml` contains + `cli_auth_credentials_store = "file"`, `check_for_update_on_startup = false`, + `[analytics] enabled = false`, `[feedback] enabled = false`. + Codex is launched with `--no-daemon`. + - `TMPDIR`/`TEMP`/`TMP`, `XDG_*_HOME` → native partition. + - Unset conflicting inherited creds: `ANTHROPIC_API_KEY`, + `ANTHROPIC_AUTH_TOKEN`, `OPENAI_API_KEY`, `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`. + - Prepend drive `bin/` dirs to the child PATH. +5. Codex auth sync: before launch copy `AI-SHARED/credentials/codex-auth.json` + → `$CODEX_HOME/auth.json`; after the child exits, copy back if newer. + Use a lock file on `AI-SHARED` to prevent two concurrent sessions. +6. Preserve the host working directory (the user's project), and pass through + extra CLI args. +7. Offer a menu: Claude Code / Codex / (build-specific: dashboard or Goose) / + login setup / audit / exit. +8. On exit: ensure no child processes remain so the drive can be ejected. + +## WSL2 Mode (Linux launcher inside WSL) +When `launch/linux.sh` is executed inside WSL2 (auto-detected via `/proc/version` or `WSL_DISTRO_NAME`): +1. **Host CLIs**: Uses the official Linux `claude` and `codex` CLIs installed inside the WSL2 Linux environment (rejecting any Windows `.exe` / `.cmd` / `/mnt/*` shims). +2. **Drive-resident state**: Rather than writing state to the host WSL rootfs, the launcher maintains all runtime state, caches, temp files, and Codex SQLite databases inside a drive-resident ext4 image (`AI-SHARED/state/wsl-state.ext4`). +3. **Mounting**: Automatically mounted via unprivileged `udisksctl` or loop mount (`mount -o loop`) during the session, and cleanly unmounted on exit. +4. **Authoritative credentials**: Authoritative credentials (`claude-oauth-token` and `codex-auth.json`) and cross-platform session locks remain on `AI-SHARED/credentials/`, shared across Windows, macOS, native Linux, and WSL2. +5. **Zero host footprint**: When the session ends and the image is unmounted, zero files remain on the host WSL system. + +## First-time login (on any machine) +- Claude: launcher option runs `claude setup-token`, user pastes the printed + token, launcher writes it to `credentials/claude-oauth-token`. +- Codex: launcher runs `codex login --device-auth` (fallback: normal browser + login) with `CODEX_HOME` on the drive, then copies `auth.json` to + `credentials/codex-auth.json`. + +## Provisioning (prep machine, never the target host) +- `provision/` scripts download pinned release assets, verify SHA256 (and + signatures where published), and lay them out per the tree above. +- Partitioning helper (Linux, `sgdisk` + `mkfs.exfat`/`mkfs.ntfs`/`mkfs.ext4`) + MUST require an explicit `--device /dev/sdX` and an interactive typed + confirmation of the device's model/serial; refuses system disks; supports + `--dry-run`. APFS cannot be created from Linux: script leaves partition 3 + as a placeholder and the doc gives the one `diskutil eraseVolume APFS AI-MAC` + step to run on a Mac. +- ext4 ownership: after provisioning, the Linux state dirs get permissions so + a different UID on another Linux host can still use them (documented + trade-off), or the launcher detects UID mismatch and explains. + +## Host-write audit tool +`tools/audit/` — snapshot (path, size, mtime) of likely host-write locations +(home dir dotfiles, `~/.claude*`, `~/.codex`, `~/.config`, `~/.cache`, +`~/.local`, `%APPDATA%`, `%LOCALAPPDATA%`, `%TEMP%`, `~/Library/...`, system +temp) before and after a session; print a diff report. Pure sh / PowerShell. + +## Omnigent Host Add-on (Linux & macOS) +Self-contained, relocatable Omnigent Host agent (`tools/omnigent-host`) connecting back to a remote Omnigent server: +1. **Standalone Python Runtime**: Uses Astral's standalone Python 3.12 (`bin/-/python`) with bundled terminfo (`share/terminfo`). +2. **Binary-Only Runtime Dependencies**: Pre-installed binary wheels in `tools/omnigent-runtime` with zero target compilation. +3. **Interactive Server URL Prompt**: Prompts for server URL on first launch if not configured; saves securely to `AI-SHARED/credentials/omnigent-server-url`. +4. **Per-Machine Host Identity**: Mints and tracks distinct UUIDs per physical machine in `AI-SHARED/credentials/omnigent-hosts.json`, preventing cross-machine session resumption. +5. **Zero Host Footprint**: All runtime state, logs, and temp files are confined to drive partitions (`state/`, `tmp/`). + +## Known, documented limits +- Gatekeeper / SmartScreen prompts on first run; managed hosts may block. +- Linux `noexec` automounts block execution (document `udisksctl` / remount). +- Browser used for login and the OS itself may leave traces. +- An untrusted host can read credentials while the drive is attached. + diff --git a/launch/lib/DriveChild.cs b/launch/lib/DriveChild.cs new file mode 100644 index 0000000..d8c8393 --- /dev/null +++ b/launch/lib/DriveChild.cs @@ -0,0 +1,61 @@ +// Native process supervision: create suspended, assign a kill-on-close job, then +// resume. Descendants cannot outlive the launcher, including during Ctrl+C. +using System; +using System.Collections; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +public static class DriveChild { + [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] + struct Startup { public int cb; public string reserved, desktop, title; public int x,y,w,h,cx,cy,fill,flags; public short show, reserved2; public IntPtr bytes, stdin, stdout, stderr; } + [StructLayout(LayoutKind.Sequential)] struct ProcessInfo { public IntPtr process, thread; public uint pid, tid; } + [StructLayout(LayoutKind.Sequential)] struct BasicLimit { public long processTime, jobTime; public uint flags; public UIntPtr min,max; public uint active; public UIntPtr affinity; public uint priority,scheduling; } + [StructLayout(LayoutKind.Sequential)] struct Io { public ulong r,w,o,rb,wb,ob; } + [StructLayout(LayoutKind.Sequential)] struct ExtendedLimit { public BasicLimit basic; public Io io; public UIntPtr processMemory,jobMemory,peakProcess,peakJob; } + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] public static extern bool CreateDirectory(string path, IntPtr attributes); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern IntPtr CreateJobObject(IntPtr attributes, string name); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int type, ref ExtendedLimit info, uint length); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool CreateProcess(string app, StringBuilder command, IntPtr pa, IntPtr ta, bool inherit, uint flags, IntPtr env, string cwd, ref Startup startup, out ProcessInfo info); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint WaitForSingleObject(IntPtr handle,uint timeout); + [DllImport("kernel32.dll")] static extern bool GetExitCodeProcess(IntPtr process,out uint code); + [DllImport("kernel32.dll")] static extern bool TerminateProcess(IntPtr process,uint code); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + static void Check(bool ok) { if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error()); } + public static string Quote(string value) { + if (value == null) value = String.Empty; + var b = new StringBuilder("\""); int slashes=0; + foreach (char c in value) { + if(c=='\\') { slashes++; continue; } + if(c=='"') b.Append('\\',slashes*2+1); else b.Append('\\',slashes); + b.Append(c); slashes=0; + } + b.Append('\\',slashes*2); b.Append('"'); return b.ToString(); + } + public static int Run(string app, string[] args, IDictionary env, string cwd) { + var command = new StringBuilder(Quote(app)); + foreach(string arg in args ?? new string[0]) command.Append(" ").Append(Quote(arg)); + var keys = new System.Collections.Generic.List(); foreach(string key in env.Keys) keys.Add(key); + keys.Sort(StringComparer.OrdinalIgnoreCase); + var block = new StringBuilder(); foreach(string key in keys) block.Append(key).Append('=').Append(env[key]).Append('\0'); block.Append('\0'); + IntPtr environment = Marshal.StringToHGlobalUni(block.ToString()); + IntPtr job = IntPtr.Zero; ProcessInfo info = new ProcessInfo(); + try { + job = CreateJobObject(IntPtr.Zero,null); Check(job!=IntPtr.Zero); + var limits = new ExtendedLimit(); limits.basic.flags=0x2000; // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + Check(SetInformationJobObject(job,9,ref limits,(uint)Marshal.SizeOf(limits))); + var startup = new Startup(); startup.cb=Marshal.SizeOf(startup); + Check(CreateProcess(app,command,IntPtr.Zero,IntPtr.Zero,true,0x404,environment,cwd,ref startup,out info)); // suspended + unicode environment + Check(AssignProcessToJobObject(job,info.process)); + Check(ResumeThread(info.thread)!=0xffffffff); + Check(WaitForSingleObject(info.process,0xffffffff)==0); + uint code; Check(GetExitCodeProcess(info.process,out code)); return unchecked((int)code); + } finally { + if(info.process!=IntPtr.Zero) { TerminateProcess(info.process,1); CloseHandle(info.process); } + if(info.thread!=IntPtr.Zero) CloseHandle(info.thread); + if(job!=IntPtr.Zero) CloseHandle(job); + Marshal.FreeHGlobal(environment); + } + } +} diff --git a/launch/lib/drive.sh b/launch/lib/drive.sh new file mode 100644 index 0000000..b292b91 --- /dev/null +++ b/launch/lib/drive.sh @@ -0,0 +1,255 @@ +#!/bin/sh +# shellcheck disable=SC3013 +# -nt is supported by the system shells on both supported Unix platforms. +# Shared POSIX helpers. Call drive_environment only inside a child/subshell. +drive_fail() { printf '%s\n' "$*" >&2; return 1; } + +drive_is_wsl() { + if [ -n "${PORTABLE_AI_WSL:-}" ]; then + case $PORTABLE_AI_WSL in + 1|true|yes) return 0;; + *) return 1;; + esac + fi + [ -n "${WSL_DISTRO_NAME:-}" ] || [ -n "${WSL_INTEROP:-}" ] || grep -qi microsoft /proc/version 2>/dev/null +} + +drive_resolve_wsl_cli() { + drive_target_cli=$1 + drive_resolved_cli= + drive_orig_ifs=$IFS + IFS=: + for drive_dir_entry in $PATH; do + IFS=$drive_orig_ifs + [ -n "$drive_dir_entry" ] || continue + drive_check_path="$drive_dir_entry/$drive_target_cli" + if [ -f "$drive_check_path" ] && [ -x "$drive_check_path" ]; then + case "$drive_check_path" in + *.exe|*.cmd|*.bat|/mnt/*) continue;; + *) + drive_resolved_cli=$drive_check_path + break + ;; + esac + fi + done + IFS=$drive_orig_ifs + if [ -n "$drive_resolved_cli" ]; then + printf '%s\n' "$drive_resolved_cli" + return 0 + fi + return 1 +} + +drive_wsl_discover() { + drive_wsl_shared=$1 + [ -n "$drive_wsl_shared" ] || { drive_fail 'AI-SHARED path required for WSL state discovery.'; return 1; } + + if [ -b /dev/disk/by-label/AI-LINUX ]; then + drive_wsl_native_mount=$(findmnt -rn -S /dev/disk/by-label/AI-LINUX -o TARGET --raw 2>/dev/null) || drive_wsl_native_mount= + if [ -n "$drive_wsl_native_mount" ]; then + printf '%s\n' "$drive_wsl_native_mount" + return 0 + fi + fi + + drive_wsl_img="$drive_wsl_shared/state/wsl-state.ext4" + if [ ! -f "$drive_wsl_img" ]; then + mkdir -p "$drive_wsl_shared/state" || { drive_fail "Cannot create directory $drive_wsl_shared/state."; return 1; } + printf 'Creating drive-resident WSL ext4 state image (wsl-state.ext4)...\n' >&2 + truncate -s 4G "$drive_wsl_img" || { drive_fail "Failed to allocate 4GB sparse image: $drive_wsl_img"; return 1; } + mkfs.ext4 -F -q -L AI-WSL-STATE "$drive_wsl_img" || { rm -f "$drive_wsl_img"; drive_fail "mkfs.ext4 failed on $drive_wsl_img"; return 1; } + fi + + drive_wsl_cur_mount=$(findmnt -rn -S "$drive_wsl_img" -o TARGET --raw 2>/dev/null || findmnt -rn -S /dev/disk/by-label/AI-WSL-STATE -o TARGET --raw 2>/dev/null || :) + if [ -n "$drive_wsl_cur_mount" ]; then + printf '%s\n' "$drive_wsl_cur_mount" + return 0 + fi + + if command -v udisksctl >/dev/null 2>&1; then + drive_wsl_uout=$(udisksctl loop-setup -f "$drive_wsl_img" 2>/dev/null || :) + if [ -n "$drive_wsl_uout" ]; then + drive_wsl_udev=$(printf '%s\n' "$drive_wsl_uout" | sed -n 's/.* as \(\/dev\/[^.]*\)\..*/\1/p') + if [ -n "$drive_wsl_udev" ]; then + drive_wsl_mout=$(udisksctl mount -b "$drive_wsl_udev" 2>/dev/null || :) + if [ -n "$drive_wsl_mout" ]; then + drive_wsl_target_mount=$(printf '%s\n' "$drive_wsl_mout" | sed -e 's/^Mounted [^ ]* at //' -e 's/\.$//') + DRIVE_WSL_LOOP_DEV="$drive_wsl_udev" + DRIVE_WSL_MOUNT_TARGET="$drive_wsl_target_mount" + export DRIVE_WSL_LOOP_DEV DRIVE_WSL_MOUNT_TARGET + printf '%s\n' "$drive_wsl_target_mount" + return 0 + fi + udisksctl loop-delete -b "$drive_wsl_udev" 2>/dev/null || : + fi + fi + fi + + drive_wsl_tmp_mnt="/tmp/ai-drive-wsl-$(id -u)" + mkdir -p "$drive_wsl_tmp_mnt" 2>/dev/null || : + if mount -o loop "$drive_wsl_img" "$drive_wsl_tmp_mnt" 2>/dev/null; then + DRIVE_WSL_MOUNT_TARGET="$drive_wsl_tmp_mnt" + DRIVE_WSL_SUDO_MOUNT=0 + export DRIVE_WSL_MOUNT_TARGET DRIVE_WSL_SUDO_MOUNT + printf '%s\n' "$drive_wsl_tmp_mnt" + return 0 + fi + if sudo -n mount -o loop "$drive_wsl_img" "$drive_wsl_tmp_mnt" 2>/dev/null || sudo mount -o loop "$drive_wsl_img" "$drive_wsl_tmp_mnt" 2>/dev/null; then + DRIVE_WSL_MOUNT_TARGET="$drive_wsl_tmp_mnt" + DRIVE_WSL_SUDO_MOUNT=1 + sudo chown "$(id -u):$(id -g)" "$drive_wsl_tmp_mnt" 2>/dev/null || : + export DRIVE_WSL_MOUNT_TARGET DRIVE_WSL_SUDO_MOUNT + printf '%s\n' "$drive_wsl_tmp_mnt" + return 0 + fi + + rmdir "$drive_wsl_tmp_mnt" 2>/dev/null || : + drive_fail "Could not mount $drive_wsl_img. Mount with: sudo mount -o loop \"$drive_wsl_img\" /mnt/ai-wsl-state" + return 1 +} + +drive_wsl_unmount() { + if [ -n "${DRIVE_WSL_LOOP_DEV:-}" ]; then + if command -v udisksctl >/dev/null 2>&1; then + udisksctl unmount -b "$DRIVE_WSL_LOOP_DEV" 2>/dev/null || : + udisksctl loop-delete -b "$DRIVE_WSL_LOOP_DEV" 2>/dev/null || : + fi + unset DRIVE_WSL_LOOP_DEV DRIVE_WSL_MOUNT_TARGET + elif [ -n "${DRIVE_WSL_MOUNT_TARGET:-}" ]; then + if [ "${DRIVE_WSL_SUDO_MOUNT:-0}" -eq 1 ]; then + sudo umount "$DRIVE_WSL_MOUNT_TARGET" 2>/dev/null || : + else + umount "$DRIVE_WSL_MOUNT_TARGET" 2>/dev/null || : + fi + rmdir "$DRIVE_WSL_MOUNT_TARGET" 2>/dev/null || : + unset DRIVE_WSL_MOUNT_TARGET DRIVE_WSL_SUDO_MOUNT + fi +} + +drive_discover() { + case $1 in + linux) + if drive_is_wsl; then + drive_wsl_discover "${2:-${drive_shared:-}}" + return $? + fi + drive_device=/dev/disk/by-label/AI-LINUX + drive_mount=$(findmnt -rn -S "$drive_device" -o TARGET --raw) || drive_mount= + if [ -z "$drive_mount" ]; then + if command -v udisksctl >/dev/null 2>&1; then + udisksctl mount -b "$drive_device" >&2 || : + drive_mount=$(findmnt -rn -S "$drive_device" -o TARGET --raw) || drive_mount= + fi + fi + [ -n "$drive_mount" ] || { drive_fail 'Mount AI-LINUX with: udisksctl mount -b /dev/disk/by-label/AI-LINUX'; return 1; } + ;; + darwin) + drive_mount=/Volumes/AI-MAC + if [ ! -d "$drive_mount" ]; then + drive_mount=$(diskutil info AI-MAC | sed -n 's/^ *Mount Point: *//p') || return 1 + fi + [ -d "$drive_mount" ] || { drive_fail 'Mount AI-MAC with: diskutil mount AI-MAC'; return 1; } + ;; + *) drive_fail "Unsupported OS: $1"; return 1 ;; + esac + # Multiple matching mounts are ambiguous; never silently select one. + case $drive_mount in *' +'*) drive_fail 'Multiple native volumes found; detach duplicate labels.'; return 1;; esac + # findmnt --raw hex-escapes whitespace and backslashes. Bash's printf + # decodes those bytes without evaluating any shell code. + if [ "$1" = linux ]; then printf '%b\n' "$drive_mount"; else printf '%s\n' "$drive_mount"; fi +} + +drive_arch() { + case $(uname -m) in + x86_64|amd64) printf 'x64\n';; + arm64|aarch64) printf 'arm64\n';; + *) drive_fail 'Unsupported CPU architecture'; return 1;; + esac +} + +drive_environment() { + # $1 shared, $2 native, $3 os-arch. No change to the calling user's shell. + drive_shared=$1 drive_native=$2 drive_bin=$2/bin/$3 + if drive_is_wsl; then + DRIVE_CLAUDE_EXE=$(drive_resolve_wsl_cli claude) || DRIVE_CLAUDE_EXE= + DRIVE_CODEX_EXE=$(drive_resolve_wsl_cli codex) || DRIVE_CODEX_EXE= + export DRIVE_CLAUDE_EXE DRIVE_CODEX_EXE + else + [ -d "$drive_bin" ] || { drive_fail "Missing binaries: $drive_bin; provision this architecture first."; return 1; } + DRIVE_CLAUDE_EXE="$drive_bin/claude" + DRIVE_CODEX_EXE="$drive_bin/codex/bin/codex" + export DRIVE_CLAUDE_EXE DRIVE_CODEX_EXE + fi + umask 077 + for drive_dir in state/claude state/codex state/xdg/config state/xdg/cache state/xdg/data state/xdg/state state/home state/dashboard tmp; do + mkdir -p "$drive_native/$drive_dir" || { drive_fail "Cannot create state on $drive_native. See START-HERE.md."; return 1; } + [ -w "$drive_native/$drive_dir" ] || { drive_fail "Cannot write $drive_native/$drive_dir. See START-HERE.md."; return 1; } + done + export CLAUDE_CONFIG_DIR="$drive_native/state/claude" CLAUDE_CODE_TMPDIR="$drive_native/tmp" + unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN OPENAI_API_KEY ANTHROPIC_BASE_URL OPENAI_BASE_URL CLAUDE_CODE_OAUTH_TOKEN + export DISABLE_AUTOUPDATER=1 DISABLE_UPDATES=1 DISABLE_TELEMETRY=1 DISABLE_ERROR_REPORTING=1 + export CODEX_HOME="$drive_native/state/codex" CODEX_SQLITE_HOME="$drive_native/state/codex" + export TMPDIR="$drive_native/tmp" TEMP="$drive_native/tmp" TMP="$drive_native/tmp" + export XDG_CONFIG_HOME="$drive_native/state/xdg/config" XDG_CACHE_HOME="$drive_native/state/xdg/cache" + export XDG_DATA_HOME="$drive_native/state/xdg/data" XDG_STATE_HOME="$drive_native/state/xdg/state" + export HOME="$drive_native/state/home" + if drive_is_wsl; then + export PORTABLE_AI_CLAUDE_EXECUTABLE="$DRIVE_CLAUDE_EXE" PORTABLE_AI_NO_OPEN=1 + else + export PORTABLE_AI_DATA_DIR="$drive_native/state/dashboard" PORTABLE_AI_RUNTIME_DIR="$drive_native/tools/dashboard-runtime" + export PORTABLE_AI_CLAUDE_EXECUTABLE="$DRIVE_CLAUDE_EXE" PORTABLE_AI_NO_OPEN=1 + export PATH="$drive_bin:$drive_bin/codex/bin:$drive_bin/codex/codex-path:$drive_bin/node/bin:$PATH" + fi + if [ -f "$drive_shared/credentials/claude-oauth-token" ]; then + CLAUDE_CODE_OAUTH_TOKEN=$(tr -d '\r\n' < "$drive_shared/credentials/claude-oauth-token") || return 1 + export CLAUDE_CODE_OAUTH_TOKEN + fi +} + +drive_codex_config() { + # Launcher owns these invariants; other settings can live in project config. + cat > "$CODEX_HOME/config.toml" <<'CONFIG' +cli_auth_credentials_store = "file" +check_for_update_on_startup = false +[analytics] +enabled = false +[feedback] +enabled = false +CONFIG +} + +drive_lock() { + mkdir -p "$drive_shared/credentials" || return 1 + drive_lock_path=$drive_shared/credentials/codex-auth.lock + # Atomic directory creation works on exFAT across the three operating systems. + if ! mkdir "$drive_lock_path" 2>/dev/null; then + drive_fail "Codex is locked: $drive_lock_path. Close the other session. After a crash, verify no session remains on any host before removing this directory." + return 1 + fi + printf '%s\n' "$(hostname) $$" > "$drive_lock_path/owner" +} + +drive_auth_in() { + drive_auth=$drive_shared/credentials/codex-auth.json + if [ -f "$drive_auth" ]; then + cp -p "$drive_auth" "$CODEX_HOME/auth.json" || return 1 + else + # Absence of the authoritative cache must not resurrect stale local auth. + rm -f "$CODEX_HOME/auth.json" || return 1 + fi +} + +drive_auth_out() { + drive_auth=$drive_shared/credentials/codex-auth.json + if [ -f "$CODEX_HOME/auth.json" ] && { [ ! -f "$drive_auth" ] || [ "$CODEX_HOME/auth.json" -nt "$drive_auth" ]; }; then + cp -p "$CODEX_HOME/auth.json" "$drive_lock_path/auth.next" && mv -f "$drive_lock_path/auth.next" "$drive_auth" || return 1 + chmod 600 "$drive_auth" 2>/dev/null || : + fi +} + +drive_unlock() { + rm -f "$drive_lock_path/owner" + rmdir "$drive_lock_path" +} diff --git a/launch/lib/omnigent-host.sh b/launch/lib/omnigent-host.sh new file mode 100644 index 0000000..cb0687e --- /dev/null +++ b/launch/lib/omnigent-host.sh @@ -0,0 +1,154 @@ +#!/bin/sh +# shellcheck disable=SC3013 +# Omnigent Host agent helper functions for ClaudeCode-Portable. +# Zero-host-footprint: all state, caches, temp files, and credentials remain on the drive. + +drive_omnigent_server_url() { + drive_shared=$1 + shift + drive_server_url="${OMNIGENT_SERVER_URL:-}" + drive_server_file="$drive_shared/credentials/omnigent-server-url" + + # 1. Command-line argument override + drive_skip_next=0 + for drive_arg in "$@"; do + if [ "$drive_skip_next" -eq 1 ]; then + drive_server_url="$drive_arg" + drive_skip_next=0 + continue + fi + case "$drive_arg" in + --server=*) + drive_server_url="${drive_arg#--server=}" + ;; + --server) + drive_skip_next=1 + ;; + http://*|https://*|ws://*|wss://*) + drive_server_url="$drive_arg" + ;; + esac + done + + # 2. Stored credential file on the drive + if [ -z "$drive_server_url" ] && [ -f "$drive_server_file" ]; then + drive_server_url=$(tr -d '\r\n' < "$drive_server_file" | xargs) + fi + + # 3. Interactive prompt if unset + if [ -z "$drive_server_url" ]; then + printf 'Enter Omnigent Server URL (e.g. https://omnigent.example.com): ' >&2 + if IFS= read -r drive_input_url; then + drive_input_url=$(printf '%s' "$drive_input_url" | tr -d '\r\n' | xargs) + case "$drive_input_url" in + http://*|https://*|ws://*|wss://*) + drive_server_url="$drive_input_url" + mkdir -p "$drive_shared/credentials" + printf '%s\n' "$drive_server_url" > "$drive_server_file.next" + chmod 600 "$drive_server_file.next" 2>/dev/null || : + mv -f "$drive_server_file.next" "$drive_server_file" + ;; + *) + drive_fail "Invalid server URL: '$drive_input_url'. Must start with http://, https://, ws://, or wss://" + return 1 + ;; + esac + else + drive_fail 'No Omnigent server URL configured. Set OMNIGENT_SERVER_URL or specify --server .' + return 1 + fi + fi + + printf '%s\n' "$drive_server_url" +} + +drive_omnigent_machine_key() { + drive_os=$1 + drive_raw= + if [ "$drive_os" = "linux" ] && [ -r /etc/machine-id ]; then + drive_raw=$(tr -d '\r\n ' < /etc/machine-id) + elif [ "$drive_os" = "darwin" ]; then + drive_raw=$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | awk -F'"' '/IOPlatformUUID/{print $4}' || :) + fi + if [ -z "$drive_raw" ]; then + drive_raw=$(uname -n 2>/dev/null || hostname 2>/dev/null || echo "host-unknown") + fi + drive_key=$(printf '%s' "$drive_raw" | sha256sum 2>/dev/null | awk '{print $1}' || :) + if [ -z "$drive_key" ]; then + drive_key=$(printf '%s' "$drive_raw" | shasum -a 256 2>/dev/null | awk '{print $1}' || :) + fi + if [ -z "$drive_key" ]; then + drive_key="$drive_raw" + fi + printf '%s\n' "$drive_key" +} + +drive_omnigent_host_identity() { + drive_shared=$1 + drive_python=$2 + drive_os=$3 + drive_mkey=$(drive_omnigent_machine_key "$drive_os") + drive_hname=$(hostname 2>/dev/null || uname -n 2>/dev/null || echo "portable-host") + drive_hdisplay="portable-${drive_hname}" + + drive_hosts_json="$drive_shared/credentials/omnigent-hosts.json" + mkdir -p "$drive_shared/credentials" + + drive_id_script=' +import json, uuid, sys, os +from pathlib import Path + +hosts_file = Path(sys.argv[1]) +machine_key = sys.argv[2] +display_name = sys.argv[3] + +data = {} +if hosts_file.is_file(): + try: + data = json.loads(hosts_file.read_text()) + except Exception: + data = {} + +if machine_key not in data or not isinstance(data.get(machine_key), dict) or "host_id" not in data[machine_key]: + data[machine_key] = { + "host_id": uuid.uuid4().hex, + "host_name": display_name, + } + hosts_file.parent.mkdir(parents=True, exist_ok=True) + tmp = hosts_file.with_name(hosts_file.name + ".tmp") + tmp.write_text(json.dumps(data, indent=2)) + os.replace(tmp, hosts_file) + +entry = data[machine_key] +hid = entry["host_id"] +hname = entry["host_name"] +print(f"OMNIGENT_HOST_ID={hid}") +print(f"OMNIGENT_HOST_NAME={hname}") +' + drive_id_output=$("$drive_python/bin/python3" -c "$drive_id_script" "$drive_hosts_json" "$drive_mkey" "$drive_hdisplay") || return 1 + eval "$drive_id_output" + export OMNIGENT_HOST_ID OMNIGENT_HOST_NAME +} + +drive_omnigent_environment() { + drive_native=$1 + drive_target=$2 + drive_python="$drive_native/bin/$drive_target/python" + drive_runtime="$drive_native/tools/omnigent-runtime" + + [ -x "$drive_python/bin/python3" ] || { drive_fail "Portable Python runtime not found at $drive_python. Provision this architecture first."; return 1; } + + export PYTHONHOME="$drive_python" + export PYTHONNOUSERSITE=1 + export PYTHONPATH="$drive_runtime:$drive_runtime/site-packages${PYTHONPATH:+:$PYTHONPATH}" + export PATH="$drive_python/bin:$drive_runtime/bin:$PATH" + + drive_terminfo="$drive_python/share/terminfo" + export TERMINFO="$drive_terminfo" + export TERMINFO_DIRS="$drive_terminfo:/usr/share/terminfo:/lib/terminfo:/etc/terminfo" + + if [ -f "$drive_runtime/certifi/cacert.pem" ]; then + export SSL_CERT_FILE="$drive_runtime/certifi/cacert.pem" + export REQUESTS_CA_BUNDLE="$drive_runtime/certifi/cacert.pem" + fi +} diff --git a/launch/lib/session.sh b/launch/lib/session.sh new file mode 100644 index 0000000..46051e3 --- /dev/null +++ b/launch/lib/session.sh @@ -0,0 +1,195 @@ +#!/bin/bash +# shellcheck disable=SC2317,SC2154 +# Traps call cleanup functions; drive.sh supplies the shared variables. +# Bash job control provides a separate process group on Linux and macOS. +# Foregrounding preserves interactive terminal input; cleanup covers descendants +# remaining in the group after the CLI exits, including dashboard children. +drive_cleanup() { + if [ -n "${drive_group_file:-}" ] && [ -f "$drive_group_file" ]; then + drive_child=$(cat "$drive_group_file") + case $drive_child in + ''|*[!0-9]*) ;; + *) + kill -TERM -- "-$drive_child" 2>/dev/null || : + drive_poll=0 + while kill -0 -- "-$drive_child" 2>/dev/null && [ "$drive_poll" -lt 20 ]; do + sleep 0.1 + drive_poll=$((drive_poll + 1)) + done + if kill -0 -- "-$drive_child" 2>/dev/null; then + kill -KILL -- "-$drive_child" 2>/dev/null || : + fi + ;; + esac + if [ -n "${drive_supervisor:-}" ]; then + kill -TERM "$drive_supervisor" 2>/dev/null || : + wait "$drive_supervisor" 2>/dev/null || : + fi + rm -f "$drive_group_file" + drive_group_file= + drive_supervisor= + fi +} + +drive_codex_end() { + if [ "${drive_locked:-0}" -eq 1 ]; then + if [ "${drive_auth_ready:-0}" -eq 1 ] && ! drive_auth_out; then + drive_locked=0 + printf 'Auth sync failed; lock retained for recovery: %s\n' "$drive_lock_path" >&2 + return 1 + fi + drive_locked=0 + drive_unlock || return 1 + fi +} + +drive_finish() { + drive_exit_status=$? + trap - EXIT INT TERM HUP + drive_cleanup + drive_codex_end || drive_exit_status=1 + drive_wsl_unmount + exit "$drive_exit_status" +} + +drive_traps() { + trap drive_finish EXIT + trap 'exit 130' INT + trap 'exit 143' TERM HUP +} + +drive_run() { + drive_traps + drive_group_file=$(mktemp "${TMPDIR:?}/drive-session.XXXXXX") || return 1 + # The outer shell waits interruptibly; fg itself defers signal traps. The + # inner shell handles terminal foreground ownership for the CLI's group. + ( + trap - EXIT INT TERM HUP + set -m + "$@" <&0 & + printf '%s\n' "$!" > "$drive_group_file" + fg %+ >/dev/null + ) <&0 & + drive_supervisor=$! + wait "$drive_supervisor" + drive_run_status=$? + drive_cleanup + return "$drive_run_status" +} + +drive_codex() { + drive_lock || return 1 + drive_locked=1 + drive_auth_ready=0 + drive_traps + if ! drive_auth_in || ! drive_codex_config; then + drive_codex_end + return 1 + fi + drive_auth_ready=1 + drive_run "${DRIVE_CODEX_EXE:-$drive_bin/codex/bin/codex}" --no-daemon "$@" + drive_codex_status=$? + drive_codex_end || return 1 + return "$drive_codex_status" +} + +drive_login() { + printf 'Login: 1 Claude subscription 2 Codex device login 3 Codex browser login\n' + IFS= read -r drive_choice || return 1 + case $drive_choice in + 1) + unset CLAUDE_CODE_OAUTH_TOKEN + if drive_is_wsl && [ -z "${DRIVE_CLAUDE_EXE:-}" ]; then + drive_fail "Linux 'claude' CLI not found in WSL. Install Claude Code inside WSL Ubuntu first." + return 1 + fi + drive_run "${DRIVE_CLAUDE_EXE:-$drive_bin/claude}" setup-token || return 1 + printf 'Paste the token (hidden): ' >&2 + IFS= read -r -s drive_token || return 1 + printf '\n' >&2 + drive_token=$(printf '%s' "$drive_token" | tr -d '\r\n') || return 1 + [ -n "$drive_token" ] || { drive_fail 'Empty token; nothing saved.'; return 1; } + mkdir -p "$drive_shared/credentials" || return 1 + printf '%s' "$drive_token" > "$drive_shared/credentials/claude-oauth-token.next" || return 1 + unset drive_token + chmod 600 "$drive_shared/credentials/claude-oauth-token.next" 2>/dev/null || : + mv -f "$drive_shared/credentials/claude-oauth-token.next" "$drive_shared/credentials/claude-oauth-token" + ;; + 2) + if drive_is_wsl && [ -z "${DRIVE_CODEX_EXE:-}" ]; then + drive_fail "Linux 'codex' CLI not found in WSL. Install Codex inside WSL Ubuntu first." + return 1 + fi + drive_codex login --device-auth + ;; + 3) + if drive_is_wsl && [ -z "${DRIVE_CODEX_EXE:-}" ]; then + drive_fail "Linux 'codex' CLI not found in WSL. Install Codex inside WSL Ubuntu first." + return 1 + fi + drive_codex login + ;; + *) drive_fail 'Unknown login option';; + esac +} + +drive_main() { + set +x # Never trace credentials, including when called with bash -x. + drive_os=$1 drive_entry=$2 + shift 2 + drive_shared=$(CDPATH='' cd -- "$(dirname -- "$drive_entry")/.." && pwd -P) || exit 1 + drive_native=$(drive_discover "$drive_os" "$drive_shared") || exit 1 + drive_target=$drive_os-$(drive_arch) || exit 1 + drive_host_home=$HOME + drive_environment "$drive_shared" "$drive_native" "$drive_target" || exit 1 + drive_action=${1:-menu} + [ "$#" -eq 0 ] || shift + while :; do + if [ "$drive_action" = menu ]; then + printf '\n1 Claude Code\n2 Codex\n3 Dashboard\n4 Login setup\n5 Audit\n6 Exit\nChoose: ' + IFS= read -r drive_selected || exit 0 + else drive_selected=$drive_action; fi + case $drive_selected in + 1|claude) + if drive_is_wsl && [ -z "${DRIVE_CLAUDE_EXE:-}" ]; then + drive_fail "Linux 'claude' CLI not found. Install Claude Code inside WSL Ubuntu (e.g. 'npm install -g @anthropic-ai/claude-code'). Note: Windows .exe/.cmd shims are not used in WSL mode." + else + drive_run "${DRIVE_CLAUDE_EXE:-$drive_bin/claude}" "$@" + fi + ;; + 2|codex) + if drive_is_wsl && [ -z "${DRIVE_CODEX_EXE:-}" ]; then + drive_fail "Linux 'codex' CLI not found. Install Codex inside WSL Ubuntu (e.g. 'npm install -g @openai/codex'). Note: Windows .exe/.cmd shims are not used in WSL mode." + else + drive_codex "$@" + fi + ;; + 3|dashboard) + if drive_is_wsl; then + drive_fail "Dashboard is not supported in WSL mode. Use launch/windows.cmd for the dashboard on Windows." + else + drive_run "$drive_bin/node/bin/node" "$drive_native/tools/dashboard/tools/launcher.mjs" dashboard "$@" + fi + ;; + 4|login) drive_login;; + 5|audit) + if [ "$drive_action" = menu ]; then + printf 'Audit: 1 Before snapshot 2 After snapshot 3 Diff\n' + IFS= read -r drive_audit_choice || return 1 + case $drive_audit_choice in + 1) HOME=$drive_host_home sh "$drive_shared/tools/audit/audit.sh" snapshot "$drive_shared/logs/before.txt";; + 2) HOME=$drive_host_home sh "$drive_shared/tools/audit/audit.sh" snapshot "$drive_shared/logs/after.txt";; + 3) HOME=$drive_host_home sh "$drive_shared/tools/audit/audit.sh" diff "$drive_shared/logs/before.txt" "$drive_shared/logs/after.txt";; + *) drive_fail 'Unknown audit option';; + esac + else HOME=$drive_host_home sh "$drive_shared/tools/audit/audit.sh" "$@"; fi + ;; + 6|exit) exit 0;; + *) drive_fail 'Choose claude, codex, dashboard, login, audit or exit';; + esac + drive_result=$? + [ "$drive_action" = menu ] || exit "$drive_result" + # Refresh a token saved during this menu session. + drive_environment "$drive_shared" "$drive_native" "$drive_target" || exit 1 + done +} diff --git a/launch/lib/windows.ps1 b/launch/lib/windows.ps1 new file mode 100644 index 0000000..2a0e7b2 --- /dev/null +++ b/launch/lib/windows.ps1 @@ -0,0 +1,92 @@ +# All environment overrides are returned as data, then supplied to CreateProcess. +function Get-DriveEnvironment($Shared, $Native, $Target) { + $bin = Join-Path $Native "bin\$Target" + if (!(Test-Path -LiteralPath $bin -PathType Container)) { throw "Missing binaries: $bin" } + $childEnv = @{} + [Environment]::GetEnvironmentVariables().GetEnumerator() | ForEach-Object { $childEnv[$_.Key] = $_.Value } + foreach ($key in @('ANTHROPIC_API_KEY','ANTHROPIC_AUTH_TOKEN','OPENAI_API_KEY','ANTHROPIC_BASE_URL','OPENAI_BASE_URL','CLAUDE_CODE_OAUTH_TOKEN')) { $childEnv.Remove($key) } + $paths = @{ + CLAUDE_CONFIG_DIR='state\claude'; CLAUDE_CODE_TMPDIR='tmp' + CODEX_HOME='state\codex'; CODEX_SQLITE_HOME='state\codex' + TMPDIR='tmp'; TEMP='tmp'; TMP='tmp' + XDG_CONFIG_HOME='state\xdg\config'; XDG_CACHE_HOME='state\xdg\cache' + XDG_DATA_HOME='state\xdg\data'; XDG_STATE_HOME='state\xdg\state' + HOME='state\home'; USERPROFILE='state\home' + APPDATA='state\appdata'; LOCALAPPDATA='state\localappdata' + PORTABLE_AI_DATA_DIR='state\dashboard' + } + foreach ($key in $paths.Keys) { + $path = Join-Path $Native $paths[$key] + [IO.Directory]::CreateDirectory($path) | Out-Null + $childEnv[$key] = $path + } + foreach ($key in @('DISABLE_AUTOUPDATER','DISABLE_UPDATES','DISABLE_TELEMETRY','DISABLE_ERROR_REPORTING','PORTABLE_AI_NO_OPEN')) { $childEnv[$key] = '1' } + $childEnv.CLAUDE_CODE_GIT_BASH_PATH = Join-Path $Native 'tools\portable-git\bin\bash.exe' + $childEnv.PORTABLE_AI_CLAUDE_EXECUTABLE = Join-Path $bin 'claude.exe' + $childEnv.PORTABLE_AI_RUNTIME_DIR = Join-Path $Native 'tools\dashboard-runtime' + $childEnv.PATH = "$bin;$bin\codex\bin;$bin\codex\codex-path;$bin\node;$Native\tools\portable-git\cmd;" + $childEnv.PATH + $token = Join-Path $Shared 'credentials\claude-oauth-token' + if (Test-Path -LiteralPath $token) { $childEnv.CLAUDE_CODE_OAUTH_TOKEN = [IO.File]::ReadAllText($token).Replace("`r", '').Replace("`n", '').Trim() } + return $childEnv +} + +function Find-DriveNative { + $volumes = @(Get-Volume -FileSystemLabel AI-WIN -ErrorAction Stop | Where-Object { $_.DriveLetter }) + if ($volumes.Count -ne 1) { throw 'Mount exactly one AI-WIN volume with a drive letter using Disk Management.' } + if ($volumes[0].FileSystem -ne 'NTFS') { throw 'AI-WIN must use NTFS.' } + return "$($volumes[0].DriveLetter):\" +} + +function Enter-DriveLock($Shared) { + $credentials = Join-Path $Shared 'credentials' + [IO.Directory]::CreateDirectory($credentials) | Out-Null + $lock = Join-Path $credentials 'codex-auth.lock' + # CreateDirectory succeeds if already present: use Win32's atomic result. + if (![DriveChild]::CreateDirectory($lock, [IntPtr]::Zero)) { + throw "Codex is locked: $lock. After a crash, check all hosts for active sessions before removing it." + } + [IO.File]::WriteAllText((Join-Path $lock 'owner'), "$env:COMPUTERNAME $PID") + return $lock +} + +function Invoke-DriveCodex($Shared, $Bin, $ChildEnv, [string[]]$CliArgs) { + $lock = Enter-DriveLock $Shared + $authoritative = Join-Path $Shared 'credentials\codex-auth.json' + $local = Join-Path $ChildEnv.CODEX_HOME 'auth.json' + $ready = $false + $synced = $true + try { + if (Test-Path -LiteralPath $authoritative) { Copy-Item -LiteralPath $authoritative -Destination $local -Force } + elseif (Test-Path -LiteralPath $local) { Remove-Item -LiteralPath $local -Force } + $config = @' +cli_auth_credentials_store = "file" +check_for_update_on_startup = false +[analytics] +enabled = false +[feedback] +enabled = false +'@ + [IO.File]::WriteAllText((Join-Path $ChildEnv.CODEX_HOME 'config.toml'), $config) + $ready = $true + return Invoke-DriveChild (Join-Path $Bin 'codex\bin\codex.exe') (@('--no-daemon') + $CliArgs) $ChildEnv + } finally { + if ($ready -and (Test-Path -LiteralPath $local)) { + try { + if (!(Test-Path -LiteralPath $authoritative) -or (Get-Item -LiteralPath $local).LastWriteTimeUtc -gt (Get-Item -LiteralPath $authoritative).LastWriteTimeUtc) { + $next = Join-Path $lock 'auth.next' + Copy-Item -LiteralPath $local -Destination $next -Force + Move-Item -LiteralPath $next -Destination $authoritative -Force + } + } catch { $synced = $false; throw "Auth sync failed; lock retained at $lock for recovery." } + } + if ($synced) { + Remove-Item -LiteralPath (Join-Path $lock 'owner') -Force + Remove-Item -LiteralPath $lock -Force + } + } +} + +function Invoke-DriveChild($Executable, [string[]]$CliArgs, $ChildEnv) { + if (!(Test-Path -LiteralPath $Executable -PathType Leaf)) { throw "Missing executable: $Executable; provision this architecture first." } + return [DriveChild]::Run($Executable, $CliArgs, $ChildEnv, (Get-Location).ProviderPath) +} diff --git a/launch/linux.sh b/launch/linux.sh new file mode 100755 index 0000000..72fee15 --- /dev/null +++ b/launch/linux.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set +x +launch_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) || exit 1 +# shellcheck source=launch/lib/drive.sh +. "$launch_dir/lib/drive.sh" +# shellcheck source=launch/lib/session.sh +. "$launch_dir/lib/session.sh" +drive_main linux "$0" "$@" diff --git a/launch/macos.command b/launch/macos.command new file mode 100755 index 0000000..b7f5bdf --- /dev/null +++ b/launch/macos.command @@ -0,0 +1,8 @@ +#!/bin/bash +set +x +launch_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) || exit 1 +# shellcheck source=launch/lib/drive.sh +. "$launch_dir/lib/drive.sh" +# shellcheck source=launch/lib/session.sh +. "$launch_dir/lib/session.sh" +drive_main darwin "$0" "$@" diff --git a/launch/windows.cmd b/launch/windows.cmd new file mode 100644 index 0000000..bbfdcea --- /dev/null +++ b/launch/windows.cmd @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0windows.ps1" %* +exit /b %ERRORLEVEL% diff --git a/launch/windows.ps1 b/launch/windows.ps1 new file mode 100644 index 0000000..6c26568 --- /dev/null +++ b/launch/windows.ps1 @@ -0,0 +1,74 @@ +param([Parameter(Position=0)][string]$Action='menu', [Parameter(ValueFromRemainingArguments=$true)][string[]]$CliArgs) +$ErrorActionPreference = 'Stop' +Set-PSDebug -Off +. (Join-Path $PSScriptRoot 'lib\windows.ps1') +$shared = Split-Path -Parent $PSScriptRoot +$native = Find-DriveNative +$architecture = $env:PROCESSOR_ARCHITEW6432 +if (!$architecture) { $architecture = $env:PROCESSOR_ARCHITECTURE } +$arch = switch ($architecture) { 'AMD64' {'x64'} 'ARM64' {'arm64'} default {throw "Unsupported CPU: $architecture"} } +$bin = Join-Path $native "bin\win32-$arch" +$childEnv = Get-DriveEnvironment $shared $native "win32-$arch" +# Provisioning compiles this small supervisor DLL on a Windows prep machine. +# Loading it avoids Add-Type compiler temp writes on target hosts (PowerShell 5). +Add-Type -Path (Join-Path $native 'tools\DriveChild.dll') +$result = 0 +while ($true) { + $selected = $Action + if ($Action -eq 'menu') { + Write-Host "`n1 Claude Code`n2 Codex`n3 Dashboard`n4 Login setup`n5 Audit`n6 Exit" + $selected = Read-Host 'Choose' + } + try { + switch ($selected) { + {$_ -in '1','claude'} { + if (!(Test-Path -LiteralPath $childEnv.CLAUDE_CODE_GIT_BASH_PATH)) { throw 'Portable Git is missing; complete provisioning first.' } + $result = Invoke-DriveChild (Join-Path $bin 'claude.exe') $CliArgs $childEnv + } + {$_ -in '2','codex'} { $result = Invoke-DriveCodex $shared $bin $childEnv $CliArgs } + {$_ -in '3','dashboard'} { $result = Invoke-DriveChild (Join-Path $bin 'node\node.exe') (@((Join-Path $native 'tools\dashboard\tools\launcher.mjs'),'dashboard') + $CliArgs) $childEnv } + {$_ -in '4','login'} { + $login = Read-Host 'Login: 1 Claude subscription, 2 Codex device, 3 Codex browser' + switch ($login) { + '1' { + $loginEnv = $childEnv.Clone(); $loginEnv.Remove('CLAUDE_CODE_OAUTH_TOKEN') + $result = Invoke-DriveChild (Join-Path $bin 'claude.exe') @('setup-token') $loginEnv + if ($result -ne 0) { throw 'Claude setup-token failed; no token saved.' } + $secret = Read-Host 'Paste token (hidden)' -AsSecureString + $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secret) + try { + $token = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr).Replace("`r", '').Replace("`n", '').Trim() + if (!$token) { throw 'Empty token; nothing saved.' } + $credentials = Join-Path $shared 'credentials' + [IO.Directory]::CreateDirectory($credentials) | Out-Null + $next = Join-Path $credentials 'claude-oauth-token.next' + [IO.File]::WriteAllText($next, $token) + Move-Item -LiteralPath $next -Destination (Join-Path $credentials 'claude-oauth-token') -Force + } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr); $token=$null; $secret.Dispose() } + } + '2' { $result = Invoke-DriveCodex $shared $bin $childEnv @('login','--device-auth') } + '3' { $result = Invoke-DriveCodex $shared $bin $childEnv @('login') } + default { throw 'Unknown login option' } + } + } + {$_ -in '5','audit'} { + $auditArgs = $CliArgs + if ($Action -eq 'menu') { + $logs = Join-Path $shared 'logs' + [IO.Directory]::CreateDirectory($logs) | Out-Null + switch (Read-Host 'Audit: 1 Before snapshot, 2 After snapshot, 3 Diff') { + '1' { $auditArgs = @('snapshot', (Join-Path $logs 'before.txt')) } + '2' { $auditArgs = @('snapshot', (Join-Path $logs 'after.txt')) } + '3' { $auditArgs = @('diff', (Join-Path $logs 'before.txt'), (Join-Path $logs 'after.txt')) } + default { throw 'Unknown audit option' } + } + } + & (Join-Path $shared 'tools\audit\audit.ps1') @auditArgs + } + {$_ -in '6','exit'} { exit 0 } + default { throw 'Choose claude, codex, dashboard, login, audit or exit' } + } + } catch { Write-Error $_ -ErrorAction Continue; $result = 1 } + if ($Action -ne 'menu') { exit $result } + $childEnv = Get-DriveEnvironment $shared $native "win32-$arch" +} diff --git a/lib/paths.mjs b/lib/paths.mjs index 84462d3..d842f88 100644 --- a/lib/paths.mjs +++ b/lib/paths.mjs @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; export const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); export const DATA = process.env.PORTABLE_AI_DATA_DIR || join(ROOT, 'data'); export const PLATFORM = `${process.platform}-${process.arch}`; -export const RUNTIME = join(ROOT, 'engine', PLATFORM, 'current'); +export const RUNTIME = process.env.PORTABLE_AI_RUNTIME_DIR || join(ROOT, 'engine', PLATFORM, 'current'); export const CONFIG = join(DATA, 'settings.json'); export const LOGS = join(DATA, 'logs'); export const CHAT_WORKSPACE = join(DATA, 'conversations'); diff --git a/lib/providers.mjs b/lib/providers.mjs index 872cd34..512397f 100644 --- a/lib/providers.mjs +++ b/lib/providers.mjs @@ -29,6 +29,10 @@ export function providerEnvironment(profile, { adapter, dashboard = false, paren const env = { ...parent }; // Do not allow ambient credentials or old OpenClaude switches to choose a different backend. for (const k of Object.keys(env)) if (/^(ANTHROPIC_|CLAUDE_CODE_|CLAUDE_CONFIG_DIR$|OPENAI_|GEMINI_|GOOGLE_API_KEY$|OPENROUTER_|DEEPSEEK_|NVIDIA_|AWS_|AZURE_)/.test(k)) delete env[k]; + // Retain native-drive paths after clearing ambient provider credentials. + for (const key of ['CLAUDE_CODE_TMPDIR', 'CLAUDE_CODE_GIT_BASH_PATH']) { + if (parent[key]) env[key] = parent[key]; + } const directory = join(DATA, 'claude', `${profile.provider}-${profile.auth}`); mkdirSync(directory, { recursive: true, mode: 0o700 }); Object.assign(env, { CLAUDE_CONFIG_DIR: directory, XDG_CACHE_HOME: join(DATA, 'cache'), DISABLE_AUTOUPDATER: '1', DISABLE_UPDATES: '1', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', DISABLE_TELEMETRY: '1', DISABLE_ERROR_REPORTING: '1' }); diff --git a/lib/runtime.mjs b/lib/runtime.mjs index 8b0a8d0..8b29aec 100644 --- a/lib/runtime.mjs +++ b/lib/runtime.mjs @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, renameSync, rmSync, realpathSync, readdirSync, copyFileSync, statSync, chmodSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, renameSync, rmSync, realpathSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { spawn } from 'node:child_process'; import { createRequire } from 'node:module'; @@ -6,52 +6,10 @@ import { pathToFileURL } from 'node:url'; import { ROOT, DATA, PLATFORM, RUNTIME, LOGS } from './paths.mjs'; export const manifest = JSON.parse(readFileSync(join(ROOT, 'tools/runtime-manifest.json'), 'utf8')); -// The published @anthropic-ai/claude-code wrapper ships bin/claude.exe as a -// ~500-byte stub until its postinstall copies the ~250MB platform-native -// binary over it. The postinstall prefers a filesystem hardlink, which -// removable drives (FAT32/exFAT pen drives) do not support, so a Windows USB -// install can silently be left with the stub. These helpers detect that state -// and repair it with a plain copy, which works on every filesystem. -const STUB_MARKER = 'claude native binary not installed'; -function nativeBinaryName() { - return process.platform === 'win32' ? 'claude.exe' : 'claude'; -} -export function isStubExecutable(executable) { - try { - if (statSync(executable).size >= 4096) return false; - return readFileSync(executable, 'utf8').includes(STUB_MARKER); - } catch { - return false; - } -} -export function nativeBinarySource(directory = RUNTIME) { - const candidate = join(directory, `node_modules/@anthropic-ai/claude-code-${PLATFORM}`, nativeBinaryName()); - return existsSync(candidate) ? candidate : null; -} -function wrapperExecutableAt(directory = RUNTIME) { - const pkgPath = join(directory, 'node_modules/@anthropic-ai/claude-code/package.json'); - if (!existsSync(pkgPath)) return null; - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); - const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.claude; - if (!bin) return null; - const result = join(dirname(pkgPath), bin); - return existsSync(result) ? result : null; -} -export function repairNativeBinary(directory = RUNTIME) { - const executable = wrapperExecutableAt(directory); - if (!executable || !isStubExecutable(executable)) return false; - const source = nativeBinarySource(directory); - if (!source) return false; - copyFileSync(source, executable); - if (process.platform !== 'win32') chmodSync(executable, 0o755); - return !isStubExecutable(executable); -} -export function executableAt(directory = RUNTIME) { - // Prefer the platform package itself. The generic wrapper is always named - // claude.exe, including on macOS/Linux, and the Agent SDK may consequently - // try to run a native Mach-O/ELF binary through Node instead of spawning it. - // Using the platform-native path also avoids link/copy quirks on FAT/exFAT. - return nativeBinarySource(directory) || wrapperExecutableAt(directory); +// Claude is provisioned as a verified native release, independently of Node. +export function executableAt() { + const executable = process.env.PORTABLE_AI_CLAUDE_EXECUTABLE; + return executable && existsSync(executable) ? executable : null; } export function run(command, args, options = {}) { return new Promise((resolve, reject) => { @@ -67,20 +25,21 @@ function npmCLI() { const candidates = [join(base, 'node_modules/npm/bin/npm-cli.js'), join(base, '../lib/node_modules/npm/bin/npm-cli.js')]; try { candidates.push(realpathSync(join(base, 'npm'))); } catch {} const found = candidates.find(existsSync); - if (!found) throw new Error('Bundled npm is missing. Run start.sh or START.bat to repair Node.js.'); + if (!found) throw new Error('Bundled npm is missing. Reprovision the dashboard Node distribution on the prep machine.'); return found; } export async function runtimeStatus() { const executable = executableAt(); - const stub = !!executable && isStubExecutable(executable); + const stub = false; let version = null; if (executable && !stub) try { version = await run(executable, ['--version'], { timeout: 15000 }); } catch {} - return { installed: !!version, version, platform: PLATFORM, node: process.version, pinned: manifest.dependencies['@anthropic-ai/claude-code'], sdk: manifest.dependencies['@anthropic-ai/claude-agent-sdk'], executable, stub }; + return { installed: !!version, version, platform: PLATFORM, node: process.version, pinned: '2.1.247', sdk: manifest.dependencies['@anthropic-ai/claude-agent-sdk'], executable, stub }; } export function stagingComplete(directory) { return Object.keys(manifest.dependencies).every(dep => existsSync(join(directory, 'node_modules', dep, 'package.json'))); } export async function installRuntime({ onOutput = () => {}, target = RUNTIME, runner = run } = {}) { + if (target === RUNTIME && process.env.PORTABLE_AI_RUNTIME_DIR) throw new Error('Use provision/download.py on the prep machine to repair the pinned drive runtime.'); const base = dirname(target); const staging = join(base, 'staging'); const backup = join(base, 'previous'); const lock = join(base, 'install.lock'); mkdirSync(base, { recursive: true }); mkdirSync(LOGS, { recursive: true }); mkdirSync(join(DATA, 'npm-cache'), { recursive: true }); try { mkdirSync(lock); } catch { throw new Error('An installation is already running. If it was interrupted, remove engine//install.lock after checking no installer is active.'); } @@ -90,31 +49,21 @@ export async function installRuntime({ onOutput = () => {}, target = RUNTIME, ru mkdirSync(staging, { recursive: true }); writeFileSync(join(staging, 'package.json'), JSON.stringify(manifest, null, 2)); appendFileSync(log, `\n=== Runtime installation ${new Date().toISOString()} ===\n`, { mode: 0o600 }); - onOutput('Installing pinned official Claude Code and dashboard dependencies...\n'); + onOutput('Installing pinned dashboard dependencies (Claude is provisioned separately)...\n'); if (resuming) onOutput('Resuming the previous incomplete installation; verified files will be reused.\n'); - onOutput('This step downloads ~300MB and can take several minutes on USB or network drives. Please wait.\n'); + onOutput('Dashboard dependencies can take several minutes to prepare. Please wait.\n'); const started = Date.now(); const heartbeat = setInterval(() => { onOutput(`Still installing... ${Math.round((Date.now() - started) / 1000)}s elapsed. Do not close this window.\n`); }, 20000); if (typeof heartbeat.unref === 'function') heartbeat.unref(); try { - await runner(process.execPath, [npmCLI(), 'install', '--prefix', staging, '--include=optional', '--no-audit', '--no-fund', '--save=false', '--no-bin-links', '--no-install-links', '--cache', join(DATA, 'npm-cache')], { cwd: staging, env: { ...process.env, npm_config_cache: join(DATA, 'npm-cache') }, onOutput: s => { appendFileSync(log, s); onOutput(s); } }); + await runner(process.execPath, [npmCLI(), 'install', '--prefix', staging, '--ignore-scripts', '--omit=optional', '--no-audit', '--no-fund', '--save=false', '--no-bin-links', '--no-install-links', '--cache', join(DATA, 'npm-cache')], { cwd: staging, env: { ...process.env, npm_config_cache: join(DATA, 'npm-cache') }, onOutput: s => { appendFileSync(log, s); onOutput(s); } }); } finally { clearInterval(heartbeat); } - const wrapper = wrapperExecutableAt(staging); - let exe = executableAt(staging); - if (!exe) throw new Error('The official executable was not installed; existing runtime was preserved'); - if (wrapper && isStubExecutable(wrapper)) { - onOutput('Native binary is still a placeholder (common on FAT32/exFAT pen drives, which block hardlinks). Copying it into place - the ~250MB file can take a few minutes on USB 2.0...\n'); - if (!repairNativeBinary(staging)) throw new Error('The native Claude binary is missing its platform package. Check disk space, then run the repair option again.'); - exe = executableAt(staging); - onOutput('Native binary copy complete.\n'); - } - if (!stagingComplete(staging)) throw new Error('Runtime dependencies are incomplete; existing runtime was preserved'); - const version = await runner(exe, ['--version'], { timeout: 15000 }); - if (!version.includes('Claude Code')) throw new Error('Runtime identity check failed'); + if (!stagingComplete(staging)) throw new Error('Dashboard dependencies are incomplete; existing runtime was preserved'); + const version = 'Pinned dashboard dependencies installed'; rmSync(backup, { recursive: true, force: true }); if (existsSync(target)) renameSync(target, backup); try { renameSync(staging, target); } catch (e) { if (existsSync(backup)) renameSync(backup, target); throw e; } @@ -126,12 +75,10 @@ export async function installRuntime({ onOutput = () => {}, target = RUNTIME, ru throw new Error(`${error.message}\nIncomplete installation files were preserved for the next attempt. Details: ${log}`, { cause:error }); } finally { rmSync(lock, { recursive: true, force: true }); } } -export async function rollbackRuntime({target=RUNTIME,runner=run}={}) { +export async function rollbackRuntime({target=RUNTIME}={}) { const base = dirname(target), backup = join(base, 'previous'), swap = join(base, 'rollback-swap'); if (existsSync(join(base, 'install.lock'))) throw new Error('Installation is in progress'); - const exe = executableAt(backup); - if (!exe) throw new Error('No previous installation is available'); - await runner(exe, ['--version'], { timeout: 15000 }); + if (!stagingComplete(backup)) throw new Error('No complete previous dashboard installation is available'); renameSync(target, swap); try { renameSync(backup, target); } catch (e) { renameSync(swap, target); throw e; } renameSync(swap, backup); diff --git a/provision/assets.json b/provision/assets.json new file mode 100644 index 0000000..a486615 --- /dev/null +++ b/provision/assets.json @@ -0,0 +1,114 @@ +{ + "claude_version": "2.1.247", + "codex_version": "rust-v0.161.0", + "node_version": "24.21.0", + "python_version": "3.12.15+20261003", + "claude_sums": "https://github.com/anthropics/claude-code/releases/download/v2.1.247/SHASUMS256.txt", + "claude_signer": "31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE", + "targets": { + "win32-x64": { + "claude": { + "url": "https://github.com/anthropics/claude-code/releases/download/v2.1.247/claude-win32-x64.zip", + "sha256": "fc4f2ff5bc4af613f92174a2b9051a52a9c2c557ab45796a248d6811044ef808" + }, + "codex": { + "url": "https://github.com/openai/codex/releases/download/rust-v0.161.0/codex-package-x86_64-pc-windows-msvc.tar.gz", + "sha256": "f3093b2483f857ae9085e87e1cc0d22488f65d2374889e4466c23a4f3d374c2d" + }, + "node": { + "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip", + "sha256": "158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541" + } + }, + "win32-arm64": { + "claude": { + "url": "https://github.com/anthropics/claude-code/releases/download/v2.1.247/claude-win32-arm64.zip", + "sha256": "7e7080ee8c6c44850d2d898a51da5e2cc6e211b9c34e7238660eb5108bdd18a1" + }, + "codex": { + "url": "https://github.com/openai/codex/releases/download/rust-v0.161.0/codex-package-aarch64-pc-windows-msvc.tar.gz", + "sha256": "9c84eebb38e59166f3db2fb53f9aa7633593f8defec045aa86b467e4f4291b05" + }, + "node": { + "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-arm64.zip", + "sha256": "8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921" + } + }, + "darwin-x64": { + "claude": { + "url": "https://github.com/anthropics/claude-code/releases/download/v2.1.247/claude-darwin-x64.tar.gz", + "sha256": "b51cf8ffdde4bd3f9ab3a4b9b1f1176ca3120c66a7a5fbccce6e5c88f0d9a049" + }, + "codex": { + "url": "https://github.com/openai/codex/releases/download/rust-v0.161.0/codex-package-x86_64-apple-darwin.tar.gz", + "sha256": "53f7c9081ab83684a3d4fb35dbc4b05d3ebb204beb2eab000626c15f0b15e738" + }, + "node": { + "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz", + "sha256": "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097" + }, + "python": { + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20261003/cpython-3.12.15%2B20261003-x86_64-apple-darwin-install_only.tar.gz", + "sha256": "a8fd7a91852f19b6d959793ef41fad048631ccb2a334a9ecdf573255298f7978" + } + }, + "darwin-arm64": { + "claude": { + "url": "https://github.com/anthropics/claude-code/releases/download/v2.1.247/claude-darwin-arm64.tar.gz", + "sha256": "77709dc499a57c3106831162fed02f7c520897bb061cfd1b4d06110f8ab871ce" + }, + "codex": { + "url": "https://github.com/openai/codex/releases/download/rust-v0.161.0/codex-package-aarch64-apple-darwin.tar.gz", + "sha256": "f0feee8537daf8dd6b4e0a36e764549ad14afdbb419d8775aeab9feb20182313" + }, + "node": { + "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz", + "sha256": "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057" + }, + "python": { + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20261003/cpython-3.12.15%2B20261003-aarch64-apple-darwin-install_only.tar.gz", + "sha256": "316a463172740e71d8dca1f2730784e325f3f720941137b5d674d5801a632213" + } + }, + "linux-x64": { + "claude": { + "url": "https://github.com/anthropics/claude-code/releases/download/v2.1.247/claude-linux-x64-musl.tar.gz", + "sha256": "c7d74dd6bc9dc6911ea0ac877c48839ee31a9a432c98ddc33403ad4d3a48a845" + }, + "codex": { + "url": "https://github.com/openai/codex/releases/download/rust-v0.161.0/codex-package-x86_64-unknown-linux-musl.tar.gz", + "sha256": "04d8ab9dbcb9df0edf3c67dca5072a374babfdf762a9bc4ae649ae140b8e2cf0" + }, + "node": { + "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz", + "sha256": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff" + }, + "python": { + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20261003/cpython-3.12.15%2B20261003-x86_64-unknown-linux-gnu-install_only.tar.gz", + "sha256": "f937814031eab4698ca6d07ec606ede1825768f3f3e99af76d9db3900bee03c5" + } + }, + "linux-arm64": { + "claude": { + "url": "https://github.com/anthropics/claude-code/releases/download/v2.1.247/claude-linux-arm64-musl.tar.gz", + "sha256": "cf0f31058173cbfa962a1aba1ac31d49975c87458b1514e258bc55c017fe7efe" + }, + "codex": { + "url": "https://github.com/openai/codex/releases/download/rust-v0.161.0/codex-package-aarch64-unknown-linux-musl.tar.gz", + "sha256": "3c02e2ae34be0d06e62557e98fc5c0a783bec5a2fed406fe00e565803bf84ee8" + }, + "node": { + "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz", + "sha256": "724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5" + }, + "python": { + "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20261003/cpython-3.12.15%2B20261003-aarch64-unknown-linux-gnu-install_only.tar.gz", + "sha256": "95c01982c9fcb9d95b0acfdb5eb8a6e0099dd11edf062314a474228d93f2b765" + } + } + }, + "git": { + "url": "https://github.com/git-for-windows/git/releases/download/v2.56.0.windows.2/PortableGit-2.56.0.2-64-bit.7z.exe", + "sha256": "075e158ef8e1f0ab80b347e245405d3eca735c2dc88fd8e032e137d0ca61f61b" + } +} diff --git a/provision/download.py b/provision/download.py new file mode 100644 index 0000000..2f62871 --- /dev/null +++ b/provision/download.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Prep-machine downloader. No installers are executed on target hosts. +Python 3.12+, GnuPG, cosign (Linux Codex signatures), 7z (Windows Git), npm +(dashboard dependencies), and a trusted Node release keyring are prep tools. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile +import urllib.request +import zipfile + +ROOT = Path(__file__).resolve().parent.parent +MANIFEST = json.loads((ROOT / 'provision/assets.json').read_text()) + + +def sha256(path): + digest = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def download(url, path, expected=None): + if not url.startswith('https://'): + raise ValueError('Only HTTPS release URLs are permitted') + if not path.exists() or not expected or sha256(path) != expected: + partial = path.with_name(path.name + '.partial') + with urllib.request.urlopen(url) as response, partial.open('wb') as stream: + shutil.copyfileobj(response, stream) + if expected and sha256(partial) != expected: + partial.unlink() + raise ValueError(f'SHA256 mismatch: {path.name}') + partial.replace(path) + return path + + +def checksums(path): + return {line.split()[1].lstrip('*'): line.split()[0] for line in path.read_text().splitlines() if line.strip()} + + +def extract(archive, destination): + destination.mkdir(parents=True, exist_ok=True) + if archive.name.endswith('.zip'): + with zipfile.ZipFile(archive) as package: + for item in package.infolist(): + path = (destination / item.filename).resolve() + if not path.is_relative_to(destination.resolve()): + raise ValueError('Unsafe ZIP path') + package.extractall(destination) + else: + with tarfile.open(archive, 'r:gz') as package: + # Python's data filter rejects absolute/escaping paths and links/devices. + package.extractall(destination, filter='data') + + +def normalize(directory, executable): + if (directory / executable).is_file(): + return + children = list(directory.iterdir()) + if len(children) == 1 and children[0].is_dir() and (children[0] / executable).is_file(): + inner = children[0] + for item in inner.iterdir(): + item.rename(directory / item.name) + inner.rmdir() + if not (directory / executable).is_file(): + raise ValueError(f'Archive missing {executable}') + + +def record_files(native, shared, target): + rows = [] + for base in [native / 'bin' / target, native / 'tools']: + for path in sorted(base.rglob('*')): + if path.is_file(): + rows.append(f'{sha256(path)} {path.relative_to(native).as_posix()}\n') + (shared / 'checksums' / f'{target}-SHA256SUMS').write_text(''.join(rows)) + + +def find_npm_cli(node, npm): + npm_path = Path(npm).resolve() + node_dir = Path(node).resolve().parent + candidates = ([npm_path] if npm_path.suffix == '.js' else []) + [ + node_dir / 'node_modules/npm/bin/npm-cli.js', + node_dir / '../lib/node_modules/npm/bin/npm-cli.js', + Path('/usr/share/nodejs/npm/bin/npm-cli.js'), + Path('/usr/lib/node_modules/npm/bin/npm-cli.js'), + ] + for candidate in candidates: + if candidate.is_file(): + return candidate + raise ValueError('Cannot locate prep-machine npm-cli.js') + + +def find_pip_cli(python, pip=None): + if pip and Path(pip).resolve().is_file(): + return Path(pip).resolve() + python_path = Path(python).resolve() + python_dir = python_path.parent + candidates = ([Path(pip).resolve()] if pip else []) + [ + python_dir / 'pip', + python_dir / 'pip3', + python_dir / 'pip3.12', + Path('/usr/bin/pip3'), + Path('/usr/local/bin/pip3'), + ] + for candidate in candidates: + if candidate.is_file(): + return candidate + raise ValueError('Cannot locate prep-machine pip CLI') + + +def prepare(args): + shared, native = args.shared.resolve(), args.native.resolve() + if shared == native or shared in native.parents or native in shared.parents: + raise ValueError('Shared and native roots must be separate partitions/directories') + target = args.target + osname = target.split('-')[0] + for directory in ['credentials', 'checksums', 'logs']: + (shared / directory).mkdir(parents=True, exist_ok=True) + for directory in ['bin', 'tools', 'state/claude', 'state/codex', 'state/xdg/config', + 'state/xdg/cache', 'state/xdg/data', 'state/xdg/state', 'tmp']: + (native / directory).mkdir(parents=True, exist_ok=True) + # Keep cache and verification keyrings on the drive, including prep temp files. + cache = native / 'tools/download-cache' + cache.mkdir(parents=True, exist_ok=True) + os.environ['TMPDIR'] = str(native / 'tmp') + tempfile.tempdir = str(native / 'tmp') + sums = download(MANIFEST['claude_sums'], cache / 'SHASUMS256.txt') + sig = download(MANIFEST['claude_sums'] + '.sig', cache / 'SHASUMS256.txt.sig') + keyhome = cache / 'gnupg' + keyhome.mkdir(mode=0o700, exist_ok=True) + gpg = ['gpg', '--batch', '--homedir', str(keyhome)] + key = ROOT / 'provision/keys/claude-code.asc' + listing = subprocess.check_output(gpg + ['--with-colons', '--show-keys', str(key)], text=True) + if MANIFEST['claude_signer'] not in listing: + raise ValueError('Claude signing-key fingerprint mismatch') + subprocess.run(gpg + ['--import', str(key)], check=True) + status = subprocess.check_output(gpg + ['--status-fd', '1', '--verify', str(sig), str(sums)], text=True) + if f'[GNUPG:] VALIDSIG {MANIFEST["claude_signer"]} ' not in status: + raise ValueError('Unexpected Claude manifest signer') + assets = MANIFEST['targets'][target] + if checksums(sums).get(Path(assets['claude']['url']).name) != assets['claude']['sha256']: + raise ValueError('Signed Claude checksum differs from pinned checksum') + bindir = native / 'bin' / target + if bindir.exists(): + raise ValueError(f'{bindir} already exists; close all sessions and move it aside before reprovisioning') + stage = Path(tempfile.mkdtemp(prefix='provision-', dir=native / 'tmp')) + try: + tools_to_fetch = ['claude', 'codex', 'node'] + if 'python' in assets: + tools_to_fetch.append('python') + for tool in tools_to_fetch: + asset = assets[tool] + archive = download(asset['url'], cache / Path(asset['url']).name, asset['sha256']) + if tool == 'codex' and osname == 'linux': + bundle = download(asset['url'] + '.sigstore', cache / (archive.name + '.sigstore')) + subprocess.run(['cosign', 'verify-blob', '--bundle', str(bundle), + '--certificate-identity', 'https://github.com/openai/codex/.github/workflows/rust-release.yml@refs/tags/' + MANIFEST['codex_version'], + '--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com', str(archive)], check=True) + if tool == 'node': + base = f'https://nodejs.org/dist/v{MANIFEST["node_version"]}/' + ns = download(base + 'SHASUMS256.txt', cache / 'node-SHASUMS256.txt') + signature = download(base + 'SHASUMS256.txt.sig', cache / 'node-SHASUMS256.txt.sig') + subprocess.run(['gpgv', '--keyring', str(args.node_keyring.resolve()), str(signature), str(ns)], check=True) + if checksums(ns).get(archive.name) != asset['sha256']: + raise ValueError('Signed Node checksum differs from pin') + destination = stage if tool == 'claude' else stage / tool + extract(archive, destination) + executable = ('claude.exe' if osname == 'win32' else 'claude') if tool == 'claude' else ('bin/codex.exe' if osname == 'win32' else 'bin/codex') if tool == 'codex' else ('bin/python3' if tool == 'python' else ('node.exe' if osname == 'win32' else 'bin/node')) + normalize(destination, executable) + if osname != 'win32': + (destination / executable).chmod(0o755) + if tool == 'python': + terminfo_dir = destination / 'share/terminfo' + if not terminfo_dir.is_dir(): + raise ValueError('Python standalone archive missing share/terminfo') + stage.rename(bindir) + finally: + if stage.exists(): + shutil.rmtree(stage) + if osname == 'win32': + git = MANIFEST['git'] + archive = download(git['url'], cache / Path(git['url']).name, git['sha256']) + subprocess.run(['7z', 'x', '-y', '-o' + str(native / 'tools/portable-git'), str(archive)], check=True) + if not (native / 'tools/portable-git/bin/bash.exe').is_file(): + raise ValueError('Portable Git archive missing Bash') + for folder in ['launch', 'tools/audit']: + shutil.copytree(ROOT / folder, shared / folder, dirs_exist_ok=True) + (shared / 'docs').mkdir(exist_ok=True) + shutil.copy2(ROOT / 'docs/DRIVE-SPEC.md', shared / 'docs/DRIVE-SPEC.md') + for name in ['START-HERE.md', 'README.md', 'LICENSE']: + shutil.copy2(ROOT / name, shared / name) + shutil.copy2(ROOT / 'provision/assets.json', shared / 'checksums/assets.json') + shutil.copy2(sums, shared / 'checksums/claude-SHASUMS256.txt') + shutil.copy2(sig, shared / 'checksums/claude-SHASUMS256.txt.sig') + dashboard = native / 'tools/dashboard' + for folder in ['lib', 'tools', 'dashboard']: + shutil.copytree(ROOT / folder, dashboard / folder, dirs_exist_ok=True, + ignore=shutil.ignore_patterns('download-cache', '__pycache__')) + shutil.copy2(ROOT / 'package.json', dashboard / 'package.json') + deps = native / 'tools/dashboard-runtime' + deps.mkdir(exist_ok=True) + shutil.copy2(ROOT / 'tools/runtime-manifest.json', deps / 'package.json') + # Invoke npm's JS entry directly: Windows npm.cmd is not an executable + # accepted by CreateProcess, and shell interpolation would misquote paths. + node = shutil.which('node') + npm = shutil.which('npm') + if not node or not npm: + raise ValueError('Prep-machine Node/npm are required for dashboard dependencies') + npm_cli = find_npm_cli(node, npm) + subprocess.run([node, str(npm_cli), 'install', '--prefix', str(deps), '--ignore-scripts', '--omit=optional', + '--no-audit', '--no-fund', '--cache', str(native / 'state/npm-cache')], check=True, + env={**os.environ, 'npm_config_update_notifier': 'false'}) + if 'python' in assets: + omnigent_runtime = native / 'tools/omnigent-runtime' + omnigent_runtime.mkdir(parents=True, exist_ok=True) + reqs_file = ROOT / 'tools/omnigent-runtime-requirements.txt' + if reqs_file.is_file(): + shutil.copy2(reqs_file, omnigent_runtime / 'requirements.txt') + py = shutil.which('python3') or shutil.which('python') + if not py: + raise ValueError('Prep-machine Python 3.12+ is required for Omnigent runtime dependencies') + pip_override = getattr(args, 'pip', None) + pip_cli = find_pip_cli(py, pip_override) + subprocess.run([py, str(pip_cli), 'install', '--target', str(omnigent_runtime), + '--only-binary', ':all:', '--no-warn-script-location', + '--cache-dir', str(native / 'state/pip-cache'), + '-r', str(omnigent_runtime / 'requirements.txt')], check=True) + if (ROOT / 'tools/omnigent-host').is_file(): + (shared / 'tools').mkdir(exist_ok=True) + shutil.copy2(ROOT / 'tools/omnigent-host', shared / 'tools/omnigent-host') + (shared / 'tools/omnigent-host').chmod(0o755) + (native / 'tools').mkdir(exist_ok=True) + shutil.copy2(ROOT / 'tools/omnigent-host', native / 'tools/omnigent-host') + (native / 'tools/omnigent-host').chmod(0o755) + record_files(native, shared, target) + if osname == 'win32': + print('On a Windows prep machine run provision/windows-supervisor.ps1 -Native , then refresh checksums with --checksums-only.') + if osname == 'linux': + print('Linux state ownership is private to the provisioning UID. Transfer state/ and tmp/ ownership to the target UID before use; see START-HERE.md.') + print(f'Prepared {target} in {native}; shared launchers in {shared}') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--shared', type=Path, required=True) + parser.add_argument('--native', type=Path, required=True) + parser.add_argument('--target', choices=MANIFEST['targets'], required=True) + parser.add_argument('--node-keyring', type=Path) + parser.add_argument('--pip', type=Path) + parser.add_argument('--checksums-only', action='store_true') + args = parser.parse_args() + if args.checksums_only: + record_files(args.native, args.shared, args.target) + else: + if not args.node_keyring or not args.node_keyring.is_file(): + parser.error('--node-keyring must be a trusted Node release GPG keyring (see START-HERE.md)') + prepare(args) + + +if __name__ == '__main__': + main() diff --git a/provision/keys/claude-code.asc b/provision/keys/claude-code.asc new file mode 100644 index 0000000..69a4aa5 --- /dev/null +++ b/provision/keys/claude-code.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGnK73ABEACnbytJXkjweYrwIr0aLEFRlH+C0nF44KxFc7gQmJ6PjSPMGZAD +dxZcaixU7zZl8WxEpVO0wLmIH8cf2zGOdyuZg1Yaugk1vHb2b8WBhAGCQJdPgB8W +XquedepEYtk56uP/gCoTjJDUZluEGBHnlnuujSJ4orxEdhSykEoAUfJZGEILPpMd +bphFt/Sn+Eb/TxM5jpKPdwnv8AShNF/1mZU1fWTQq9tRKJUakZj04gdaDFElQXak +CtTij+GT6yoYCARSHwGO+PC/Pr6q4tc+D7LRjxSBvUWDoFSmlqb/PJ1hj9D/7I2O +e4XXniAPWMR56KvxHlzOzrNQdJujbJdSkCwh1ZijkSd3y8ayW5WYUTGdRab99NUw +agzlabe/VVF6kzJ0Scn5q3PihB2Y9Bwo0CKnkYk7a7KT77EWv0Kkq+VHmOtqX3a2 +hhX+b6a6ve9rzJ1qZYGj+obv/C3Sx1LzUjAfqVy7RJDf2uAoP5t2g8u/TkSpUxhM +VEjZBkSxYZhMyzQM6t8IgkUfnSrIPTHixbDWARZ4beMOBjxyPZK1nP7OOrNR3TkK +JtwLMQAabURCDnL0PjS0iwBTU4jtumBD1XSULyWuoTvMljrpQr1nV1oDyOt0OLqa +KA2McWtd9PdXhC8y2EIg7TmrTlJLfHYbdmkiCYj4J49Q8HWkN/6WE+RTUwARAQAB +tD5BbnRocm9waWMgQ2xhdWRlIENvZGUgUmVsZWFzZSBTaWduaW5nIDxzZWN1cml0 +eUBhbnRocm9waWMuY29tPokCUQQTAQoAOxYhBDHd3iTd+rZ59C170rqpKf8afsrO +BQJpyu9wAhsPBQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJELqpKf8afsrO +l5IP/2I8X1dFy5xYczWB/coIxGjuzS/V6ByZGZZEJsbr04pmuHiFUykJqPGWGQ6q +U0YF5iEwvEkaagS5m7DzhSEf3FM3Cgafax/6d70tar9Vr1D+w6uPfxetu7u/WYJp +aolIsdh5fTrBh9zSM1Njl8FM8wG8CwZQjS33Oa7d8cwRkgdUWbt6LXgz+cTQNuBn +BgW6Ks7oZFI25dfu0ojDR+aDFJg4+4wZoyDLPvJz1SIrJ5WFGs67zsx9SfS3yZnf +XKmBe+f0dUy+GJ2nFZrXFf99+c0dPEHYO8DCeAHZizjkFrdYtUHdDU0YDYEGkLJa +bE+pgcpkHf5EvsZzHsyDbl95W/eh7pcXMbwkN+W4CBYUE9X4uHhqzWaC5yAVRWUA +1BJ9V4LjZfHPLEJt0I3TxzXiEg9/BVeaTYq9RjaxIFo9Nfk158HqJY6SA5jslBlx +Gv/No8u+xVcze2UJyGVfEIUfm92+0UAIkny3+5cuVV0ICzJxXlXj0CnLM9Lt50wE +p3suVwuBEviCbZ08eAH1Ht8gbBdSsiOkIU8CX3v/scwHHx5q0+NBL6xLrQObg13a +tRXBlKObfElkPN3lTUbUnJOW4U8uSjH8VRP+AujKWMDFe7x0zCs+iYY1mTOvbrTS +9n3CmZUmbynZ+E/QWNENpW/pDNZdWFy43PASmML5FHu4m9Sn +=oqMI +-----END PGP PUBLIC KEY BLOCK----- diff --git a/provision/partition-linux.py b/provision/partition-linux.py new file mode 100644 index 0000000..158546c --- /dev/null +++ b/provision/partition-linux.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Destructive prep-machine helper. Tests must use --dry-run with mocked probes.""" +import argparse +import json +import os +import re +import subprocess +import sys +import stat + + +def command(*args): + return subprocess.check_output(args, text=True).strip() + + +def plan(device, rows, root_source): + if not re.fullmatch(r'/dev/[A-Za-z0-9._/-]+', device): + raise ValueError('Provide an absolute /dev/ whole-disk path') + nodes = [] + + def walk(items, parents=()): + for node in items: + nodes.append((node, parents)) + walk(node.get('children', []), parents + (node['path'],)) + walk(rows) + root_nodes = [(n, p) for n, p in nodes if n['path'] == root_source] + if not root_nodes: + raise ValueError('Cannot prove system disk topology; refusing (containers/network/ZFS roots require a physical prep host)') + if any(n['path'] == device or device in p for n, p in root_nodes): + raise ValueError('Refusing system disk') + candidates = [(n, p) for n, p in nodes if n['path'] == device] + if len(candidates) != 1 or candidates[0][0]['type'] != 'disk': + raise ValueError('Device must identify exactly one whole disk (not a partition, loop or mapper)') + disk = candidates[0][0] + # Reject all mounted descendants, not only /. This also protects /boot, /home, + # swap, and mounted LVM/RAID graphs whose physical ancestors include this disk. + for node, parents in nodes: + if node['path'] == device or device in parents: + if any(node.get('mountpoints') or []) or node.get('ro'): + raise ValueError('Refusing mounted/system/read-only disk; unmount all non-system volumes first') + size = int(disk['size']) + gib = 1024 ** 3 + if size < 48 * gib: + raise ValueError('At least 48 GiB required') + model, serial = (str(disk.get(k) or '').strip() for k in ('model', 'serial')) + if not model or not serial: + raise ValueError('Refusing device without both model and serial identity') + rest = (size // gib - 9) // 3 + suffix = 'p' if device[-1].isdigit() else '' + part = lambda n: f'{device}{suffix}{n}' + # Explicit native types prevent treating APFS/ext4 as Windows basic data. + # Set only bit 63 (no default drive letter); never hidden/read-only bits. + commands = [ + ['sgdisk', '--zap-all', device], + ['sgdisk', '-n', '1:1MiB:+8GiB', '-t', '1:EBD0A0A2-B9E5-4433-87C0-68B6B72699C7', '-c', '1:AI-SHARED', + '-n', f'2:0:+{rest}GiB', '-t', '2:EBD0A0A2-B9E5-4433-87C0-68B6B72699C7', '-c', '2:AI-WIN', + '-n', f'3:0:+{rest}GiB', '-t', '3:7C3457EF-0000-11AA-AA11-00306543ECAC', '-c', '3:AI-MAC', + '-n', '4:0:0', '-t', '4:0FC63DAF-8483-4772-8E79-3D69D8477DE4', '-c', '4:AI-LINUX', + '--attributes=3:set:63', '--attributes=4:set:63', device], + ['partprobe', device], ['udevadm', 'settle'], + ['mkfs.exfat', '-n', 'AI-SHARED', part(1)], + ['mkfs.ntfs', '-Q', '-L', 'AI-WIN', part(2)], + ['mkfs.ext4', '-L', 'AI-LINUX', part(4)], + ] + return f'{model} / {serial}', commands + + +def probe(): + return json.loads(command('lsblk', '--json', '--bytes', '--paths', '--output', + 'PATH,TYPE,SIZE,MODEL,SERIAL,MOUNTPOINTS,RO'))['blockdevices'] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--device', required=True) + parser.add_argument('--dry-run', action='store_true') + args = parser.parse_args() + device = os.path.realpath(args.device) + root_source = os.path.realpath(command('findmnt', '-rn', '-o', 'SOURCE', '/').split('[')[0]) + identity, commands = plan(device, probe(), root_source) + # lsblk reports active swap as [SWAP], but verify the kernel list as well. + swap = command('swapon', '--show', '--noheadings', '--raw', '--output', 'NAME') + if swap: + # Conservatively refuse any active swap during partitioning preparations. + raise ValueError('Active swap exists; refusing until swap topology is cleared on the prep machine') + print(f'Device: {device}; identity: {identity}') + for cmd in commands: + print(' '.join(cmd)) + print('Partition 3 is an APFS placeholder: format it on a Mac; see START-HERE.md.') + if args.dry_run: + print('DRY RUN: no writes and no confirmation requested.') + return + if not stat.S_ISBLK(os.stat(device).st_mode): + raise ValueError('Device is not a block device') + if not sys.stdin.isatty(): + raise ValueError('Interactive typed confirmation required') + if os.geteuid() != 0: + raise ValueError('Run explicitly as root on the prep machine; never elevates itself') + if input(f'ERASE ALL DATA. Type exactly {identity}: ') != identity: + raise ValueError('Confirmation did not match; nothing changed') + # Detect replacement/mounting between inspection and confirmation. + if plan(device, probe(), root_source) != (identity, commands): + raise ValueError('Device changed during confirmation; nothing changed') + if command('swapon', '--show', '--noheadings', '--raw', '--output', 'NAME'): + raise ValueError('Swap changed during confirmation; nothing changed') + for cmd in commands: + subprocess.run(cmd, check=True) + + +if __name__ == '__main__': + try: + main() + except (ValueError, OSError, subprocess.SubprocessError) as error: + sys.exit(str(error)) diff --git a/provision/partition-linux.sh b/provision/partition-linux.sh new file mode 100755 index 0000000..6e1768f --- /dev/null +++ b/provision/partition-linux.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +provision_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) +exec python3 "$provision_dir/partition-linux.py" "$@" diff --git a/provision/windows-supervisor.ps1 b/provision/windows-supervisor.ps1 new file mode 100644 index 0000000..87cc1d1 --- /dev/null +++ b/provision/windows-supervisor.ps1 @@ -0,0 +1,6 @@ +param([Parameter(Mandatory=$true)][string]$Native) +$ErrorActionPreference = 'Stop' +$tools = Join-Path $Native 'tools' +[IO.Directory]::CreateDirectory($tools) | Out-Null +Add-Type -Path (Join-Path $PSScriptRoot '..\launch\lib\DriveChild.cs') -OutputAssembly (Join-Path $tools 'DriveChild.dll') -OutputType Library +Write-Host 'Windows process supervisor prepared.' diff --git a/resume.sh b/resume.sh index 5bcc5f7..ec05fb9 100755 --- a/resume.sh +++ b/resume.sh @@ -1,2 +1,2 @@ -#!/usr/bin/env bash -exec bash "$(cd "$(dirname "$0")" && pwd)/start.sh" resume "$@" +#!/bin/sh +exec sh "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)/start.sh" claude --resume "$@" diff --git a/start.sh b/start.sh index d4a55e9..44b45d3 100755 --- a/start.sh +++ b/start.sh @@ -1,36 +1,8 @@ -#!/usr/bin/env bash -set -euo pipefail -PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)" -NODE_VERSION="24.21.0" -case "$(uname -s)" in Darwin) PLATFORM=darwin;; Linux) PLATFORM=linux;; *) echo "Unsupported operating system"; exit 1;; esac -case "$(uname -m)" in arm64|aarch64) ARCH=arm64;; x86_64|amd64) ARCH=x64;; *) echo "Unsupported CPU architecture"; exit 1;; esac -NODE_DIR="$PROJECT_ROOT/engine/node-$PLATFORM-$ARCH" -NODE_BIN="$NODE_DIR/bin/node" -if [ ! -x "$NODE_BIN" ] || [ "$("$NODE_BIN" --version 2>/dev/null || true)" != "v$NODE_VERSION" ]; then - mkdir -p "$PROJECT_ROOT/engine" - ARCHIVE_NAME="node-v$NODE_VERSION-$PLATFORM-$ARCH.tar.gz" - ARCHIVE="$PROJECT_ROOT/engine/$ARCHIVE_NAME" - BASE="https://nodejs.org/dist/v$NODE_VERSION" - echo "Downloading portable Node.js from nodejs.org…" - curl --fail --location --retry 2 "$BASE/$ARCHIVE_NAME" -o "$ARCHIVE" - EXPECTED="$(curl --fail --location "$BASE/SHASUMS256.txt" | awk -v file="$ARCHIVE_NAME" '$2 == file {print $1}')" - if command -v sha256sum >/dev/null 2>&1; then ACTUAL="$(sha256sum "$ARCHIVE" | awk '{print $1}')"; else ACTUAL="$(shasum -a 256 "$ARCHIVE" | awk '{print $1}')"; fi - if [ -z "$EXPECTED" ] || [ "$EXPECTED" != "$ACTUAL" ]; then echo "Node.js checksum verification failed"; exit 1; fi - STAGING="$(mktemp -d "$PROJECT_ROOT/engine/node-extract.XXXXXX")" - trap 'rm -rf "$STAGING"' EXIT - if ln -s portable-symlink-test "$STAGING/.portable-symlink-test" 2>/dev/null; then - rm "$STAGING/.portable-symlink-test" - tar -xzf "$ARCHIVE" -C "$STAGING" --strip-components=1 - else - echo "This drive does not support symbolic links; using the NTFS-compatible Node.js layout…" - tar -xzf "$ARCHIVE" -C "$STAGING" --strip-components=1 \ - --exclude='*/bin/npm' --exclude='*/bin/npx' --exclude='*/bin/corepack' - fi - "$STAGING/bin/node" --version - if [ -d "$NODE_DIR" ]; then mv "$NODE_DIR" "$NODE_DIR.incomplete.$(date +%s)"; fi - mv "$STAGING" "$NODE_DIR" - rm "$ARCHIVE" - trap - EXIT -fi -export PATH="$NODE_DIR/bin:$PATH" -exec "$NODE_BIN" "$PROJECT_ROOT/tools/launcher.mjs" "$@" +#!/bin/sh +set -eu +start_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) +case $(uname -s) in + Linux) exec bash "$start_dir/launch/linux.sh" "$@";; + Darwin) exec bash "$start_dir/launch/macos.command" "$@";; + *) printf 'Unsupported operating system\n' >&2; exit 1;; +esac diff --git a/tests/backend.test.mjs b/tests/backend.test.mjs index 9b1b0b3..d1a04db 100644 --- a/tests/backend.test.mjs +++ b/tests/backend.test.mjs @@ -71,11 +71,11 @@ test('failed runtime installation preserves the working copy',async()=>{ }); test('verified runtime replacement retains the old copy and rollback restores it',async()=>{ const target=join(temp,'transaction/current');let installArgs; - const writeRuntime=(dir,tag)=>{mkdirSync(join(dir,'node_modules/@anthropic-ai/claude-code/bin'),{recursive:true});writeFileSync(join(dir,'node_modules/@anthropic-ai/claude-code/package.json'),JSON.stringify({bin:{claude:'bin/claude.exe'}}));writeFileSync(join(dir,'node_modules/@anthropic-ai/claude-code/bin/claude.exe'),tag);for(const dep of Object.keys(manifest.dependencies)){if(dep==='@anthropic-ai/claude-code')continue;mkdirSync(join(dir,'node_modules',dep),{recursive:true});writeFileSync(join(dir,'node_modules',dep,'package.json'),JSON.stringify({name:dep}));}}; + const writeRuntime=(dir,tag)=>{mkdirSync(dir,{recursive:true});writeFileSync(join(dir,'dashboard-version'),tag);for(const dep of Object.keys(manifest.dependencies)){mkdirSync(join(dir,'node_modules',dep),{recursive:true});writeFileSync(join(dir,'node_modules',dep,'package.json'),JSON.stringify({name:dep}));}}; writeRuntime(target,'old'); await installRuntime({target,runner:async(cmd,args,options)=>{if(args[0]==='--version')return '2.1.247 (Claude Code)';installArgs=args;writeRuntime(options.cwd,'new');return '';}}); assert.equal(installArgs[installArgs.indexOf('--no-fund')+1],'--save=false');assert.ok(installArgs.includes('--no-bin-links')); - const exe='node_modules/@anthropic-ai/claude-code/bin/claude.exe';assert.equal(readFileSync(join(target,exe),'utf8'),'new'); + const exe='dashboard-version';assert.equal(readFileSync(join(target,exe),'utf8'),'new'); await rollbackRuntime({target,runner:async()=> '2.1.247 (Claude Code)'});assert.equal(readFileSync(join(target,exe),'utf8'),'old'); }); test('dashboard blocks unauthenticated, cross-origin and rebinding requests',async t=>{ diff --git a/tests/drive-audit.test.mjs b/tests/drive-audit.test.mjs new file mode 100644 index 0000000..5149c52 --- /dev/null +++ b/tests/drive-audit.test.mjs @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,readdirSync,rmSync,symlinkSync} from 'node:fs'; +import {join,resolve} from 'node:path'; +import {spawnSync} from 'node:child_process'; +const root=resolve('.'); +function fixture(t){ + const dir=mkdtempSync(join(root,'.drive-test-audit-')); + t.after(()=>rmSync(dir,{recursive:true,force:true})); + const actual=join(dir,'private-tmp'), link=join(dir,'tmp'), outputDir=join(dir,'drive'), mocks=join(dir,'mocks'); + for(const p of [actual,outputDir,mocks])mkdirSync(p); + symlinkSync(actual,link);writeFileSync(join(actual,'changed'),'do not copy this content'); + const output=join(outputDir,'snapshot'); + const env={...process.env,HOME:dir,PATH:`${mocks}:${process.env.PATH}`,TMPDIR:'/not-a-drive',TEMP:'/not-a-drive',TMP:'/not-a-drive'}; + const run=()=>spawnSync('sh',['tools/audit/audit.sh','snapshot',output,link],{encoding:'utf8',env}); + return {dir,actual,link,outputDir,mocks,output,env,run}; +} +test('audit follows symlinked roots but not nested symlinks',t=>{ + const f=fixture(t), other=join(f.dir,'elsewhere');mkdirSync(other);writeFileSync(join(other,'excluded'),'x');symlinkSync(other,join(f.actual,'nested')); + const r=f.run();assert.equal(r.status,0,r.stderr); + const report=readFileSync(f.output,'utf8');assert.match(report,/\/tmp\/changed\|24\|/); + assert.doesNotMatch(report,/excluded|do not copy this content/); + assert.deepEqual(readdirSync(f.outputDir),['snapshot']); +}); +for(const outcome of ['success','failure','signal']){ + test(`audit sort scratch is on drive and cleaned after ${outcome}`,t=>{ + const f=fixture(t); + writeFileSync(join(f.mocks,'sort'),`#!/bin/sh\n[ "$1" = -T ] && [ "$2" = "$TMPDIR" ] && [ "$TEMP" = "$TMPDIR" ] && [ "$TMP" = "$TMPDIR" ] || exit 90\ncase "$TMPDIR" in "$EXPECTED_DIR"/.audit-scratch.*) ;; *) exit 91;; esac\nprintf spill > "$TMPDIR/spill"\n${outcome==='signal'?'kill -TERM "$PPID"; exit 42':outcome==='failure'?'exit 42':'cat "$4"'}\n`,{mode:0o755}); + f.env.EXPECTED_DIR=f.outputDir; + writeFileSync(f.output,'prior snapshot'); + const r=f.run();assert.equal(r.status,outcome==='signal'?143:outcome==='failure'?42:0,r.stderr); + assert.deepEqual(readdirSync(f.outputDir),['snapshot']); + if(outcome!=='success')assert.equal(readFileSync(f.output,'utf8'),'prior snapshot'); + }); +} diff --git a/tests/drive-launcher.test.mjs b/tests/drive-launcher.test.mjs new file mode 100644 index 0000000..5986146 --- /dev/null +++ b/tests/drive-launcher.test.mjs @@ -0,0 +1,407 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, cpSync, writeFileSync, readFileSync, existsSync, rmSync, utimesSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { spawnSync, spawn } from 'node:child_process'; +const root = resolve('.'); +const quote = s => `'${s.replaceAll("'", "'\\''")}'`; +function fixture(t) { + const dir = mkdtempSync(join(root, '.drive-test-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const shared = join(dir, 'AI SHARED'), native = join(dir, 'native space'), mocks = join(dir, 'mock'); + for (const p of [shared, mocks, join(native,'tmp'), join(native, 'bin/linux-x64/codex/bin')]) mkdirSync(p, { recursive: true }); + const env = { ...process.env, PATH: `${mocks}:${process.env.PATH}`, MOCK_NATIVE: native, TMPDIR: join(native,'tmp') }; + const run = (body, extra = {}) => spawnSync('bash', ['-c', `. ${quote(join(root, 'launch/lib/drive.sh'))}; . ${quote(join(root, 'launch/lib/session.sh'))}; ${body}`], { env, encoding: 'utf8', ...extra }); + const setup = `drive_environment ${quote(shared)} ${quote(native)} linux-x64 || exit;`; + const mock = (name, body) => writeFileSync(join(mocks, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 }); + return { dir, shared, native, mocks, env, run, setup, mock }; +} +test('environment is local to subshell, credentials cleared, paths and cwd preserved', t => { + const f = fixture(t); + f.env.ANTHROPIC_API_KEY = 'host-secret'; f.env.OPENAI_BASE_URL = 'host-url'; + mkdirSync(join(f.shared, 'credentials')); + writeFileSync(join(f.shared, 'credentials/claude-oauth-token'), 'fake-token\n'); + const r = f.run(`(${f.setup} [ -z "\${ANTHROPIC_API_KEY+x}" ] && [ -z "\${OPENAI_BASE_URL+x}" ] || exit 2; [ "$CLAUDE_CODE_OAUTH_TOKEN" = fake-token ] || exit 3; printf '%s\\n' "$CODEX_HOME" "$CODEX_SQLITE_HOME" "$HOME" "$PWD" "$DISABLE_UPDATES"); [ "$ANTHROPIC_API_KEY" = host-secret ] || exit 4;`, { cwd: f.dir }); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual(r.stdout.trim().split('\n'), [join(f.native,'state/codex'),join(f.native,'state/codex'),join(f.native,'state/home'),f.dir,'1']); + assert.ok(!r.stdout.includes('fake-token')); +}); +test('discovery uses label, handles spaces, mounts with udisks and refuses missing/ambiguous mounts', t => { + const f = fixture(t); + f.mock('findmnt', '[ "$3" = /dev/disk/by-label/AI-LINUX ] || exit 8; printf "%s\\n" "$MOCK_NATIVE"'); + assert.equal(f.run('drive_discover linux').stdout.trim(), f.native); + f.mock('findmnt', `[ -f ${quote(join(f.dir,'mounted'))} ] && printf '%s\\n' "$MOCK_NATIVE"`); + f.mock('udisksctl', `touch ${quote(join(f.dir,'mounted'))}`); + assert.equal(f.run('drive_discover linux').stdout.trim(), f.native); + f.mock('findmnt', 'exit 1'); f.mock('udisksctl', 'exit 1'); + let r = f.run('drive_discover linux'); assert.notEqual(r.status,0); assert.match(r.stderr,/udisksctl mount -b/); + f.mock('findmnt', 'printf "/one\\n/two\\n"'); + r = f.run('drive_discover linux'); assert.notEqual(r.status,0); assert.match(r.stderr,/Multiple/); +}); +test('auth sync is authoritative, only copies newer auth back, lock excludes sessions', t => { + const f = fixture(t), auth = join(f.shared,'credentials/codex-auth.json'); + mkdirSync(join(f.shared,'credentials')); writeFileSync(auth,'shared'); + let r = f.run(`${f.setup} drive_lock && drive_auth_in && drive_codex_config`); + assert.equal(r.status,0,r.stderr); + assert.equal(readFileSync(join(f.native,'state/codex/auth.json'),'utf8'),'shared'); + r=f.run(`${f.setup} drive_lock`); assert.notEqual(r.status,0); assert.match(r.stderr,/locked/); + const local=join(f.native,'state/codex/auth.json'); writeFileSync(local,'old'); utimesSync(local,1,1); + r=f.run(`${f.setup} drive_lock_path=${quote(join(f.shared,'credentials/codex-auth.lock'))}; drive_auth_out`); + assert.equal(r.status,0,r.stderr); assert.equal(readFileSync(auth,'utf8'),'shared'); + writeFileSync(local,'refreshed'); const future=Date.now()/1000+3;utimesSync(local,future,future); + r=f.run(`${f.setup} drive_lock_path=${quote(join(f.shared,'credentials/codex-auth.lock'))}; drive_auth_out && drive_unlock`); + assert.equal(r.status,0,r.stderr);assert.equal(readFileSync(auth,'utf8'),'refreshed'); + assert.ok(!existsSync(join(f.shared,'credentials/codex-auth.lock'))); + rmSync(auth); r=f.run(`${f.setup} drive_auth_in`); assert.equal(r.status,0);assert.ok(!existsSync(local)); + const config=readFileSync(join(f.native,'state/codex/config.toml'),'utf8'); + assert.match(config,/cli_auth_credentials_store = "file"/);assert.match(config,/check_for_update_on_startup = false/);assert.match(config,/\[analytics\]\nenabled = false/);assert.match(config,/\[feedback\]\nenabled = false/); +}); +test('Codex gets no-daemon, extra args, child exit status, sync and unlock', t => { + const f=fixture(t); + writeFileSync(join(f.native,'bin/linux-x64/codex/bin/codex'), '#!/bin/sh\nprintf "%s\\n" "$@"\nprintf refreshed > "$CODEX_HOME/auth.json"\nexit 7\n',{mode:0o755}); + const r=f.run(`${f.setup} drive_codex exec 'space argument'`); + assert.equal(r.status,7,r.stderr); assert.deepEqual(r.stdout.trim().split('\n'),['--no-daemon','exec','space argument']); + assert.equal(readFileSync(join(f.shared,'credentials/codex-auth.json'),'utf8'),'refreshed'); + assert.ok(!existsSync(join(f.shared,'credentials/codex-auth.lock'))); +}); +test('session cleanup kills a background descendant after command exits', async t => { + const f=fixture(t), pidFile=join(f.dir,'pid'); + const script=join(f.dir,'child'); writeFileSync(script,`#!/bin/sh\nsleep 60 &\nprintf '%s' "$!" > ${quote(pidFile)}\n`,{mode:0o755}); + const r=f.run(`drive_run ${quote(script)}`); assert.equal(r.status,0,r.stderr); + const pid=Number(readFileSync(pidFile,'utf8')); + const ps=spawnSync('ps',['-o','stat=','-p',String(pid)],{encoding:'utf8'}); + assert.ok(ps.status!==0 || ps.stdout.trim().startsWith('Z'),`descendant ${pid} still running: ${ps.stdout}`); +}); +test('macOS discovery uses diskutil label fallback and architecture selection rejects unknown CPU',t=>{ + const f=fixture(t); + f.mock('diskutil',`[ "$1 $2" = 'info AI-MAC' ] || exit 7; printf ' Mount Point: %s\\n' "$MOCK_NATIVE"`); + // The fallback is exercised on Linux where /Volumes/AI-MAC does not exist. + assert.equal(f.run('drive_discover darwin').stdout.trim(),f.native); + f.mock('uname','printf aarch64');assert.equal(f.run('drive_arch').stdout.trim(),'arm64'); + f.mock('uname','printf riscv64');assert.notEqual(f.run('drive_arch').status,0); +}); +test('failed auth import does not copy stale native credentials back',t=>{ + const f=fixture(t);mkdirSync(join(f.shared,'credentials'));writeFileSync(join(f.shared,'credentials/codex-auth.json'),'authoritative'); + const r=f.run(`${f.setup} printf stale > "$CODEX_HOME/auth.json"; drive_auth_in() { return 1; }; drive_codex`); + assert.equal(r.status,1,r.stderr); + assert.equal(readFileSync(join(f.shared,'credentials/codex-auth.json'),'utf8'),'authoritative'); + assert.ok(!existsSync(join(f.shared,'credentials/codex-auth.lock'))); +}); +test('SIGTERM to session supervisor cleans child process group',async t=>{ + const f=fixture(t), pidFile=join(f.dir,'signal-pid'); + const script=join(f.dir,'long-child');writeFileSync(script,`#!/bin/sh\nprintf '%s' "$$" > ${quote(pidFile)}\nsleep 60\n`,{mode:0o755}); + const proc=spawn('bash',['-c',`. ${quote(join(root,'launch/lib/session.sh'))}; drive_run ${quote(script)}`],{stdio:'ignore',env:f.env}); + t.after(()=>{try{proc.kill('SIGKILL');}catch{}}); + for(let i=0;i<100&&!existsSync(pidFile);i++)await new Promise(r=>setTimeout(r,10)); + assert.ok(existsSync(pidFile),'child started'); + const childPid=Number(readFileSync(pidFile,'utf8')); + t.after(()=>{try{process.kill(-childPid,'SIGKILL');}catch{}}); + proc.kill('SIGTERM'); + await new Promise(r=>setTimeout(r,1500)); + const ps=spawnSync('ps',['-o','stat=','-p',String(childPid)],{encoding:'utf8'}); + assert.ok(ps.status!==0||ps.stdout.trim().startsWith('Z'),`child survived SIGTERM: ${ps.stdout}`); +}); + +test('Linux mount discovery decodes findmnt hex-escaped spaces',t=>{ + const f=fixture(t); + f.mock('findmnt',"printf '%s\\n' '/media/AI\\x20LINUX'"); + assert.equal(f.run('drive_discover linux').stdout.trim(),'/media/AI LINUX'); +}); +test('interactive child can read its terminal under the process supervisor',t=>{ + const f=fixture(t), child=join(f.dir,'interactive-child'); + writeFileSync(child,'#!/bin/sh\nread -r value\nprintf "RECEIVED:%s\\n" "$value"\n',{mode:0o755}); + const python=` +import os, pty, select, time, signal +pid, fd = pty.fork() +if pid == 0: + os.execvp('bash', ['bash', '-c', '. "$1"; drive_run "$2"', 'test', ${JSON.stringify(join(root,'launch/lib/session.sh'))}, ${JSON.stringify(child)}]) +output=b'' +try: + os.write(fd,b'hello-terminal\\n') + end=time.time()+8 + while time.time(){ + const f=fixture(t); + cpSync(join(root,'launch'),join(f.shared,'launch'),{recursive:true}); + f.mock('findmnt','printf "%s\\n" "$MOCK_NATIVE"');f.mock('uname','printf x86_64'); + writeFileSync(join(f.native,'bin/linux-x64/claude'),'#!/bin/sh\n[ -z "${OPENAI_API_KEY+x}" ] || exit 8\npwd\nprintf "%s\\n" "$@" "$CLAUDE_CONFIG_DIR"\n',{mode:0o755}); + const r=spawnSync('bash',[join(f.shared,'launch/linux.sh'),'claude','--resume','space argument'],{cwd:f.dir,env:{...f.env,OPENAI_API_KEY:'fake-host-key'},encoding:'utf8'}); + assert.equal(r.status,0,r.stderr); + assert.deepEqual(r.stdout.trim().split('\n'),[f.dir,'--resume','space argument',join(f.native,'state/claude')]); +}); + +for (const entry of ['linux.sh','macos.command']) { + test(`${entry} removes CR/LF from stored token without printing it`,t=>{ + const f=fixture(t), token='fake-private-token'; + cpSync(join(root,'launch'),join(f.shared,'launch'),{recursive:true}); + mkdirSync(join(f.shared,'credentials')); + writeFileSync(join(f.shared,'credentials/claude-oauth-token'),`${token}\r\n\r\n`); + f.mock('findmnt','printf "%s\\n" "$MOCK_NATIVE"');f.mock('uname','printf x86_64'); + f.mock('diskutil','printf " Mount Point: %s\\n" "$MOCK_NATIVE"'); + const target=entry==='linux.sh'?'linux-x64':'darwin-x64'; + mkdirSync(join(f.native,`bin/${target}`),{recursive:true}); + writeFileSync(join(f.native,`bin/${target}/claude`),`#!/bin/sh\n[ "$CLAUDE_CODE_OAUTH_TOKEN" = '${token}' ]\n`,{mode:0o755}); + const r=spawnSync('bash',[join(f.shared,'launch',entry),'claude'],{env:f.env,encoding:'utf8'}); + assert.equal(r.status,0,r.stderr);assert.ok(!(r.stdout+r.stderr).includes(token)); + }); +} +test('Claude paste strips CR/LF before saving and rejects newline-only input',t=>{ + const f=fixture(t); + let r=f.run(`${f.setup} drive_run() { :; }; drive_login`,{input:'1\nfake-private-token\r\n'}); + assert.equal(r.status,0,r.stderr); + const path=join(f.shared,'credentials/claude-oauth-token'); + assert.equal(readFileSync(path,'utf8'),'fake-private-token'); + assert.ok(!(r.stdout+r.stderr).includes('fake-private-token')); + r=f.run(`${f.setup} drive_run() { :; }; drive_login`,{input:'1\n\r\n'}); + assert.equal(r.status,1);assert.equal(readFileSync(path,'utf8'),'fake-private-token'); +}); +for (const aliveSteps of [0,2,100]) { + test(`cleanup polls process group and escalates only at deadline (${aliveSteps})`,t=>{ + const f=fixture(t), calls=join(f.dir,'calls'), group=join(f.dir,'group'); + writeFileSync(group,'999999'); + const r=f.run(`drive_group_file=${quote(group)}; probes=0; + kill() { printf '%s\\n' "$1" >> ${quote(calls)}; if [ "$1" = -0 ]; then probes=$((probes + 1)); [ "$probes" -le ${aliveSteps} ]; fi; }; + sleep() { printf 'sleep:%s\\n' "$1" >> ${quote(calls)}; }; + drive_cleanup`); + assert.equal(r.status,0,r.stderr); + const events=readFileSync(calls,'utf8').trim().split('\n'); + assert.equal(events.filter(e=>e==='sleep:0.1').length,Math.min(aliveSteps,20)); + assert.equal(events.includes('-KILL'),aliveSteps>20); + assert.ok(!existsSync(group)); + }); +} + +test('WSL detection recognizes WSL environment and respects override', t => { + const f = fixture(t); + let r = f.run('PORTABLE_AI_WSL=1 drive_is_wsl'); + assert.equal(r.status, 0); + r = f.run('PORTABLE_AI_WSL=0 drive_is_wsl'); + assert.notEqual(r.status, 0); + r = f.run('unset PORTABLE_AI_WSL; WSL_DISTRO_NAME=Ubuntu drive_is_wsl'); + assert.equal(r.status, 0); + r = f.run('unset PORTABLE_AI_WSL; unset WSL_DISTRO_NAME; unset WSL_INTEROP; drive_is_wsl() { [ "$PORTABLE_AI_WSL" = 1 ]; }; drive_is_wsl'); + assert.notEqual(r.status, 0); +}); + +test('WSL CLI resolution finds Linux binaries and rejects Windows shims and /mnt paths', t => { + const f = fixture(t); + const wslBin = join(f.dir, 'wsl-bin'), winBin = join(f.dir, 'win-bin'), mntBin = join(f.dir, 'mnt-bin'); + mkdirSync(wslBin); mkdirSync(winBin); mkdirSync(mntBin); + writeFileSync(join(wslBin, 'claude'), '#!/bin/sh\n', { mode: 0o755 }); + writeFileSync(join(winBin, 'claude.cmd'), '@echo off\n', { mode: 0o755 }); + writeFileSync(join(winBin, 'claude.exe'), 'MZ\n', { mode: 0o755 }); + writeFileSync(join(mntBin, 'codex'), '#!/bin/sh\n', { mode: 0o755 }); + f.env.PATH = `${winBin}:${wslBin}:${process.env.PATH}`; + let r = f.run('drive_resolve_wsl_cli claude'); + assert.equal(r.status, 0); + assert.equal(r.stdout.trim(), join(wslBin, 'claude')); + + f.env.PATH = `/mnt/c/some/path:${winBin}`; + r = f.run('drive_resolve_wsl_cli codex'); + assert.notEqual(r.status, 0); +}); + +test('WSL discovery creates sparse ext4 image, uses existing mount or mounts loopback', t => { + const f = fixture(t); + const shared = f.shared; + f.mock('truncate', 'touch "$3"'); + f.mock('mkfs.ext4', ':'); + f.mock('findmnt', 'exit 1'); + f.mock('udisksctl', ` + case $1 in + loop-setup) printf "Mapped file %s as /dev/loop42.\\n" "$3";; + mount) printf "Mounted /dev/loop42 at %s.\\n" "$MOCK_NATIVE";; + esac + `); + let r = f.run(`PORTABLE_AI_WSL=1 drive_wsl_discover ${quote(shared)}`); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), f.native); + assert.ok(existsSync(join(shared, 'state/wsl-state.ext4'))); + + f.mock('findmnt', `printf '%s\\n' "${f.native}"`); + r = f.run(`PORTABLE_AI_WSL=1 drive_wsl_discover ${quote(shared)}`); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), f.native); +}); + +test('WSL launcher executes resolved Linux CLIs, blocks dashboard, and guides missing CLIs', t => { + const f = fixture(t); + cpSync(join(root, 'launch'), join(f.shared, 'launch'), { recursive: true }); + const wslBin = join(f.dir, 'wsl-bin'); + mkdirSync(wslBin); + writeFileSync(join(wslBin, 'claude'), '#!/bin/sh\nprintf "claude-run:%s\\n" "$*"\n', { mode: 0o755 }); + writeFileSync(join(wslBin, 'codex'), '#!/bin/sh\nprintf "codex-run:%s\\n" "$*"\n', { mode: 0o755 }); + f.env.PATH = `${wslBin}:${process.env.PATH}`; + f.env.PORTABLE_AI_WSL = '1'; + f.mock('findmnt', `printf '%s\\n' "${f.native}"`); + + let r = f.run(`drive_main linux ${quote(join(f.shared, 'launch/linux.sh'))} claude arg1 'arg 2'`); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /claude-run:arg1 arg 2/); + + r = f.run(`drive_main linux ${quote(join(f.shared, 'launch/linux.sh'))} dashboard`); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /Dashboard is not supported in WSL mode/); + + r = f.run(`PATH=/usr/bin:/bin drive_main linux ${quote(join(f.shared, 'launch/linux.sh'))} claude`); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /Linux 'claude' CLI not found.*Install Claude Code inside WSL Ubuntu/); +}); + +test('WSL session unmounts loop device and cleans up on exit', t => { + const f = fixture(t); + const calls = join(f.dir, 'unmount-calls'); + f.mock('udisksctl', `printf '%s\\n' "$*" >> ${quote(calls)}`); + const r = f.run(` + DRIVE_WSL_LOOP_DEV=/dev/loop99; DRIVE_WSL_MOUNT_TARGET=/tmp/ai-drive-wsl-1000; + drive_wsl_unmount + `); + assert.equal(r.status, 0, r.stderr); + const events = readFileSync(calls, 'utf8').trim().split('\n'); + assert.ok(events.includes('unmount -b /dev/loop99')); + assert.ok(events.includes('loop-delete -b /dev/loop99')); +}); + +test('omnigent server URL resolution prompts when unset, validates, and reuses saved URL', t => { + const f = fixture(t); + const omniLib = quote(join(root, 'launch/lib/omnigent-host.sh')); + + // 1. Argument override + let r = f.run(`. ${omniLib}; drive_omnigent_server_url ${quote(f.shared)} --server https://arg.example.test`); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), 'https://arg.example.test'); + + // 2. Environment override + r = f.run(`. ${omniLib}; OMNIGENT_SERVER_URL=https://env.example.test drive_omnigent_server_url ${quote(f.shared)}`); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), 'https://env.example.test'); + + // 3. Saved URL file + mkdirSync(join(f.shared, 'credentials'), { recursive: true }); + writeFileSync(join(f.shared, 'credentials/omnigent-server-url'), 'https://saved.example.test\n'); + r = f.run(`. ${omniLib}; drive_omnigent_server_url ${quote(f.shared)}`); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), 'https://saved.example.test'); + + // 4. Interactive prompt when unset + rmSync(join(f.shared, 'credentials/omnigent-server-url')); + r = f.run(`. ${omniLib}; drive_omnigent_server_url ${quote(f.shared)}`, { input: 'https://prompted.example.test\n' }); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), 'https://prompted.example.test'); + assert.equal(readFileSync(join(f.shared, 'credentials/omnigent-server-url'), 'utf8').trim(), 'https://prompted.example.test'); + + // 5. Invalid URL rejected + rmSync(join(f.shared, 'credentials/omnigent-server-url')); + r = f.run(`. ${omniLib}; drive_omnigent_server_url ${quote(f.shared)}`, { input: 'not-a-valid-url\n' }); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /Invalid server URL/); +}); + +test('omnigent host identity mints UUID per machine, stores in credentials, and avoids cross-machine resumption', t => { + const f = fixture(t); + const omniLib = quote(join(root, 'launch/lib/omnigent-host.sh')); + const pyDir = join(f.native, 'bin/linux-x64/python/bin'); + mkdirSync(pyDir, { recursive: true }); + // Use system python3 as the stub for the script execution in fixture + const sysPy = spawnSync('which', ['python3'], { encoding: 'utf8' }).stdout.trim(); + writeFileSync(join(pyDir, 'python3'), `#!/bin/sh\nexec "${sysPy}" "$@"\n`, { mode: 0o755 }); + + // Machine 1 + f.mock('sha256sum', 'printf "machine-hash-1 -\\n"'); + let r = f.run(`. ${omniLib}; drive_omnigent_host_identity ${quote(f.shared)} ${quote(join(f.native, 'bin/linux-x64/python'))} linux; printf '%s\\n' "$OMNIGENT_HOST_ID" "$OMNIGENT_HOST_NAME"`); + assert.equal(r.status, 0, r.stderr); + const [id1, name1] = r.stdout.trim().split('\n'); + assert.match(id1, /^[0-9a-f]{32}$/); + assert.match(name1, /^portable-/); + + // Machine 1 again -> stable ID + r = f.run(`. ${omniLib}; drive_omnigent_host_identity ${quote(f.shared)} ${quote(join(f.native, 'bin/linux-x64/python'))} linux; printf '%s\\n' "$OMNIGENT_HOST_ID"`); + assert.equal(r.status, 0, r.stderr); + assert.equal(r.stdout.trim(), id1); + + // Machine 2 -> distinct ID (disallowing cross-machine resumption) + f.mock('sha256sum', 'printf "machine-hash-2 -\\n"'); + r = f.run(`. ${omniLib}; drive_omnigent_host_identity ${quote(f.shared)} ${quote(join(f.native, 'bin/linux-x64/python'))} linux; printf '%s\\n' "$OMNIGENT_HOST_ID"`); + assert.equal(r.status, 0, r.stderr); + const id2 = r.stdout.trim(); + assert.match(id2, /^[0-9a-f]{32}$/); + assert.notEqual(id1, id2); + + // Verify stored JSON has both machines mapped + const hosts = JSON.parse(readFileSync(join(f.shared, 'credentials/omnigent-hosts.json'), 'utf8')); + assert.equal(hosts['machine-hash-1'].host_id, id1); + assert.equal(hosts['machine-hash-2'].host_id, id2); +}); + +test('omnigent runtime environment sets PYTHONHOME, isolated paths, and terminfo across moved paths', t => { + const f = fixture(t); + const omniLib = quote(join(root, 'launch/lib/omnigent-host.sh')); + + // Build runtime at path_A + const pathA = join(f.dir, 'native-path-A'); + const pyA = join(pathA, 'bin/linux-x64/python/bin'); + const termA = join(pathA, 'bin/linux-x64/python/share/terminfo'); + const omniRuntimeA = join(pathA, 'tools/omnigent-runtime'); + mkdirSync(pyA, { recursive: true }); + mkdirSync(termA, { recursive: true }); + mkdirSync(omniRuntimeA, { recursive: true }); + writeFileSync(join(pyA, 'python3'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + + // Move path_A to path_B + const pathB = join(f.dir, 'native-path-B'); + cpSync(pathA, pathB, { recursive: true }); + rmSync(pathA, { recursive: true, force: true }); + + const r = f.run(`. ${omniLib}; drive_omnigent_environment ${quote(pathB)} linux-x64; printf '%s\\n' "$PYTHONHOME" "$PYTHONNOUSERSITE" "$PYTHONPATH" "$TERMINFO" "$TERMINFO_DIRS"`); + assert.equal(r.status, 0, r.stderr); + const [pyHome, noUserSite, pyPath, termInfo, termDirs] = r.stdout.trim().split('\n'); + assert.equal(pyHome, join(pathB, 'bin/linux-x64/python')); + assert.equal(noUserSite, '1'); + assert.ok(pyPath.includes(join(pathB, 'tools/omnigent-runtime'))); + assert.equal(termInfo, join(pathB, 'bin/linux-x64/python/share/terminfo')); + assert.ok(termDirs.startsWith(join(pathB, 'bin/linux-x64/python/share/terminfo'))); + assert.ok(!r.stdout.includes('native-path-A')); +}); + +test('omnigent-host wrapper executes relative Python entrypoint with zero host footprint', t => { + const f = fixture(t); + cpSync(join(root, 'launch'), join(f.shared, 'launch'), { recursive: true }); + cpSync(join(root, 'tools'), join(f.shared, 'tools'), { recursive: true }); + + const pyDir = join(f.native, 'bin/linux-x64/python/bin'); + const termDir = join(f.native, 'bin/linux-x64/python/share/terminfo'); + mkdirSync(pyDir, { recursive: true }); + mkdirSync(termDir, { recursive: true }); + + const recordFile = join(f.dir, 'omnigent-calls.txt'); + writeFileSync(join(pyDir, 'python3'), `#!/bin/sh\nprintf 'RUN: %s\\n' "$*" >> ${quote(recordFile)}\nexit 0\n`, { mode: 0o755 }); + f.mock('findmnt', `printf '%s\\n' "${f.native}"`); + f.mock('sha256sum', 'printf "machine-hash-wrapper -\\n"'); + + const wrapper = join(f.shared, 'tools/omnigent-host'); + const r = f.run(`${quote(wrapper)} --server https://omni.example.test status`); + assert.equal(r.status, 0, r.stderr); + + const calls = readFileSync(recordFile, 'utf8').trim().split('\n'); + assert.ok(calls.some(call => call.includes('-m omnigent.cli host https://omni.example.test --no-open status'))); +}); + + diff --git a/tests/drive-partition.test.mjs b/tests/drive-partition.test.mjs new file mode 100644 index 0000000..bb16322 --- /dev/null +++ b/tests/drive-partition.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { resolve,join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +const root=resolve('.'); +function fixture(t, change=()=>{}, swap='') { + const dir=mkdtempSync(join(root,'.partition-test-'));t.after(()=>rmSync(dir,{recursive:true,force:true})); + const disk={path:'/dev/mockdrive',type:'disk',size:128*1024**3,model:'Test SSD',serial:'MOCK123',mountpoints:[null],ro:false,children:[]};change(disk); + const data=JSON.stringify({blockdevices:[disk,{path:'/dev/system',type:'disk',children:[{path:'/dev/system1',type:'part',mountpoints:['/']}]}]}); + const quote=s=>`'${s.replaceAll("'","'\\''")}'`; + writeFileSync(join(dir,'lsblk'),`#!/bin/sh\nprintf '%s' ${quote(data)}\n`,{mode:0o755}); + writeFileSync(join(dir,'findmnt'), '#!/bin/sh\nprintf /dev/system1\n',{mode:0o755}); + writeFileSync(join(dir,'swapon'),`#!/bin/sh\nprintf '%s' ${quote(swap)}\n`,{mode:0o755}); + // Any accidental destructive command fails and is recorded as a test failure. + for(const command of ['sgdisk','partprobe','udevadm','mkfs.exfat','mkfs.ntfs','mkfs.ext4'])writeFileSync(join(dir,command),'#!/bin/sh\necho DESTRUCTIVE-COMMAND >&2\nexit 99\n',{mode:0o755}); + return args=>spawnSync('sh',['provision/partition-linux.sh',...args],{cwd:root,env:{...process.env,PATH:`${dir}:${process.env.PATH}`},encoding:'utf8'}); +} +test('dry-run shows GPT labels, native filesystems and APFS placeholder without commands',t=>{ + const r=fixture(t)(['--device','/dev/mockdrive','--dry-run']);assert.equal(r.status,0,r.stderr); + for(const label of ['AI-SHARED','AI-WIN','AI-MAC','AI-LINUX'])assert.ok(r.stdout.includes(label)); + assert.match(r.stdout,/DRY RUN/);assert.doesNotMatch(r.stderr,/DESTRUCTIVE/);assert.doesNotMatch(r.stdout,/mkfs.apfs/); +}); +test('partition helper requires an explicit device',t=>{const r=fixture(t)(['--dry-run']);assert.notEqual(r.status,0);assert.match(r.stderr,/--device/);}); +for(const [name,change,swap] of [ + ['root mounted',d=>d.children.push({path:'/dev/mockdrive1',type:'part',mountpoints:['/']}),''], + ['LVM root',d=>d.children.push({path:'/dev/mockdrive1',type:'part',children:[{path:'/dev/mapper/root',type:'lvm',mountpoints:['/']}]}),''], + ['read-only',d=>d.ro=true,''],['partition',d=>d.type='part',''], + ['unknown identity',d=>d.serial='',''],['small disk',d=>d.size=10,''], + ['swap',()=>{},'/dev/mockdrive1']]) { + test(`partition safety rejects ${name} even in dry-run`,t=>{ + const r=fixture(t,change,swap)(['--device','/dev/mockdrive','--dry-run']);assert.notEqual(r.status,0);assert.doesNotMatch(r.stderr,/DESTRUCTIVE/); + }); +} + +test('partition helper refuses the physical ancestor of the system root',t=>{ + const r=fixture(t)(['--device','/dev/system','--dry-run']); + assert.notEqual(r.status,0);assert.match(r.stderr,/Refusing system disk/); +}); + +for (const key of ['model','serial']) { + for (const [label,value] of [['null',null],['blank',' \t\r\n'],['missing',undefined]]) { + test(`partition helper cleanly refuses ${label} ${key}`,t=>{ + const r=fixture(t,d=>d[key]=value)(['--device','/dev/mockdrive','--dry-run']); + assert.equal(r.status,1); + assert.equal(r.stderr.trim(),'Refusing device without both model and serial identity'); + assert.equal(r.stdout,''); + }); + } +} + + +test('dry-run prints exact native GPT GUIDs and sets only bit 63 on partitions 3 and 4',t=>{ + const r=fixture(t)(['--device','/dev/mockdrive','--dry-run']); + assert.equal(r.status,0,r.stderr); + const commands=r.stdout.split('\n').filter(line=>line.startsWith('sgdisk ')); + assert.deepEqual(commands,[ + 'sgdisk --zap-all /dev/mockdrive', + 'sgdisk -n 1:1MiB:+8GiB -t 1:EBD0A0A2-B9E5-4433-87C0-68B6B72699C7 -c 1:AI-SHARED '+ + '-n 2:0:+39GiB -t 2:EBD0A0A2-B9E5-4433-87C0-68B6B72699C7 -c 2:AI-WIN '+ + '-n 3:0:+39GiB -t 3:7C3457EF-0000-11AA-AA11-00306543ECAC -c 3:AI-MAC '+ + '-n 4:0:0 -t 4:0FC63DAF-8483-4772-8E79-3D69D8477DE4 -c 4:AI-LINUX '+ + '--attributes=3:set:63 --attributes=4:set:63 /dev/mockdrive' + ]); + const args=commands[1].split(' '); + const types=args.flatMap((arg,index)=>arg==='-t'?[args[index+1]]:[]); + assert.deepEqual(types,[ + '1:EBD0A0A2-B9E5-4433-87C0-68B6B72699C7', + '2:EBD0A0A2-B9E5-4433-87C0-68B6B72699C7', + '3:7C3457EF-0000-11AA-AA11-00306543ECAC', + '4:0FC63DAF-8483-4772-8E79-3D69D8477DE4' + ]); + assert.deepEqual(args.filter(arg=>arg.startsWith('--attributes=')),['--attributes=3:set:63','--attributes=4:set:63']); + assert.doesNotMatch(r.stderr,/DESTRUCTIVE-COMMAND/); +}); diff --git a/tests/drive-provision.test.mjs b/tests/drive-provision.test.mjs new file mode 100644 index 0000000..2e3670a --- /dev/null +++ b/tests/drive-provision.test.mjs @@ -0,0 +1,7 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +test('provisioner verification and archive layout (ten Python test functions)',()=>{ + const result=spawnSync('python3',['-m','unittest','discover','-s','tests','-p','drive_provision_test.py','-v'],{encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}}); + assert.equal(result.status,0,result.stdout+result.stderr); +}); diff --git a/tests/drive-windows.ps1 b/tests/drive-windows.ps1 new file mode 100644 index 0000000..638b7db --- /dev/null +++ b/tests/drive-windows.ps1 @@ -0,0 +1,40 @@ +# Run with pwsh -NoProfile -File tests/drive-windows.ps1 on a Windows prep host. +$ErrorActionPreference = 'Stop' +$repo = Split-Path -Parent $PSScriptRoot +Get-ChildItem -LiteralPath $repo -Filter '*.ps1' -Recurse | ForEach-Object { + $tokens = $null; $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count) { throw ($errors | Out-String) } +} +. (Join-Path $repo 'launch/lib/windows.ps1') +$testRoot = Join-Path $repo ('.drive-test-' + [Guid]::NewGuid().ToString('N')) +try { + $shared = Join-Path $testRoot 'shared' + $native = Join-Path $testRoot 'native space' + [IO.Directory]::CreateDirectory((Join-Path $native 'bin/win32-x64')) | Out-Null + [IO.Directory]::CreateDirectory((Join-Path $shared 'credentials')) | Out-Null + [IO.File]::WriteAllText((Join-Path $shared 'credentials/claude-oauth-token'), "fake-token`r`n`r`n") + $before = [Environment]::GetEnvironmentVariables() + $envMap = Get-DriveEnvironment $shared $native 'win32-x64' + if ($envMap.CODEX_HOME -ne (Join-Path $native 'state/codex')) { throw 'Wrong CODEX_HOME' } + if ($envMap.CLAUDE_CODE_OAUTH_TOKEN -ne 'fake-token') { throw 'Token not loaded' } + foreach ($key in @('ANTHROPIC_API_KEY','ANTHROPIC_AUTH_TOKEN','OPENAI_API_KEY','ANTHROPIC_BASE_URL','OPENAI_BASE_URL')) { + if ($envMap.ContainsKey($key)) { throw "Inherited credential: $key" } + } + foreach ($key in $before.Keys) { + if ([Environment]::GetEnvironmentVariable($key) -ne $before[$key]) { throw "Host environment changed: $key" } + } + function Get-Volume { param($FileSystemLabel) if ($FileSystemLabel -ne 'AI-WIN') { throw 'Wrong label' }; [PSCustomObject]@{DriveLetter='W'; FileSystem='NTFS'} } + if ((Find-DriveNative) -ne 'W:\') { throw 'Wrong volume discovery' } + function Get-Volume { param($FileSystemLabel) @([PSCustomObject]@{DriveLetter='W';FileSystem='NTFS'},[PSCustomObject]@{DriveLetter='X';FileSystem='NTFS'}) } + $rejected = $false + try { Find-DriveNative | Out-Null } catch { $rejected = $true } + if (!$rejected) { throw 'Duplicate labels accepted' } + # Compilation validates the process supervisor without starting a CLI. + Add-Type -Path (Join-Path $repo 'launch/lib/DriveChild.cs') + if ([DriveChild]::Quote($null) -ne '""') { throw 'Null argument quoting failed' } + if ([DriveChild]::Quote('space argument') -ne '"space argument"') { throw 'Argument quoting failed' } + Write-Host 'PowerShell parsing, child environment, volume discovery and supervisor compilation passed.' +} finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force +} diff --git a/tests/drive-windows.test.mjs b/tests/drive-windows.test.mjs new file mode 100644 index 0000000..7e51aca --- /dev/null +++ b/tests/drive-windows.test.mjs @@ -0,0 +1,8 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +const available=spawnSync('pwsh',['-NoProfile','-Command','$PSVersionTable.PSVersion.ToString()'],{encoding:'utf8'}).status===0; +test('PowerShell parse, environment, discovery and C# compile', {skip: !available && 'pwsh is not installed'},()=>{ + const r=spawnSync('pwsh',['-NoProfile','-Command',"& ./tests/drive-windows.ps1"],{encoding:'utf8'}); + assert.equal(r.status,0,r.stdout+r.stderr); +}); diff --git a/tests/drive_provision_test.py b/tests/drive_provision_test.py new file mode 100644 index 0000000..9d7b6c4 --- /dev/null +++ b/tests/drive_provision_test.py @@ -0,0 +1,161 @@ +"""No release binaries/network access: all downloads and subprocesses are fixtures.""" +import importlib.util +import io +import json +from pathlib import Path +import tarfile +import tempfile +import unittest +from unittest.mock import patch +import urllib.error + +ROOT = Path(__file__).resolve().parent.parent +spec = importlib.util.spec_from_file_location('drive_download', ROOT / 'provision/download.py') +drive = importlib.util.module_from_spec(spec) +spec.loader.exec_module(drive) + + +class ProvisionTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='.provision-test-', dir=ROOT) + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + + def test_npm_cli_lookup_fallbacks_and_failure(self): + node = '/prep/node/bin/node' + npm = '/prep/npm-wrapper' + candidates = [ + Path('/prep/node/bin/node_modules/npm/bin/npm-cli.js'), + Path('/prep/node/lib/node_modules/npm/bin/npm-cli.js'), + Path('/usr/share/nodejs/npm/bin/npm-cli.js'), + Path('/usr/lib/node_modules/npm/bin/npm-cli.js'), + ] + for expected in candidates: + with self.subTest(expected=expected), patch.object(Path, 'is_file', lambda p: p.resolve() == expected): + self.assertEqual(drive.find_npm_cli(node, npm).resolve(), expected) + with patch.object(Path, 'is_file', return_value=False): + with self.assertRaisesRegex(ValueError, 'Cannot locate'): + drive.find_npm_cli(node, npm) + direct = self.base / 'npm-cli.js' + direct.write_text('// fixture') + self.assertEqual(drive.find_npm_cli(node, str(direct)), direct) + + def test_all_six_targets_use_pinned_official_packages(self): + self.assertEqual(set(drive.MANIFEST['targets']), {f'{osname}-{arch}' for osname in ['linux', 'darwin', 'win32'] for arch in ['x64', 'arm64']}) + for target, assets in drive.MANIFEST['targets'].items(): + with self.subTest(target=target): + for name, asset in assets.items(): + self.assertRegex(asset['sha256'], r'^[0-9a-f]{64}$') + self.assertTrue(asset['url'].startswith('https://')) + self.assertIn('/anthropics/claude-code/releases/download/v2.1.247/', assets['claude']['url']) + self.assertIn('/openai/codex/releases/download/rust-v0.161.0/codex-package-', assets['codex']['url']) + if target.startswith('linux'): + self.assertIn('musl', assets['claude']['url']) + self.assertIn('musl', assets['codex']['url']) + + def test_downloader_rejects_mismatch_before_install(self): + target = self.base / 'asset' + with patch.object(drive.urllib.request, 'urlopen', return_value=io.BytesIO(b'corrupt')): + with self.assertRaisesRegex(ValueError, 'SHA256 mismatch'): + drive.download('https://example.test/release', target, '0' * 64) + self.assertFalse(target.exists()) + self.assertFalse(target.with_name('asset.partial').exists()) + + def test_verified_cache_is_reused_without_network(self): + target = self.base / 'asset' + target.write_bytes(b'verified') + with patch.object(drive.urllib.request, 'urlopen', side_effect=AssertionError('network')): + self.assertEqual(drive.download('https://example.test/release', target, drive.sha256(target)), target) + + def test_safe_extraction_retains_package_layout_and_symlink(self): + archive = self.base / 'package.tar.gz' + with tarfile.open(archive, 'w:gz') as tar: + for name in ['bin/codex', 'codex-path/rg', 'codex-resources/resource']: + entry = tarfile.TarInfo(name); entry.size = 4; entry.mode = 0o755 + tar.addfile(entry, io.BytesIO(b'fake')) + link = tarfile.TarInfo('bin/companion'); link.type = tarfile.SYMTYPE; link.linkname = 'codex'; tar.addfile(link) + dest = self.base / 'out' + drive.extract(archive, dest) + drive.normalize(dest, 'bin/codex') + self.assertEqual((dest / 'codex-path/rg').read_bytes(), b'fake') + self.assertTrue((dest / 'bin/companion').is_symlink()) + self.assertEqual((dest / 'bin/codex').stat().st_mode & 0o777, 0o755) + + def test_traversal_is_rejected(self): + archive = self.base / 'bad.tar.gz' + with tarfile.open(archive, 'w:gz') as tar: + entry = tarfile.TarInfo('../escape'); entry.size = 1; tar.addfile(entry, io.BytesIO(b'x')) + with self.assertRaises(tarfile.FilterError): + drive.extract(archive, self.base / 'out') + self.assertFalse((self.base / 'escape').exists()) + + def test_unix_targets_include_pinned_python_assets(self): + self.assertEqual(drive.MANIFEST['python_version'], '3.12.15+20261003') + unix_targets = ['darwin-x64', 'darwin-arm64', 'linux-x64', 'linux-arm64'] + for target in unix_targets: + with self.subTest(target=target): + assets = drive.MANIFEST['targets'][target] + self.assertIn('python', assets) + python_asset = assets['python'] + self.assertRegex(python_asset['sha256'], r'^[0-9a-f]{64}$') + self.assertTrue(python_asset['url'].startswith('https://github.com/astral-sh/python-build-standalone/releases/download/20261003/cpython-3.12.')) + self.assertIn('install_only', python_asset['url']) + + def test_pip_cli_lookup_fallbacks_and_failure(self): + python = '/prep/python/bin/python3' + pip = '/prep/pip-wrapper' + candidates = [ + Path('/prep/python/bin/pip'), + Path('/prep/python/bin/pip3'), + Path('/prep/python/bin/pip3.12'), + Path('/usr/bin/pip3'), + Path('/usr/local/bin/pip3'), + ] + for expected in candidates: + with self.subTest(expected=expected), patch.object(Path, 'is_file', lambda p: p.resolve() == expected): + self.assertEqual(drive.find_pip_cli(python).resolve(), expected) + with patch.object(Path, 'is_file', return_value=False): + with self.assertRaisesRegex(ValueError, 'Cannot locate prep-machine pip CLI'): + drive.find_pip_cli(python) + direct = self.base / 'pip' + direct.write_text('#!/bin/sh') + self.assertEqual(drive.find_pip_cli(python, str(direct)), direct) + + def test_safe_extraction_normalizes_python_and_preserves_terminfo(self): + archive = self.base / 'cpython.tar.gz' + with tarfile.open(archive, 'w:gz') as tar: + for name in ['python/bin/python3', 'python/share/terminfo/t/tmux-256color', 'python/share/terminfo/x/xterm-256color']: + entry = tarfile.TarInfo(name); entry.size = 4; entry.mode = 0o755 if 'bin' in name else 0o644 + tar.addfile(entry, io.BytesIO(b'term')) + dest = self.base / 'python' + drive.extract(archive, dest) + drive.normalize(dest, 'bin/python3') + self.assertEqual((dest / 'bin/python3').read_bytes(), b'term') + self.assertEqual((dest / 'bin/python3').stat().st_mode & 0o777, 0o755) + self.assertTrue((dest / 'share/terminfo/t/tmux-256color').is_file()) + self.assertTrue((dest / 'share/terminfo/x/xterm-256color').is_file()) + + def test_checksums_cover_companions_and_tools(self): + native = self.base / 'native'; shared = self.base / 'shared' + (shared / 'checksums').mkdir(parents=True) + for name in [ + 'bin/linux-x64/claude', + 'bin/linux-x64/codex/bin/codex', + 'bin/linux-x64/codex/codex-path/rg', + 'bin/linux-x64/python/bin/python3', + 'tools/omnigent-host', + 'tools/omnigent-runtime/requirements.txt', + 'tools/DriveChild.dll' + ]: + path = native / name; path.parent.mkdir(parents=True, exist_ok=True); path.write_bytes(b'fixture') + drive.record_files(native, shared, 'linux-x64') + rows = (shared / 'checksums/linux-x64-SHA256SUMS').read_text().splitlines() + self.assertEqual(len(rows), 7) + self.assertTrue(any('python/bin/python3' in row for row in rows)) + self.assertTrue(any('tools/omnigent-host' in row for row in rows)) + self.assertTrue(any('tools/omnigent-runtime/requirements.txt' in row for row in rows)) + + +if __name__ == '__main__': + unittest.main() + diff --git a/tests/runtime-pen-drive.test.mjs b/tests/runtime-pen-drive.test.mjs index 3ed1cbb..785104f 100644 --- a/tests/runtime-pen-drive.test.mjs +++ b/tests/runtime-pen-drive.test.mjs @@ -3,90 +3,24 @@ import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; - -// Simulates a removable-drive install: the wrapper postinstall leaves the -// ~500-byte stub because FAT32/exFAT pen drives reject its hardlink step. -const temp = mkdtempSync(join(tmpdir(), 'portable-pen-drive-')); -process.env.PORTABLE_AI_DATA_DIR = join(temp, 'data'); -const { installRuntime, isStubExecutable, repairNativeBinary, executableAt, manifest } = await import('../lib/runtime.mjs'); -const { PLATFORM } = await import('../lib/paths.mjs'); -test.after(() => rmSync(temp, { recursive: true, force: true })); - -const STUB = 'echo "Error: claude native binary not installed." >&2\nexit 1\n'; -function writeWrapper(dir, { stub = true, native = true } = {}) { - const binDir = join(dir, 'node_modules/@anthropic-ai/claude-code/bin'); - mkdirSync(binDir, { recursive: true }); - writeFileSync(join(dir, 'node_modules/@anthropic-ai/claude-code/package.json'), JSON.stringify({ bin: { claude: 'bin/claude.exe' } })); - writeFileSync(join(binDir, 'claude.exe'), stub ? STUB : 'REAL-BINARY'); - for (const dep of Object.keys(manifest.dependencies)) { - if (dep === '@anthropic-ai/claude-code') continue; - mkdirSync(join(dir, 'node_modules', dep), { recursive: true }); - writeFileSync(join(dir, 'node_modules', dep, 'package.json'), JSON.stringify({ name: dep })); - } - if (native) { - const nativeFile = process.platform === 'win32' ? 'claude.exe' : 'claude'; - const nativeDir = join(dir, `node_modules/@anthropic-ai/claude-code-${PLATFORM}`); - mkdirSync(nativeDir, { recursive: true }); - writeFileSync(join(nativeDir, 'package.json'), JSON.stringify({ name: `@anthropic-ai/claude-code-${PLATFORM}` })); - writeFileSync(join(nativeDir, nativeFile), 'REAL-BINARY'); - } -} -function stubInstaller(options, seen = {}) { - return async (cmd, args, runOptions) => { - if (args[0] === '--version') { - assert.equal(readFileSync(cmd, 'utf8'), 'REAL-BINARY', 'version check must run against the repaired binary'); - return '2.1.247 (Claude Code)'; - } - seen.args = args; - writeWrapper(runOptions.cwd, options); - if (options.failInstall) throw new Error('simulated postinstall failure on exFAT'); - return ''; - }; -} - -test('stub executables are detected and repaired with a plain copy', () => { - const dir = join(temp, 'repair/current'); - writeWrapper(dir, { stub: true, native: true }); - const wrapper = join(dir, 'node_modules/@anthropic-ai/claude-code/bin/claude.exe'); - assert.equal(isStubExecutable(wrapper), true); - assert.equal(repairNativeBinary(dir), true); - assert.equal(readFileSync(wrapper, 'utf8'), 'REAL-BINARY'); - assert.equal(isStubExecutable(wrapper), false); - assert.equal(repairNativeBinary(dir), false, 'a healthy binary needs no repair'); +const temp=mkdtempSync(join(tmpdir(),'portable-native-')); +process.env.PORTABLE_AI_DATA_DIR=join(temp,'data'); +const { executableAt, manifest, installRuntime }=await import('../lib/runtime.mjs'); +test.after(()=>rmSync(temp,{recursive:true,force:true})); +test('Claude engine is never an npm dependency',()=>{ + assert.equal(manifest.dependencies['@anthropic-ai/claude-code'],undefined); + assert.ok(manifest.dependencies['@anthropic-ai/claude-agent-sdk']); }); - -test('runtime launches the platform-native package instead of the generic wrapper', () => { - const dir = join(temp, 'native/current'); - writeWrapper(dir, { stub: false, native: true }); - const expected = join(dir, `node_modules/@anthropic-ai/claude-code-${PLATFORM}`, process.platform === 'win32' ? 'claude.exe' : 'claude'); - assert.equal(executableAt(dir), expected); +test('runtime uses explicitly provisioned native executable and rejects missing paths',()=>{ + process.env.PORTABLE_AI_CLAUDE_EXECUTABLE=join(temp,'claude'); + assert.equal(executableAt(),null); + writeFileSync(process.env.PORTABLE_AI_CLAUDE_EXECUTABLE,'NATIVE'); + assert.equal(executableAt(),process.env.PORTABLE_AI_CLAUDE_EXECUTABLE); + delete process.env.PORTABLE_AI_CLAUDE_EXECUTABLE; + assert.equal(executableAt(),null); }); - -test('pen-drive install replaces the leftover stub before verifying', async () => { - const target = join(temp, 'pen/current'); - const notes = []; - const seen = {}; - await installRuntime({ target, runner: stubInstaller({ stub: true, native: true }, seen), onOutput: s => notes.push(s) }); - assert.equal(readFileSync(executableAt(target), 'utf8'), 'REAL-BINARY'); - assert.match(notes.join(''), /pen drives/i); - assert.ok(seen.args.includes('--no-bin-links'), 'link-free install flags are passed'); - assert.ok(seen.args.includes('--no-install-links'), 'directory specs must extract, never link'); -}); - -test('pen-drive install without the native package fails with a clear error', async () => { - const target = join(temp, 'broken/current'); - await assert.rejects( - installRuntime({ target, runner: stubInstaller({ stub: true, native: false }) }), - /native Claude binary is missing/ - ); -}); - -test('pen-drive install never accepts an npm error as a complete runtime', async () => { - const target = join(temp, 'exfat/current'); - writeWrapper(target, { stub: false, native: true }); - await assert.rejects( - installRuntime({ target, runner: stubInstaller({ stub: true, native: true, failInstall: true }) }), - /simulated postinstall failure/ - ); - assert.equal(readFileSync(executableAt(target), 'utf8'), 'REAL-BINARY', 'the existing runtime must remain active'); +test('dashboard install rejects incomplete dependencies and preserves prior runtime',async()=>{ + const target=join(temp,'dashboard/current');mkdirSync(target,{recursive:true});writeFileSync(join(target,'keep'),'working'); + await assert.rejects(installRuntime({target,runner:async()=>''}),/dependencies are incomplete/); + assert.equal(readFileSync(join(target,'keep'),'utf8'),'working'); }); diff --git a/tools/audit/audit.ps1 b/tools/audit/audit.ps1 new file mode 100644 index 0000000..fcb1d20 --- /dev/null +++ b/tools/audit/audit.ps1 @@ -0,0 +1,21 @@ +param( + [ValidateSet('snapshot','diff')][string]$Action, + [string]$Before, + [string]$After +) +$ErrorActionPreference = 'Stop' +if ($Action -eq 'snapshot' -and $Before) { + $roots = @($env:APPDATA, $env:LOCALAPPDATA, $env:TEMP, "$env:SystemRoot\Temp") + $roots += @(Get-ChildItem -LiteralPath $env:USERPROFILE -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name.StartsWith('.') } | ForEach-Object { $_.FullName }) + $rows = foreach ($root in ($roots | Where-Object { $_ } | Select-Object -Unique)) { + Get-ChildItem -LiteralPath $root -Force -Recurse -File -ErrorAction SilentlyContinue | + ForEach-Object { '{0}|{1}|{2}' -f $_.FullName, $_.Length, $_.LastWriteTimeUtc.Ticks } + } + $rows | Sort-Object -Unique | Set-Content -LiteralPath $Before -Encoding UTF8 + Write-Host "Snapshot: $Before (unreadable paths skipped)" +} elseif ($Action -eq 'diff' -and $Before -and $After) { + Compare-Object @(Get-Content -LiteralPath $Before) @(Get-Content -LiteralPath $After) +} else { + throw 'Usage: audit.ps1 snapshot DRIVE-OUTPUT | diff BEFORE AFTER' +} diff --git a/tools/audit/audit.sh b/tools/audit/audit.sh new file mode 100755 index 0000000..6c81555 --- /dev/null +++ b/tools/audit/audit.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Metadata only; no file contents or credentials are read. +set -eu +usage() { printf 'Usage: audit.sh snapshot DRIVE-OUTPUT [ROOT ...] | diff BEFORE AFTER\n' >&2; exit 2; } +[ "$#" -gt 0 ] || usage +case $1 in + snapshot) + [ "$#" -ge 2 ] || usage + audit_output=$2 + shift 2 + mkdir -p "$(dirname -- "$audit_output")" + audit_output_dir=$(CDPATH='' cd -- "$(dirname -- "$audit_output")" && pwd -P) + audit_output=$audit_output_dir/$(basename -- "$audit_output") + umask 077 + audit_scratch=$(mktemp -d "$audit_output_dir/.audit-scratch.XXXXXX") + trap 'rm -rf "$audit_scratch"' 0 + trap 'exit 130' INT + trap 'exit 143' TERM HUP + # Explicit -T plus all conventional temp variables keep sort spills on + # the drive. The exit trap removes scratch on success and failure. + TMPDIR=$audit_scratch TEMP=$audit_scratch TMP=$audit_scratch + export TMPDIR TEMP TMP + if [ "$#" -eq 0 ]; then + set -- "$HOME"/.[!.]* "$HOME"/..?* "$HOME/Library" /tmp /var/tmp + fi + { + for audit_path do + [ -e "$audit_path" ] || continue + # -H follows only command-line root symlinks (macOS /tmp and + # /var/tmp), without traversing arbitrary links inside a tree. + if [ "$(uname -s)" = Darwin ]; then + find -H "$audit_path" \( -path "$audit_scratch" -o -path "$audit_output" \) -prune -o -type f -exec stat -f '%N|%z|%m' {} + 2>/dev/null || : + else + find -H "$audit_path" \( -path "$audit_scratch" -o -path "$audit_output" \) -prune -o -type f -printf '%p|%s|%T@\n' 2>/dev/null || : + fi + done + } > "$audit_scratch/metadata" + LC_ALL=C sort -T "$audit_scratch" -u "$audit_scratch/metadata" > "$audit_scratch/snapshot" + mv -f "$audit_scratch/snapshot" "$audit_output" + printf 'Snapshot: %s (unreadable paths skipped)\n' "$audit_output" + ;; + diff) + [ "$#" -eq 3 ] || usage + diff -u "$2" "$3" || { audit_status=$?; [ "$audit_status" -eq 1 ] || exit "$audit_status"; } + ;; + *) usage;; +esac diff --git a/tools/bootstrap.ps1 b/tools/bootstrap.ps1 index bf0825e..ba831b4 100644 --- a/tools/bootstrap.ps1 +++ b/tools/bootstrap.ps1 @@ -1,121 +1,3 @@ -$ErrorActionPreference = 'Stop' -$ProgressPreference = 'SilentlyContinue' -# Windows PowerShell 5.1 does not preload System.Net.Http - load it for the -# streaming downloader below (PowerShell 7+ already has it; harmless there). -Add-Type -AssemblyName System.Net.Http -$ProjectRoot = Split-Path $PSScriptRoot -Parent -$Version = '24.21.0' -$Arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -eq 'Arm64') { 'arm64' } else { 'x64' } -$NodeDir = Join-Path $ProjectRoot "engine/node-win32-$Arch" -$NodeExe = Join-Path $NodeDir 'node.exe' - -function Get-PortableNodeVersion { - try { - if (Test-Path $NodeExe) { return (& $NodeExe --version) } - } catch {} - return $null -} - -function Invoke-PortableDownload($Url, $Destination, $Label) { - $attempt = 0 - while ($attempt -lt 3) { - $attempt++ - if (Test-Path $Destination) { Remove-Item $Destination -Force } - try { - $handler = New-Object System.Net.Http.HttpClientHandler - $handler.AllowAutoRedirect = $true - $client = New-Object System.Net.Http.HttpClient($handler) - $client.Timeout = [TimeSpan]::FromMinutes(30) - $client.DefaultRequestHeaders.UserAgent.ParseAdd('ClaudeCode-Portable/2.0') - $response = $client.GetAsync($Url, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() - [void]$response.EnsureSuccessStatusCode() - $total = $response.Content.Headers.ContentLength - $stream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() - $file = [System.IO.File]::OpenWrite($Destination) - try { - $buffer = New-Object byte[] 81920 - $downloaded = [long]0 - $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() - $lastShown = [TimeSpan]::Zero - while ($true) { - $read = $stream.Read($buffer, 0, $buffer.Length) - if ($read -le 0) { break } - $file.Write($buffer, 0, $read) - $downloaded += $read - $elapsed = $stopwatch.Elapsed - if (($elapsed - $lastShown).TotalMilliseconds -ge 500) { - $lastShown = $elapsed - $mbDone = [math]::Round($downloaded / 1MB, 1) - $speed = if ($elapsed.TotalSeconds -gt 0) { $downloaded / $elapsed.TotalSeconds } else { 0 } - $mbps = [math]::Round($speed / 1MB, 2) - if ($total -and $total -gt 0) { - $mbTotal = [math]::Round($total / 1MB, 1) - $pct = [math]::Min(100, [math]::Round($downloaded * 100 / $total)) - $remaining = $total - $downloaded - $eta = if ($speed -gt 0) { [TimeSpan]::FromSeconds($remaining / $speed).ToString('mm\:ss') } else { '--:--' } - Write-Host ("`r {0}: {1}% ({2} / {3} MB) {4} MB/s ETA {5} " -f $Label, $pct, $mbDone, $mbTotal, $mbps, $eta) -NoNewline - } else { - Write-Host ("`r {0}: {1} MB downloaded {2} MB/s " -f $Label, $mbDone, $mbps) -NoNewline - } - } - } - } finally { - $file.Close() - $stream.Close() - } - Write-Host '' - if ($total -and (Get-Item $Destination).Length -ne $total) { throw 'Downloaded file size does not match the server (interrupted download)' } - return - } catch { - Write-Host '' - Write-Host (" Attempt $attempt failed: $($_.Exception.Message)") -ForegroundColor Yellow - if ($attempt -ge 3) { throw "Download failed after 3 attempts: $Url" } - Start-Sleep -Seconds 3 - } finally { - if ($client) { $client.Dispose() } - if ($response) { $response.Dispose() } - } - } -} - -$NeedsNode = ((Get-PortableNodeVersion) -ne "v$Version") -if ($NeedsNode) { - $ArchiveName = "node-v$Version-win-$Arch.zip" - $Base = "https://nodejs.org/dist/v$Version" - $Engine = Join-Path $ProjectRoot 'engine' - New-Item -ItemType Directory -Force -Path $Engine | Out-Null - try { - $drive = (Get-Item $ProjectRoot).PSDrive - if ($drive -and $drive.Free -ne $null) { - Write-Host (" Drive {0}: {1} GB free" -f $drive.Name, [math]::Round($drive.Free / 1GB, 1)) -ForegroundColor DarkGray - } - } catch {} - $Archive = Join-Path $Engine $ArchiveName - Write-Host "Downloading portable Node.js v$Version (~30MB) from nodejs.org..." - Invoke-PortableDownload "$Base/$ArchiveName" $Archive 'Node.js' - Write-Host 'Verifying checksum...' - $Checksums = (Invoke-WebRequest "$Base/SHASUMS256.txt" -UseBasicParsing).Content - $Expected = $null - foreach ($line in ($Checksums -split "`n")) { - if ($line -match '^([a-fA-F0-9]{64})\s+[\* ]?node-v\S+-win-\S+\.zip\s*$' -and $line.Trim().EndsWith(" $ArchiveName")) { - $Expected = $matches[1].ToLower() - } - } - if (!$Expected -or (Get-FileHash $Archive -Algorithm SHA256).Hash.ToLower() -ne $Expected) { - Remove-Item $Archive -Force -ErrorAction SilentlyContinue - throw 'Node.js checksum verification failed - the download was removed, please run START.bat again' - } - Write-Host 'Extracting portable Node.js... (one-time setup)' - $Extracted = Join-Path $Engine "node-v$Version-win-$Arch" - if (Test-Path $Extracted) { Remove-Item $Extracted -Recurse -Force } - Expand-Archive -Path $Archive -DestinationPath $Engine -Force - if (Test-Path $NodeDir) { Move-Item $NodeDir "$NodeDir.previous.$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" } - Move-Item $Extracted $NodeDir - Remove-Item $Archive -Force -ErrorAction SilentlyContinue - $Installed = Get-PortableNodeVersion - if ($Installed -ne "v$Version") { throw "Portable Node.js verification failed (got '$Installed')" } - Write-Host "Portable Node.js $Installed ready." -ForegroundColor Green -} -$env:PATH = "$NodeDir;$env:PATH" -& $NodeExe (Join-Path $ProjectRoot 'tools/launcher.mjs') @args +# Compatibility entry; downloads happen only on the prep machine. +& (Join-Path $PSScriptRoot '..\launch\windows.ps1') @args exit $LASTEXITCODE diff --git a/tools/launcher.mjs b/tools/launcher.mjs index fb73225..1415316 100644 --- a/tools/launcher.mjs +++ b/tools/launcher.mjs @@ -5,7 +5,7 @@ import { existsSync } from 'node:fs'; import { ROOT } from '../lib/paths.mjs'; import { readConfig, saveProfile } from '../lib/config.mjs'; import { PROVIDERS, providerEnvironment } from '../lib/providers.mjs'; -import { executableAt, installRuntime, isStubExecutable, repairNativeBinary, runtimeStatus, rollbackRuntime, run } from '../lib/runtime.mjs'; +import { executableAt, installRuntime, runtimeStatus, rollbackRuntime, run } from '../lib/runtime.mjs'; import { startAdapter } from '../lib/adapter.mjs'; import { startExternalAdapter } from '../lib/external-adapter.mjs'; import { startResponsesAdapter } from '../lib/responses-adapter.mjs'; @@ -22,12 +22,7 @@ async function main() { saveProfile({provider:'ollama',auth:'api',baseUrl:PROVIDERS.ollama.baseUrl,key:'',model:args[0]}); return console.log(`Ollama profile configured for ${args[0]}. Start its server in System before launching a session.`); } - let executable = executableAt(); - if (executable && isStubExecutable(executable)) { - process.stdout.write('Repairing the portable Claude Code native executable...\n'); - if (!repairNativeBinary()) executable = null; - } - if (!executable) await installRuntime({ onOutput: s => process.stdout.write(s) }); + if (!executableAt()) throw new Error('Native Claude binary is missing. Use the AI-SHARED launcher after provisioning.'); if (!command) { console.log('\n CLAUDECODE-PORTABLE\n Official Claude Code · Your choice of model\n\n 1 Open studio dashboard\n 2 Launch Claude Code terminal\n 3 Configure providers\n 4 Set up local models\n 5 Repair / update pinned runtime\n 6 Roll back runtime\n'); const rl = createInterface({ input: process.stdin, output: process.stdout }); diff --git a/tools/omnigent-host b/tools/omnigent-host new file mode 100755 index 0000000..1a8bde9 --- /dev/null +++ b/tools/omnigent-host @@ -0,0 +1,73 @@ +#!/bin/bash +# Relative wrapper for portable Omnigent Host agent. +# Usage: omnigent-host [OPTIONS] [SERVER_URL] +set -euo pipefail + +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P) +drive_shared= +if [ -f "$script_dir/../launch/lib/drive.sh" ]; then + drive_shared=$(CDPATH='' cd -- "$script_dir/.." && pwd -P) +elif [ -f "$script_dir/../../AI-SHARED/launch/lib/drive.sh" ]; then + drive_shared=$(CDPATH='' cd -- "$script_dir/../../AI-SHARED" && pwd -P) +elif [ -f "$script_dir/launch/lib/drive.sh" ]; then + drive_shared="$script_dir" +fi + +if [ -z "$drive_shared" ] || [ ! -f "$drive_shared/launch/lib/drive.sh" ]; then + echo "Cannot resolve AI-SHARED volume relative to $script_dir" >&2 + exit 1 +fi + +drive_os=$(uname -s | tr '[:upper:]' '[:lower:]') +case "$drive_os" in + linux|darwin) ;; + *) echo "Unsupported operating system: $drive_os (Linux and macOS supported)" >&2; exit 1 ;; +esac + +# Source drive and omnigent helpers +# shellcheck source=launch/lib/drive.sh +. "$drive_shared/launch/lib/drive.sh" +# shellcheck source=launch/lib/session.sh +. "$drive_shared/launch/lib/session.sh" +# shellcheck source=launch/lib/omnigent-host.sh +. "$drive_shared/launch/lib/omnigent-host.sh" + +drive_native=$(drive_discover "$drive_os" "$drive_shared") || exit 1 +drive_target="$drive_os-$(drive_arch)" || exit 1 + +# Setup drive environment (HOME, TMPDIR, CODEX_HOME, CLAUDE_CONFIG_DIR, etc.) +drive_environment "$drive_shared" "$drive_native" "$drive_target" || exit 1 + +drive_python="$drive_native/bin/$drive_target/python" +[ -x "$drive_python/bin/python3" ] || { drive_fail "Portable Python runtime not found at $drive_python. Provision this architecture first."; exit 1; } + +# Setup Python, terminfo, certs, and omnigent runtime +drive_omnigent_environment "$drive_native" "$drive_target" || exit 1 + +# Resolve server URL and machine identity +drive_server_url=$(drive_omnigent_server_url "$drive_shared" "$@") || exit 1 +drive_omnigent_host_identity "$drive_shared" "$drive_python" "$drive_os" || exit 1 + +drive_filtered_args=() +drive_skip=0 +for drive_arg in "$@"; do + if [ "$drive_skip" -eq 1 ]; then + drive_skip=0 + continue + fi + case "$drive_arg" in + --server=*) + ;; + --server) + drive_skip=1 + ;; + http://*|https://*|ws://*|wss://*) + ;; + *) + drive_filtered_args+=("$drive_arg") + ;; + esac +done + +exec "$drive_python/bin/python3" -m omnigent.cli host "$drive_server_url" --no-open "${drive_filtered_args[@]}" + diff --git a/tools/omnigent-runtime-requirements.txt b/tools/omnigent-runtime-requirements.txt new file mode 100644 index 0000000..dbf58bc --- /dev/null +++ b/tools/omnigent-runtime-requirements.txt @@ -0,0 +1,31 @@ +# Omnigent Host portable runtime dependencies (pinned, binary-only) +omnigent==0.16.0 +omnigent-client==0.16.0 +omnigent-ui-sdk==0.16.0 +click==8.5.0 +fastapi==0.142.1 +starlette==1.7.0 +uvicorn==0.54.0 +websockets==14.2 +httpx==0.28.1 +psutil==7.2.2 +pydantic==2.13.5 +pydantic-settings==2.15.0 +pydantic_core==2.46.5 +PyYAML==6.0.3 +rich==14.3.4 +prompt_toolkit==3.0.53 +mcp==1.30.0 +alembic==1.20.0 +SQLAlchemy==2.1.1 +pexpect==4.9.0 +pyte==0.8.2 +filelock==4.0.7 +certifi==2026.7.22 +keyring==25.7.0 +cel-python==0.5.0 +google-re2==1.1.20251105 +zstandard==0.25.0 +tiktoken==0.14.0 +claude-agent-sdk==0.2.162 +openai-agents==0.13.6 diff --git a/tools/runtime-manifest.json b/tools/runtime-manifest.json index ffc3727..b7d70ed 100644 --- a/tools/runtime-manifest.json +++ b/tools/runtime-manifest.json @@ -2,7 +2,6 @@ "name": "claudecode-portable-runtime", "private": true, "dependencies": { - "@anthropic-ai/claude-code": "2.1.247", "@anthropic-ai/claude-agent-sdk": "0.3.247", "claude-adapter": "2.2.1", "marked": "18.0.11", diff --git a/tools/setup_local_models.sh b/tools/setup_local_models.sh index a4afdc0..ae35f87 100755 --- a/tools/setup_local_models.sh +++ b/tools/setup_local_models.sh @@ -11,7 +11,6 @@ DIM='\033[90m'; MAGENTA='\033[35m'; BOLD='\033[1m'; RESET='\033[0m' SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" DATA_DIR="$ROOT_DIR/data" -ENV_FILE="$DATA_DIR/ai_settings.env" MODELS_DIR="$DATA_DIR/models" OLLAMA_DIR="$DATA_DIR/ollama" @@ -83,7 +82,6 @@ if [ -d "$MANIFEST_DIR" ]; then # Get size from manifest JSON SIZE_BYTES=$(grep -o '"size"[[:space:]]*:[[:space:]]*[0-9]*' "$tag_file" 2>/dev/null | awk -F: '{s+=$2} END{print s+0}') [ "${SIZE_BYTES:-0}" -lt 100000000 ] 2>/dev/null && continue - SIZE_GB=$(awk "BEGIN {printf \"%.1f\", ${SIZE_BYTES:-0}/1073741824}") DL_TAGS+=("$full_tag") DL_NAMES+=("$full_tag") done @@ -104,7 +102,7 @@ echo -e " ${DIM}Browse ALL models here: ${CYAN}https://ollama.com/library${ echo -e " ${DIM}------------------------------------------------${RESET}" echo -e " ${DIM}Enter number(s) separated by commas (e.g. 1,5)${RESET}" echo "" -read -p " Your choice: " USER_CHOICE +read -r -p " Your choice: " USER_CHOICE if [ -z "$USER_CHOICE" ]; then echo -e "\n ${YELLOW}No input! Defaulting to [3] Gemma 4 E4B...${RESET}" @@ -148,7 +146,7 @@ done if [ "$HAS_CUSTOM" -eq 1 ]; then echo "" echo -e "${GREEN} ---- Custom Model Setup ----${RESET}" - read -p " Ollama Tag (e.g. mistral-nemo, phi3): " CUSTOM_TAG + read -r -p " Ollama Tag (e.g. mistral-nemo, phi3): " CUSTOM_TAG if [ -n "$CUSTOM_TAG" ]; then SELECTED_MODELS+=("99") SELECTED_NAMES+=("Custom: $CUSTOM_TAG") @@ -168,7 +166,7 @@ if [ -n "$FREE_KB" ]; then echo -e "\n ${DIM}USB Free Space: ~${FREE_GB} GB${RESET}" if [ "$FREE_GB" -lt 5 ]; then echo -e " ${RED}WARNING: You have very low disk space (${FREE_GB} GB). Models may fail to download.${RESET}" - read -p " Continue anyway? (y/N): " CONT + read -r -p " Continue anyway? (y/N): " CONT [[ ! "$CONT" =~ ^[Yy]$ ]] && exit 1 fi fi @@ -190,11 +188,11 @@ else # If it extracts as bin/ollama, move it if [ -f "$OLLAMA_DIR/bin/ollama" ]; then mv "$OLLAMA_DIR/bin/ollama" "$OLLAMA_EXE" - rm -rf "$OLLAMA_DIR/bin" + rm -rf "${OLLAMA_DIR:?}/bin" fi chmod +x "$OLLAMA_EXE" - [ "$PLATFORM" = "darwin" ] && xattr -d com.apple.quarantine "$OLLAMA_EXE" 2>/dev/null || true + if [ "$PLATFORM" = "darwin" ]; then xattr -d com.apple.quarantine "$OLLAMA_EXE" 2>/dev/null || true; fi echo -e " ${GREEN}Engine Installed successfully!${RESET}" else echo -e " ${RED}ERROR: Failed to download engine!${RESET}" @@ -212,7 +210,7 @@ sleep 5 ERRORS=0 for i in "${!SELECTED_TAGS[@]}"; do - TAG="${SELECTED_TAGS[$i]}" + TAG="${SELECTED_TAGS[i]}" NAME="${SELECTED_NAMES[$i]}" if [[ "$TAG" == http* ]] && [[ "$TAG" == *.gguf* ]]; then @@ -239,7 +237,7 @@ for i in "${!SELECTED_TAGS[@]}"; do # Only skip if file is FULLY downloaded AND ollama has it imported if [ "$FILE_COMPLETE" -eq 1 ] && "$OLLAMA_EXE" show "$MODEL_NAME" >/dev/null 2>&1; then echo -e " ${GREEN}✅ $NAME fully downloaded ($(( EXISTING_SIZE / 1024 / 1024 )) MB) & imported — skipping!${RESET}" - SELECTED_TAGS[$i]="$MODEL_NAME" + SELECTED_TAGS[i]="$MODEL_NAME" continue fi @@ -263,7 +261,7 @@ for i in "${!SELECTED_TAGS[@]}"; do pushd "$MODELS_DIR" >/dev/null if "$OLLAMA_EXE" create "$MODEL_NAME" -f "Modelfile-$MODEL_NAME"; then echo -e " ${GREEN}Import complete!${RESET}" - SELECTED_TAGS[$i]="$MODEL_NAME" + SELECTED_TAGS[i]="$MODEL_NAME" else echo -e " ${RED}FAILED to import custom model: $FILE_NAME${RESET}" ERRORS=$((ERRORS+1)) @@ -296,7 +294,7 @@ wait "$SERVER_PID" 2>/dev/null || true # Record Models for the Dashboard if [ ${#SELECTED_TAGS[@]} -gt 0 ]; then for i in "${!SELECTED_TAGS[@]}"; do - echo "${SELECTED_TAGS[$i]}|${SELECTED_NAMES[$i]}|LOCAL" >> "$MODELS_DIR/installed-models.txt" + echo "${SELECTED_TAGS[i]}|${SELECTED_NAMES[$i]}|LOCAL" >> "$MODELS_DIR/installed-models.txt" done sort -u "$MODELS_DIR/installed-models.txt" -o "$MODELS_DIR/installed-models.txt" fi