diff --git a/.env.example b/.env.example index a08660c..5e5e052 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,13 @@ BLOOM_SSH_ENABLED=0 # BLOOM_SSH_MAX_LEASE_MINUTES=15 BLOOM_NFS_ENABLED=0 # BLOOM_NFS_KERNEL_CONFIG=./artifacts/nfs-kernel/vmlinux-6.1.155-nfsd.config +# Storage quota for workspace volumes in MiB (16–5120, default 512). +# BLOOM_STORAGE_QUOTA_MIB=512 +# Comma-separated list of Bloom Petals to pre-install in every workspace. +# Default is empty (no auto-installed Petals). Users can still `bloom install` +# explicitly from the terminal (subject to the egress proxy allowlist). +# Max 32 entries, alphanumeric/dash/underscore only. +# BLOOM_PREINSTALLED_PETALS=gasless,enso # Set controlled for the curated public package allowlist. Raw internet mode is # rejected in public deployments. # BLOOM_VM_EGRESS=controlled diff --git a/.gitignore b/.gitignore index 4c52d4b..ea5992d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ artifacts/ *.log .env .DS_Store +__pycache__/ +ops/guest-control/target/ diff --git a/ops/bloom/guest-bootstrap.sh b/ops/bloom/guest-bootstrap.sh index ddd325c..ade82bf 100755 --- a/ops/bloom/guest-bootstrap.sh +++ b/ops/bloom/guest-bootstrap.sh @@ -2,8 +2,15 @@ set -eu # Provision Bloom inside a workspace using the authenticated login address as a -# watch-only wallet. This helper has no signer path by design: it accepts one -# EVM address, never a key/passphrase, and refuses to run beside signer state. +# watch-only wallet. This helper has no direct signer path: it accepts one EVM +# address, never a key/passphrase, and refuses to run beside signer state. +# +# Transaction signing uses Bloom's Sealed Approval ceremony: when a workspace +# process stages a transaction, Bloom writes plan.md and approval_challenge.json +# to the VFS outbox. The ceremony URL (http://localhost:18734/ceremony/) +# is surfaced to the user. The user must connect via SSH with port forwarding +# (-L 18734:localhost:18734) and open the ceremony URL in their local browser +# to complete the WebAuthn approval. Private keys never enter the workspace VM. BLOOM_BIN=${BLOOM_BIN:-/usr/local/bin/bloom} BLOOM_WORKSPACE_ROOT=/workspace @@ -78,6 +85,30 @@ verify_watch_only_keystore() { [ "$stored" = "$expected" ] || die 'stored watch address does not match the authenticated login address' } +validate_preinstalled_petals() { + config=$1 + approved=$2 + # Extract the preinstalled array contents from config.toml. + # Handles both single-line (preinstalled = ["a", "b"]) and multi-line forms. + entries=$(sed -n '/^preinstalled = \[/,/^\]/p' "$config" | tr -d '\n' | sed 's/.*\[//; s/\].*//') + # If empty array, nothing to check. + [ -n "$(printf '%s' "$entries" | tr -d ' \t')" ] || return 0 + # Extract quoted values. + configured=$(printf '%s' "$entries" | tr ',' '\n' | sed 's/^[ \t]*"//; s/"[ \t]*$//' | grep -v '^$' || true) + # Build approved set from comma-separated list. + approved_set=$(printf '%s' "$approved" | tr ',' '\n' | grep -v '^$' || true) + # Check each configured petal is in the approved set. + while IFS= read -r petal; do + [ -n "$petal" ] || continue + case "$approved_set" in + *"$petal"*) ;; + *) die "preinstalled Petal '$petal' is not in the operator-approved list (BLOOM_PREINSTALLED_PETALS)" ;; + esac + done </dev/null 2>&1 || die "Bloom binary is unavailable: $BLOOM_BIN" @@ -89,37 +120,59 @@ initialize_watch_wallet() { # `bloom init` provisions network-fetched Petals by default in v0.1.3. # First let a non-provisioning read command create Bloom's complete default - # config, then atomically persist the explicit empty-list opt-out. This keeps - # bootstrap offline and prevents remote executable content from entering the - # curated guest implicitly. + # config, then atomically persist the operator-approved Petal list. This keeps + # bootstrap deterministic: only operator-curated Petals enter the guest, not + # arbitrary remote executable content. Users can still `bloom install` explicit + # additions from the terminal (subject to the egress proxy allowlist). config=${BLOOM_HOME}/config.toml + operator_petals=${BLOOM_PREINSTALLED_PETALS:-} if [ ! -e "$config" ]; then "$BLOOM_BIN" --home "$BLOOM_HOME" --quiet status >/dev/null [ -f "$config" ] && [ ! -L "$config" ] || die 'Bloom did not create a regular config file' [ "$(grep -c '^preinstalled = ' "$config")" -eq 1 ] || \ die 'Bloom config does not contain exactly one preinstalled Petals setting' config_staging=${config}.workspace-bootstrap - awk ' + # Build the TOML array value from the operator-approved comma-separated list. + toml_array='[]' + if [ -n "$operator_petals" ]; then + toml_array='' + remainder=$operator_petals + while [ -n "$remainder" ]; do + petal=${remainder%%,*} + [ -n "$petal" ] || { remainder=${remainder#*,}; continue; } + case "$petal" in + *[!a-zA-Z0-9_-]*) die "invalid petal name in BLOOM_PREINSTALLED_PETALS: $petal" ;; + esac + toml_array="${toml_array}\"$(printf '%s' "$petal" | sed 's/\\/\\\\/g; s/"/\\"/g')\", " + remainder=${remainder#$petal} + remainder=${remainder#,} + done + toml_array="[${toml_array%, }]" + fi + awk -v replacement="preinstalled = $toml_array" ' BEGIN { in_preinstalled = 0; seen = 0 } in_preinstalled == 1 { if ($0 ~ /^]$/) { in_preinstalled = 0 } next } /^preinstalled = \[/ { - print "preinstalled = []" + print replacement seen++ if ($0 !~ /]$/) { in_preinstalled = 1 } next } { print } END { if (seen != 1 || in_preinstalled != 0) exit 42 } - ' "$config" > "$config_staging" || die 'could not safely disable preinstalled Petals' + ' "$config" > "$config_staging" || die 'could not safely set preinstalled Petals' chmod 0600 "$config_staging" mv -- "$config_staging" "$config" else [ -f "$config" ] && [ ! -L "$config" ] || die 'Bloom config must be a regular file' - grep -q '^preinstalled = \[\]$' "$config" || \ - die 'preinstalled Petals must remain disabled in the watch-only workspace' + # On re-bootstrap (persistent workspace), validate that the preinstalled + # list contains only operator-approved entries. This allows user-initiated + # `bloom install` additions that match the approved list while catching + # unexpected entries that may have entered through compromise. + validate_preinstalled_petals "$config" "$operator_petals" fi "$BLOOM_BIN" --home "$BLOOM_HOME" --quiet init >/dev/null diff --git a/ops/guest-control/Cargo.lock b/ops/guest-control/Cargo.lock new file mode 100644 index 0000000..ae2e135 --- /dev/null +++ b/ops/guest-control/Cargo.lock @@ -0,0 +1,167 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bloom-guest-control" +version = "1.0.0" +dependencies = [ + "base64", + "libc", + "once_cell", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/ops/guest-control/Cargo.toml b/ops/guest-control/Cargo.toml new file mode 100644 index 0000000..1b48b0d --- /dev/null +++ b/ops/guest-control/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "bloom-guest-control" +version = "1.0.0" +edition = "2021" +description = "Bounded guest-side file, job, and Bloom status service for Bloom Workspaces" +license = "MIT" + +[[bin]] +name = "bloom-guest-control" +path = "src/main.rs" + +[profile.release] +opt-level = 3 +lto = true +strip = true +panic = "abort" +codegen-units = 1 + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" +regex = "1" +libc = "0.2" +once_cell = "1" diff --git a/ops/guest-control/README.md b/ops/guest-control/README.md index d386465..bf09b63 100644 --- a/ops/guest-control/README.md +++ b/ops/guest-control/README.md @@ -1,50 +1,41 @@ -# Guest control service +# bloom-guest-control -`bloom-guest-control.py` is the guest-owned implementation of protocol v1. It -supports bounded file chunks, structured jobs, absolute-cursor log reads, -process-group cancellation, watch-only Bloom status, and one-time SSH/NFS -configuration. It has no TCP listener: the production transports are QEMU virtio-serial stdio, AF_VSOCK port -5001, and a mode-0600 guest-local Unix socket for `bloom-workspace`. Stdio and -socket transports may run concurrently and share one bounded job table. +A single Rust binary that serves as both the guest-side control daemon and the +guest-local CLI client. -The image should install: +## Server mode -- `bloom-guest-control.py` at `/usr/local/libexec/bloom-guest-control`; -- `bloom-workspace` at `/usr/local/bin/bloom-workspace`. +The server accepts the version-1 JSON-line protocol over AF_VSOCK, a guest-local +Unix socket, and/or stdio. It provides: -Start the controller as root so an untrusted workspace process cannot signal or -ptrace it: +- Bounded file CRUD (`fs.list`, `fs.read`, `fs.write`, `fs.delete`) +- Structured job execution (`job.start`, `job.status`, `job.cancel`) with + `prlimit` + `setpriv` isolation as the unprivileged workspace account +- Bloom status reporting (`bloom.status`) +- Ceremony pending scan (`ceremony.pending`) +- Connection configuration (`connections.configure`) + +Each job is exec'd via `prlimit` and `setpriv` as the unprivileged workspace +account with no capabilities and no-new-privileges. + +## Client mode + +When invoked with a subcommand (`status`, `hello`, `files`, `jobs`), the binary +runs as a guest-local CLI client that connects to the server's Unix socket. + +## Building ```sh -/usr/local/libexec/bloom-guest-control \ - --workspace /workspace \ - --workspace-quota-bytes 134217728 \ - --job-uid 1000 --job-gid 1000 \ - --unix-socket /run/bloom/guest-control.sock \ - --vsock-port 5001 +cargo build --release ``` -For QEMU, replace `--vsock-port 5001` with `--stdio` while retaining the Unix -socket for the guest helper. `.` is the explicit `/workspace` sentinel for -directory listing and job cwd only; file read/write/delete operations require a -non-root relative path. File write/delete responses include current -`usedBytes` and `quotaBytes` without returning file content. - -Jobs never pass through a shell added by the service. The requested argv is -executed by `prlimit` and `setpriv`: UID/GID 1000, empty capability sets, -no-new-privileges, a private process group, 64 processes, 64 descriptors, no -core dumps, and 64 MiB per-file output. User-provided environment keys are -limited to documented safe names and `APP_`, `JOB_`, or `TEST_` namespaces. -The controller copies only the operator's controlled proxy and CA path settings; -it does not inherit wallet, control-plane, or host credentials. - -`bloom.status` reports only the public watch address, whether `/bloom` is -mounted, and explicit false values for wallet signing and transactions. It -never reads or returns passphrases, private keys, cookies, or session material. - -`connections.configure` is accepted once per guest scope and only by the -root-owned controller. It validates an Ed25519 CA **public** key, creates an -ephemeral host key, writes wallet/workspace principals, and starts the locked -guest sshd. With the verified QEMU NFSD kernel and a persistent volume it may -also export guest-loopback NFSv4. The CA private key and user private key never -enter the guest. +For the Alpine guest image (static musl): + +```sh +cargo build --release --target x86_64-unknown-linux-musl +``` + +## Binary layout in the guest image + +- `bloom-guest-control` at `/usr/local/libexec/bloom-guest-control` (server mode) +- `bloom-workspace` at `/usr/local/bin/bloom-workspace` (client mode — same binary) diff --git a/ops/guest-control/bloom-guest-control.py b/ops/guest-control/bloom-guest-control.py deleted file mode 100755 index c58d457..0000000 --- a/ops/guest-control/bloom-guest-control.py +++ /dev/null @@ -1,1065 +0,0 @@ -#!/usr/bin/env python3 -"""Bounded guest-side file, job, and Bloom status service. - -The service is intentionally dependency-free. It accepts the version-1 -JSON-line protocol over AF_VSOCK and/or a guest-local Unix socket. The service -may run as root so jobs cannot signal or ptrace it, but every job is exec'd via -prlimit + setpriv as the unprivileged workspace account with no capabilities -and no-new-privileges. -""" - -from __future__ import annotations - -import argparse -import base64 -import binascii -import hashlib -import errno -import json -import os -import posixpath -import re -import select -import shutil -import signal -import socket -import stat -import subprocess -import threading -import time -import uuid -from collections import OrderedDict -from dataclasses import dataclass, field -from typing import Any, BinaryIO - - -PROTOCOL_VERSION = 1 -MAX_FRAME_BYTES = 384 * 1024 -MAX_FILE_CHUNK_BYTES = 256 * 1024 -MAX_FILE_BYTES = 8 * 1024 * 1024 -MAX_LOG_CHUNK_BYTES = 256 * 1024 -MAX_LOG_BYTES = 1024 * 1024 -MAX_LIST_ENTRIES = 1000 -MAX_SCAN_ENTRIES = 20_000 -MAX_ACTIVE_JOBS = 2 -MAX_RETAINED_JOBS = 64 -MAX_JOB_PROCESSES = 64 -MAX_JOB_FILE_BYTES = 64 * 1024 * 1024 -MAX_JOB_TIMEOUT_MS = 2 * 60 * 60 * 1000 -JOB_KILL_GRACE_SECONDS = 1.0 - -REQUEST_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$") -ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$") -EVM_ADDRESS = re.compile(r"^0x[0-9a-f]{40}$") -SSH_CA_PUBLIC_KEY = re.compile(r"^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$") -WORKSPACE_ID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") -USER_ENV_EXACT = frozenset( - { - "CI", - "DEBUG", - "FORCE_COLOR", - "LANG", - "LC_ALL", - "LOG_LEVEL", - "NODE_ENV", - "NO_COLOR", - "PYTHONUNBUFFERED", - "RUST_BACKTRACE", - "RUST_LOG", - "TERM", - "TZ", - } -) -USER_ENV_PREFIXES = ("APP_", "JOB_", "TEST_") -SYSTEM_PROXY_ENV = ("HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy") - - -class ControlError(Exception): - def __init__(self, code: str, message: str): - super().__init__(message) - self.code = code - - -def now_ms() -> int: - return int(time.time() * 1000) - - -def require(condition: bool, code: str, message: str) -> None: - if not condition: - raise ControlError(code, message) - - -def require_exact_keys(value: dict[str, Any], expected: set[str]) -> None: - require(set(value) == expected, "invalid_request", "request fields do not match the operation contract") - - -def validate_request(raw: Any) -> dict[str, Any]: - require(isinstance(raw, dict), "invalid_request", "request must be an object") - require(raw.get("version") == PROTOCOL_VERSION, "invalid_request", "unsupported guest protocol version") - require(isinstance(raw.get("id"), str) and REQUEST_ID.fullmatch(raw["id"]), "invalid_request", "invalid request id") - operation = raw.get("operation") - require(isinstance(operation, str), "invalid_request", "operation is required") - base = {"version", "id", "operation"} - fields = { - "hello": base, - "fs.list": base | {"path"}, - "fs.read": base | {"path", "offset", "maxBytes"}, - "fs.write": base | {"path", "offset", "data", "truncate"}, - "fs.delete": base | {"path", "recursive"}, - "job.start": base | {"jobId", "argv", "cwd", "environment", "timeoutMs"}, - "job.status": base | {"jobId", "logOffset", "maxBytes"}, - "job.cancel": base | {"jobId"}, - "bloom.status": base, - "connections.configure": base | {"workspaceId", "wallet", "caPublicKey", "nfs"}, - } - require(operation in fields, "invalid_request", "unknown guest operation") - require_exact_keys(raw, fields[operation]) - return raw - - -def validate_relative_path(value: Any) -> list[str]: - require(isinstance(value, str), "invalid_request", "workspace path must be a string") - require(0 < len(value.encode("utf-8")) <= 1024, "invalid_request", "workspace path has an invalid length") - require("\x00" not in value and "\\" not in value, "invalid_request", "workspace path contains forbidden characters") - require(not value.startswith("/") and not value.endswith("/"), "invalid_request", "workspace path must be relative") - require(posixpath.normpath(value) == value and value not in (".", "..") and not value.startswith("../"), "invalid_request", "workspace path escapes /workspace") - parts = value.split("/") - require(all(part not in ("", ".", "..") for part in parts), "invalid_request", "invalid workspace path") - return parts - - -def validate_workspace_directory(value: Any) -> list[str]: - if value == ".": - return [] - return validate_relative_path(value) - - -def validate_integer(value: Any, minimum: int, maximum: int, label: str) -> int: - require(isinstance(value, int) and not isinstance(value, bool), "invalid_request", f"{label} must be an integer") - require(minimum <= value <= maximum, "invalid_request", f"{label} is outside the allowed range") - return value - - -def validate_job_id(value: Any) -> str: - require(isinstance(value, str), "invalid_request", "job id must be a UUID") - try: - parsed = uuid.UUID(value) - except (ValueError, AttributeError): - raise ControlError("invalid_request", "job id must be a UUID") from None - require(str(parsed) == value, "invalid_request", "job id must be a canonical lowercase UUID") - return value - - -def validate_environment(value: Any) -> dict[str, str]: - require(isinstance(value, dict) and len(value) <= 64, "invalid_request", "job environment must contain at most 64 variables") - result: dict[str, str] = {} - total = 0 - for name, item in value.items(): - require(isinstance(name, str) and ENV_NAME.fullmatch(name), "invalid_request", "job environment contains an invalid name") - require(name in USER_ENV_EXACT or name.startswith(USER_ENV_PREFIXES), "permission_denied", f"job environment variable is not allowlisted: {name}") - require(isinstance(item, str) and "\x00" not in item, "invalid_request", f"job environment value is invalid: {name}") - encoded = item.encode("utf-8") - require(len(encoded) <= 8192, "limit_exceeded", f"job environment value is too large: {name}") - total += len(name.encode("ascii")) + len(encoded) - require(total <= 32 * 1024, "limit_exceeded", "job environment exceeds the aggregate size limit") - result[name] = item - return result - - -class WorkspaceFiles: - def __init__(self, root: str, quota_bytes: int, owner_uid: int, owner_gid: int): - self.root = os.path.abspath(root) - root_metadata = os.lstat(self.root) - if stat.S_ISLNK(root_metadata.st_mode) or not stat.S_ISDIR(root_metadata.st_mode): - raise RuntimeError("workspace root must be a non-symlink directory") - root_flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0) - try: - self.root_fd = os.open(self.root, root_flags) - except OSError as error: - raise RuntimeError(f"unsafe or unavailable workspace root: {error}") from error - self.quota_bytes = quota_bytes - self.owner_uid = owner_uid - self.owner_gid = owner_gid - self.write_lock = threading.Lock() - - def close(self) -> None: - os.close(self.root_fd) - - def prepare_job_tmp(self) -> None: - descriptor = self._open_directory([".tmp"], create=True) - try: - os.fchmod(descriptor, 0o700) - if os.geteuid() == 0: - os.fchown(descriptor, self.owner_uid, self.owner_gid) - finally: - os.close(descriptor) - - def _open_directory(self, parts: list[str], create: bool = False) -> int: - current = os.dup(self.root_fd) - flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0) - try: - for part in parts: - if create: - try: - os.mkdir(part, mode=0o700, dir_fd=current) - if os.geteuid() == 0: - os.chown(part, self.owner_uid, self.owner_gid, dir_fd=current, follow_symlinks=False) - except FileExistsError: - pass - following = os.open(part, flags, dir_fd=current) - os.close(current) - current = following - return current - except Exception: - os.close(current) - raise - - def _open_file(self, parts: list[str], flags: int, mode: int = 0o600, create_parents: bool = False) -> int: - parent = self._open_directory(parts[:-1], create=create_parents) - try: - return os.open(parts[-1], flags | getattr(os, "O_NOFOLLOW", 0), mode, dir_fd=parent) - finally: - os.close(parent) - - def list(self, path: Any) -> dict[str, Any]: - parts = validate_workspace_directory(path) - directory = self._open_directory(parts) - try: - names = sorted(os.listdir(directory)) - require(len(names) <= MAX_LIST_ENTRIES, "limit_exceeded", "directory contains too many entries") - entries = [] - for name in names: - metadata = os.stat(name, dir_fd=directory, follow_symlinks=False) - kind = "symlink" if stat.S_ISLNK(metadata.st_mode) else "directory" if stat.S_ISDIR(metadata.st_mode) else "file" - entries.append( - { - "path": "/".join(parts + [name]), - "type": kind, - "size": metadata.st_size if stat.S_ISREG(metadata.st_mode) else 0, - "modifiedAt": int(metadata.st_mtime * 1000), - } - ) - return {"files": entries} - finally: - os.close(directory) - - def read(self, path: Any, offset: Any, max_bytes: Any) -> dict[str, Any]: - parts = validate_relative_path(path) - start = validate_integer(offset, 0, MAX_FILE_BYTES, "file offset") - limit = validate_integer(max_bytes, 1, MAX_FILE_CHUNK_BYTES, "file read size") - descriptor = self._open_file(parts, os.O_RDONLY) - try: - metadata = os.fstat(descriptor) - require(stat.S_ISREG(metadata.st_mode), "invalid_request", "download path is not a regular file") - require(metadata.st_size <= MAX_FILE_BYTES, "limit_exceeded", "file exceeds the download limit") - require(start <= metadata.st_size, "invalid_request", "file offset exceeds the file size") - chunk = os.pread(descriptor, limit, start) - return { - "path": "/".join(parts), - "offset": start, - "nextOffset": start + len(chunk), - "size": metadata.st_size, - "eof": start + len(chunk) >= metadata.st_size, - "data": base64.b64encode(chunk).decode("ascii"), - } - finally: - os.close(descriptor) - - def write(self, path: Any, offset: Any, encoded: Any, truncate: Any) -> dict[str, Any]: - parts = validate_relative_path(path) - start = validate_integer(offset, 0, MAX_FILE_BYTES, "file offset") - require(isinstance(encoded, str), "invalid_request", "file payload must be base64") - require(isinstance(truncate, bool), "invalid_request", "truncate must be boolean") - require(not truncate or start == 0, "invalid_request", "a truncating write must begin at offset zero") - try: - chunk = base64.b64decode(encoded, validate=True) - except (binascii.Error, ValueError): - raise ControlError("invalid_request", "file payload is not canonical base64") from None - require(base64.b64encode(chunk).decode("ascii") == encoded, "invalid_request", "file payload is not canonical base64") - require(len(chunk) <= MAX_FILE_CHUNK_BYTES, "limit_exceeded", "file write chunk is too large") - require(start + len(chunk) <= MAX_FILE_BYTES, "limit_exceeded", "file exceeds the upload limit") - with self.write_lock: - flags = os.O_WRONLY | os.O_CREAT - descriptor = self._open_file(parts, flags, create_parents=True) - try: - if os.geteuid() == 0: - os.fchown(descriptor, self.owner_uid, self.owner_gid) - metadata = os.fstat(descriptor) - require(stat.S_ISREG(metadata.st_mode), "invalid_request", "upload target is not a regular file") - prior = metadata.st_size - used = self._directory_bytes() - resulting_size = max(0 if truncate else prior, start + len(chunk)) - require(used - prior + resulting_size <= self.quota_bytes, "limit_exceeded", "workspace storage quota exceeded") - if truncate: - os.ftruncate(descriptor, 0) - written = 0 - while written < len(chunk): - count = os.pwrite(descriptor, chunk[written:], start + written) - require(count > 0, "internal", "file write made no progress") - written += count - os.fsync(descriptor) - final_size = os.fstat(descriptor).st_size - finally: - os.close(descriptor) - used_after = self._directory_bytes() - return { - "path": "/".join(parts), - "size": final_size, - "nextOffset": start + len(chunk), - "usedBytes": used_after, - "quotaBytes": self.quota_bytes, - } - - def delete(self, path: Any, recursive: Any) -> dict[str, Any]: - parts = validate_relative_path(path) - require(recursive is False, "permission_denied", "recursive deletion is not supported") - with self.write_lock: - parent = self._open_directory(parts[:-1]) - try: - metadata = os.stat(parts[-1], dir_fd=parent, follow_symlinks=False) - require(stat.S_ISREG(metadata.st_mode), "invalid_request", "delete path is not a regular file") - os.unlink(parts[-1], dir_fd=parent) - finally: - os.close(parent) - used_after = self._directory_bytes() - return { - "path": "/".join(parts), - "deleted": True, - "usedBytes": used_after, - "quotaBytes": self.quota_bytes, - } - - def open_job_cwd(self, path: Any) -> int: - return self._open_directory(validate_workspace_directory(path)) - - def read_small(self, path: str, maximum: int = 256) -> str | None: - try: - descriptor = self._open_file(validate_relative_path(path), os.O_RDONLY) - try: - metadata = os.fstat(descriptor) - if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum: - return None - return os.read(descriptor, maximum).decode("utf-8").strip() - finally: - os.close(descriptor) - except (OSError, ControlError, UnicodeDecodeError): - return None - - def watch_identity(self) -> str | None: - """Return a validated public watch address, rejecting any extra state.""" - try: - keystore = self._open_directory([".bloom", "keystore"]) - try: - if sorted(os.listdir(keystore)) != ["workspace-login"]: - return None - finally: - os.close(keystore) - wallet = self._open_directory([".bloom", "keystore", "workspace-login"]) - try: - if sorted(os.listdir(wallet)) != ["address", "kind", "pubkey"]: - return None - finally: - os.close(wallet) - kind = self.read_small(".bloom/keystore/workspace-login/kind") - address = self.read_small(".bloom/keystore/workspace-login/address") - public_key = self.read_small(".bloom/keystore/workspace-login/pubkey") - normalized = address.lower() if address else None - if kind != "watch" or public_key != "" or not isinstance(normalized, str) or EVM_ADDRESS.fullmatch(normalized) is None: - return None - return normalized - except (OSError, ControlError): - return None - - def _directory_bytes(self) -> int: - count = 0 - total = 0 - for _, directories, files, directory_fd in os.fwalk(self.root, topdown=True, follow_symlinks=False): - directories[:] = [name for name in directories if not stat.S_ISLNK(os.stat(name, dir_fd=directory_fd, follow_symlinks=False).st_mode)] - for name in files: - count += 1 - require(count <= MAX_SCAN_ENTRIES, "limit_exceeded", "workspace contains too many files") - metadata = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) - if stat.S_ISREG(metadata.st_mode): - total += metadata.st_size - return total - - -class LogRing: - def __init__(self) -> None: - self.data = bytearray() - self.start_offset = 0 - self.end_offset = 0 - self.lock = threading.Lock() - - def append(self, chunk: bytes) -> None: - if not chunk: - return - with self.lock: - self.data.extend(chunk) - self.end_offset += len(chunk) - overflow = len(self.data) - MAX_LOG_BYTES - if overflow > 0: - del self.data[:overflow] - self.start_offset += overflow - - def slice(self, requested_offset: int, maximum: int, terminal: bool) -> dict[str, Any]: - with self.lock: - require(requested_offset <= self.end_offset, "invalid_request", "log cursor is beyond the current log end") - offset = max(requested_offset, self.start_offset) - relative = offset - self.start_offset - chunk = bytes(self.data[relative : relative + maximum]) - next_offset = offset + len(chunk) - return { - "offset": offset, - "nextOffset": next_offset, - "endOffset": self.end_offset, - "truncatedBefore": requested_offset < self.start_offset, - "eof": terminal and next_offset == self.end_offset, - "encoding": "base64", - "data": base64.b64encode(chunk).decode("ascii"), - } - - -TERMINAL_STATES = frozenset({"succeeded", "failed", "cancelled", "timed_out"}) - - -@dataclass -class Job: - job_id: str - argv: list[str] - cwd: str - timeout_ms: int - created_at: int - state: str = "queued" - started_at: int | None = None - finished_at: int | None = None - exit_code: int | None = None - signal_number: int | None = None - process: subprocess.Popen[bytes] | None = None - cancel_requested: bool = False - logs: LogRing = field(default_factory=LogRing) - finished: threading.Event = field(default_factory=threading.Event) - - -class JobEngine: - def __init__(self, files: WorkspaceFiles, job_uid: int, job_gid: int): - self.files = files - self.job_uid = job_uid - self.job_gid = job_gid - self.jobs: OrderedDict[str, Job] = OrderedDict() - self.lock = threading.RLock() - - def start(self, request: dict[str, Any]) -> dict[str, Any]: - job_id = validate_job_id(request["jobId"]) - argv = request["argv"] - require(isinstance(argv, list) and 1 <= len(argv) <= 64, "invalid_request", "argv must contain between 1 and 64 arguments") - validated_argv = [] - total_argv = 0 - for argument in argv: - require(isinstance(argument, str) and argument and "\x00" not in argument, "invalid_request", "argv contains an invalid argument") - size = len(argument.encode("utf-8")) - require(size <= 4096, "limit_exceeded", "argv contains an oversized argument") - total_argv += size - require(total_argv <= 32 * 1024, "limit_exceeded", "argv exceeds the aggregate size limit") - validated_argv.append(argument) - cwd_parts = validate_workspace_directory(request["cwd"]) - cwd = "/".join(cwd_parts) if cwd_parts else "." - timeout_ms = validate_integer(request["timeoutMs"], 1000, MAX_JOB_TIMEOUT_MS, "job timeout") - user_environment = validate_environment(request["environment"]) - launcher = self._launcher_argv(validated_argv) - - with self.lock: - require(job_id not in self.jobs, "conflict", "job id already exists") - active = sum(job.state not in TERMINAL_STATES for job in self.jobs.values()) - require(active < MAX_ACTIVE_JOBS, "limit_exceeded", "workspace already has the maximum number of active jobs") - self._prune_terminal_jobs() - require(len(self.jobs) < MAX_RETAINED_JOBS, "limit_exceeded", "workspace has too many retained jobs") - cwd_fd = self.files.open_job_cwd(cwd) - job = Job(job_id=job_id, argv=validated_argv, cwd=cwd, timeout_ms=timeout_ms, created_at=now_ms()) - self.jobs[job_id] = job - try: - job.started_at = now_ms() - process = subprocess.Popen( - launcher, - cwd=f"/proc/self/fd/{cwd_fd}", - env=self._job_environment(user_environment), - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=0, - close_fds=True, - pass_fds=(cwd_fd,), - start_new_session=True, - ) - except (OSError, subprocess.SubprocessError) as error: - job.state = "failed" - job.finished_at = now_ms() - job.logs.append(f"bloom job launch failed: {error}\n".encode("utf-8", "replace")) - job.finished.set() - return self._status(job, 0, MAX_LOG_CHUNK_BYTES) - finally: - os.close(cwd_fd) - job.process = process - job.state = "running" - threading.Thread(target=self._capture_output, args=(job,), daemon=True, name=f"job-log-{job_id}").start() - threading.Thread(target=self._wait_for_job, args=(job,), daemon=True, name=f"job-wait-{job_id}").start() - return self._status(job, 0, MAX_LOG_CHUNK_BYTES) - - def status(self, request: dict[str, Any]) -> dict[str, Any]: - job = self._get(validate_job_id(request["jobId"])) - offset = validate_integer(request["logOffset"], 0, 2**53 - 1, "log cursor") - maximum = validate_integer(request["maxBytes"], 1, MAX_LOG_CHUNK_BYTES, "log read size") - with self.lock: - return self._status(job, offset, maximum) - - def cancel(self, request: dict[str, Any]) -> dict[str, Any]: - job = self._get(validate_job_id(request["jobId"])) - with self.lock: - if job.state in TERMINAL_STATES: - return self._status(job, job.logs.end_offset, 1) - process = job.process - require(process is not None, "conflict", "job has not started") - if process.poll() is None: - first_request = not job.cancel_requested - job.cancel_requested = True - self._signal_group(process.pid, signal.SIGTERM) - if first_request: - threading.Thread(target=self._force_cancel, args=(job,), daemon=True, name=f"job-cancel-{job.job_id}").start() - return self._status(job, job.logs.end_offset, 1) - - def close(self) -> None: - with self.lock: - active = [job for job in self.jobs.values() if job.state not in TERMINAL_STATES and job.process] - for job in active: - job.cancel_requested = True - self._signal_group(job.process.pid, signal.SIGKILL) - for job in active: - job.finished.wait(2) - - def _get(self, job_id: str) -> Job: - with self.lock: - job = self.jobs.get(job_id) - require(job is not None, "not_found", "job does not exist") - return job - - def _prune_terminal_jobs(self) -> None: - while len(self.jobs) >= MAX_RETAINED_JOBS: - terminal_id = next((job_id for job_id, job in self.jobs.items() if job.state in TERMINAL_STATES), None) - if terminal_id is None: - return - del self.jobs[terminal_id] - - def _capture_output(self, job: Job) -> None: - stream = job.process.stdout if job.process else None - if stream is None: - return - try: - while True: - chunk = stream.read(16 * 1024) - if not chunk: - return - job.logs.append(chunk) - finally: - stream.close() - - def _wait_for_job(self, job: Job) -> None: - process = job.process - if process is None: - return - timed_out = False - try: - try: - process.wait(timeout=job.timeout_ms / 1000) - except subprocess.TimeoutExpired: - timed_out = True - self._signal_group(process.pid, signal.SIGTERM) - try: - process.wait(timeout=JOB_KILL_GRACE_SECONDS) - except subprocess.TimeoutExpired: - self._signal_group(process.pid, signal.SIGKILL) - process.wait() - if process.stdout: - # Give the bounded reader a chance to drain the closed pipe. - for _ in range(100): - if process.stdout.closed: - break - time.sleep(0.01) - with self.lock: - return_code = process.returncode - job.finished_at = now_ms() - if timed_out: - job.state = "timed_out" - elif job.cancel_requested: - job.state = "cancelled" - elif return_code == 0: - job.state = "succeeded" - else: - job.state = "failed" - if return_code is not None and return_code < 0: - job.signal_number = -return_code - else: - job.exit_code = return_code - finally: - job.finished.set() - - def _force_cancel(self, job: Job) -> None: - time.sleep(JOB_KILL_GRACE_SECONDS) - process = job.process - if process is not None and process.poll() is None: - self._signal_group(process.pid, signal.SIGKILL) - - def _status(self, job: Job, log_offset: int, max_bytes: int) -> dict[str, Any]: - result: dict[str, Any] = { - "jobId": job.job_id, - "state": job.state, - "createdAt": job.created_at, - "startedAt": job.started_at, - "finishedAt": job.finished_at, - "exitCode": job.exit_code, - "signal": job.signal_number, - "timeoutMs": job.timeout_ms, - "logs": job.logs.slice(log_offset, max_bytes, job.state in TERMINAL_STATES), - } - return result - - def _launcher_argv(self, argv: list[str]) -> list[str]: - prlimit = shutil.which("prlimit") or "/usr/bin/prlimit" - setpriv = shutil.which("setpriv") or "/usr/bin/setpriv" - command = [ - prlimit, - "--nofile=64:64", - f"--fsize={MAX_JOB_FILE_BYTES}:{MAX_JOB_FILE_BYTES}", - "--core=0:0", - ] - if os.geteuid() == 0: - command.append(f"--nproc={MAX_JOB_PROCESSES}:{MAX_JOB_PROCESSES}") - command.extend([ - "--", - setpriv, - "--no-new-privs", - "--pdeathsig=SIGKILL", - ]) - if os.geteuid() == 0: - command.extend([ - "--bounding-set=-all", - "--inh-caps=-all", - "--ambient-caps=-all", - f"--reuid={self.job_uid}", - f"--regid={self.job_gid}", - "--clear-groups", - ]) - else: - require(os.geteuid() == self.job_uid and os.getegid() == self.job_gid, "unavailable", "guest control must run as root to change job identity") - return command + ["--"] + argv - - def _job_environment(self, user_environment: dict[str, str]) -> dict[str, str]: - environment = { - "HOME": "/workspace", - "USER": "workspace", - "LOGNAME": "workspace", - "SHELL": "/bin/bash", - "PATH": "/usr/local/bin:/usr/bin:/bin", - "TMPDIR": "/workspace/.tmp", - "LANG": "C.UTF-8", - } - for name in SYSTEM_PROXY_ENV: - value = os.environ.get(name) - if value and len(value) <= 2048 and "@" not in value and "\x00" not in value: - environment[name] = value - for name in ("SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"): - value = os.environ.get(name) - if value and value.startswith("/") and "\x00" not in value and len(value) <= 1024: - environment[name] = value - environment.update(user_environment) - return environment - - @staticmethod - def _signal_group(pid: int, requested_signal: signal.Signals) -> None: - try: - os.killpg(pid, requested_signal) - except ProcessLookupError: - pass - - -def write_private_file(path: str, content: str, mode: int) -> None: - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0), mode) - try: - os.fchmod(descriptor, mode) - encoded = content.encode("utf-8") - written = 0 - while written < len(encoded): - count = os.write(descriptor, encoded[written:]) - if count <= 0: - raise OSError("connection configuration write made no progress") - written += count - os.fsync(descriptor) - finally: - os.close(descriptor) - - -def read_small_regular_file(path: str, maximum: int) -> str | None: - try: - descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) - try: - metadata = os.fstat(descriptor) - if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum: - return None - return os.read(descriptor, maximum).decode("utf-8").strip() - finally: - os.close(descriptor) - except (OSError, UnicodeDecodeError): - return None - - -class GuestControl: - def __init__(self, files: WorkspaceFiles, jobs: JobEngine): - self.files = files - self.jobs = jobs - self.sshd: subprocess.Popen[bytes] | None = None - self.mountd: subprocess.Popen[bytes] | None = None - self.connection_scope: tuple[str, str, bool] | None = None - self.connection_lock = threading.Lock() - - def handle(self, raw: Any) -> dict[str, Any]: - request_id = raw.get("id") if isinstance(raw, dict) and isinstance(raw.get("id"), str) and REQUEST_ID.fullmatch(raw["id"]) else "invalid" - try: - request = validate_request(raw) - operation = request["operation"] - if operation == "hello": - result = { - "protocolVersion": PROTOCOL_VERSION, - "operations": ["fs.list", "fs.read", "fs.write", "fs.delete", "job.start", "job.status", "job.cancel", "bloom.status", "connections.configure"], - "limits": { - "fileChunkBytes": MAX_FILE_CHUNK_BYTES, - "fileBytes": MAX_FILE_BYTES, - "activeJobs": MAX_ACTIVE_JOBS, - "retainedJobs": MAX_RETAINED_JOBS, - "jobProcesses": MAX_JOB_PROCESSES, - "jobLogBytes": MAX_LOG_BYTES, - "jobTimeoutMs": MAX_JOB_TIMEOUT_MS, - }, - } - elif operation == "fs.list": - result = self.files.list(request["path"]) - elif operation == "fs.read": - result = self.files.read(request["path"], request["offset"], request["maxBytes"]) - elif operation == "fs.write": - result = self.files.write(request["path"], request["offset"], request["data"], request["truncate"]) - elif operation == "fs.delete": - result = self.files.delete(request["path"], request["recursive"]) - elif operation == "job.start": - result = self.jobs.start(request) - elif operation == "job.status": - result = self.jobs.status(request) - elif operation == "job.cancel": - result = self.jobs.cancel(request) - elif operation == "bloom.status": - result = self._bloom_status() - elif operation == "connections.configure": - result = self._configure_connections(request) - else: - raise ControlError("invalid_request", "unknown guest operation") - return {"version": PROTOCOL_VERSION, "id": request_id, "ok": True, "result": result} - except ControlError as error: - return {"version": PROTOCOL_VERSION, "id": request_id, "ok": False, "error": {"code": error.code, "message": str(error)[:1024]}} - except FileNotFoundError: - return {"version": PROTOCOL_VERSION, "id": request_id, "ok": False, "error": {"code": "not_found", "message": "workspace path does not exist"}} - except PermissionError: - return {"version": PROTOCOL_VERSION, "id": request_id, "ok": False, "error": {"code": "permission_denied", "message": "workspace operation was denied"}} - except OSError as error: - code = "not_found" if error.errno == errno.ENOENT else "permission_denied" if error.errno in (errno.EACCES, errno.EPERM, errno.ELOOP, errno.ENOTDIR) else "internal" - return {"version": PROTOCOL_VERSION, "id": request_id, "ok": False, "error": {"code": code, "message": "workspace operation failed"}} - except Exception: - return {"version": PROTOCOL_VERSION, "id": request_id, "ok": False, "error": {"code": "internal", "message": "guest control operation failed"}} - - def _bloom_status(self) -> dict[str, Any]: - address = self.files.watch_identity() - watch_identity = address is not None - executable = shutil.which("bloom") is not None - return { - "available": executable and watch_identity, - "mount": {"path": "/bloom", "mounted": os.path.ismount("/bloom")}, - "identity": {"kind": "watch", "address": address} if watch_identity else None, - "capabilities": { - "files": True, - "jobs": True, - "bloomRead": executable and watch_identity, - "walletSigning": False, - "transactions": False, - }, - "helper": {"name": "bloom-workspace", "protocolVersion": PROTOCOL_VERSION}, - } - - def _configure_connections(self, request: dict[str, Any]) -> dict[str, Any]: - with self.connection_lock: - return self._configure_connections_locked(request) - - def _configure_connections_locked(self, request: dict[str, Any]) -> dict[str, Any]: - workspace_id = request["workspaceId"] - wallet = request["wallet"] - ca_public_key = request["caPublicKey"] - nfs_enabled = request["nfs"] - require(isinstance(workspace_id, str) and WORKSPACE_ID.fullmatch(workspace_id), "invalid_request", "invalid workspace id") - require(isinstance(wallet, str) and EVM_ADDRESS.fullmatch(wallet), "invalid_request", "invalid workspace wallet") - require(isinstance(ca_public_key, str) and SSH_CA_PUBLIC_KEY.fullmatch(ca_public_key), "invalid_request", "invalid SSH CA public key") - require(isinstance(nfs_enabled, bool), "invalid_request", "invalid NFS capability") - scope = (workspace_id, wallet, nfs_enabled) - if self.connection_scope is not None: - require(self.connection_scope == scope, "conflict", "workspace connection scope is already configured") - return self._connection_status(workspace_id, nfs_enabled) - require(os.geteuid() == 0, "unavailable", "workspace connections require the root guest controller") - require(shutil.which("sshd") is not None and shutil.which("ssh-keygen") is not None, "unavailable", "OpenSSH server tooling is unavailable") - - try: - decoded = base64.b64decode(ca_public_key.split(" ", 1)[1], validate=True) - except (binascii.Error, ValueError): - raise ControlError("invalid_request", "invalid SSH CA public key") from None - require(32 <= len(decoded) <= 128, "invalid_request", "invalid SSH CA public key payload") - directory = "/run/bloom/ssh" - os.makedirs(directory, mode=0o700, exist_ok=True) - os.chmod(directory, 0o700) - ca_path = f"{directory}/user_ca.pub" - principals_path = f"{directory}/authorized_principals" - host_key_path = f"{directory}/ssh_host_ed25519_key" - owner_digest = hashlib.sha256(wallet.encode("ascii")).hexdigest()[:32] - principals = [f"bloom-shell-{workspace_id}-w-{owner_digest}"] - if nfs_enabled: - principals.append(f"bloom-nfs-{workspace_id}-w-{owner_digest}") - write_private_file(ca_path, f"{ca_public_key}\n", 0o600) - write_private_file(principals_path, "\n".join(principals) + "\n", 0o600) - if not os.path.exists(host_key_path): - subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", host_key_path], check=True, timeout=10) - os.chmod(host_key_path, 0o600) - os.chmod(f"{host_key_path}.pub", 0o644) - shell = "/usr/local/libexec/bloom-workspace-shell" - require(os.path.isfile(shell) and not os.path.islink(shell), "unavailable", "workspace SSH shell helper is unavailable") - sshd_argv = [ - "sshd", "-D", "-e", "-f", "/etc/ssh/sshd_config", - "-o", f"HostKey={host_key_path}", "-o", f"TrustedUserCAKeys={ca_path}", - "-o", f"AuthorizedPrincipalsFile={principals_path}", "-o", "AuthorizedKeysFile=none", - "-o", "AuthenticationMethods=publickey", "-o", "PubkeyAuthentication=yes", - "-o", "PasswordAuthentication=no", "-o", "KbdInteractiveAuthentication=no", - "-o", "PermitRootLogin=no", "-o", "AllowUsers=workspace", - "-o", "AllowAgentForwarding=no", "-o", "AllowTcpForwarding=local", - "-o", "PermitOpen=127.0.0.1:2049", "-o", "AllowStreamLocalForwarding=no", - "-o", "GatewayPorts=no", "-o", "X11Forwarding=no", "-o", "PermitTunnel=no", - "-o", "PermitUserEnvironment=no", "-o", "PermitUserRC=no", "-o", "PermitTTY=yes", - "-o", "MaxSessions=1", "-o", "UsePAM=no", - "-o", "AddressFamily=inet", "-o", "ListenAddress=0.0.0.0", "-o", "Port=22", - ] - self.sshd = subprocess.Popen(sshd_argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True) - time.sleep(0.05) - require(self.sshd.poll() is None, "unavailable", "workspace sshd failed to start") - if nfs_enabled: - try: - self._start_nfs(workspace_id) - except Exception: - self.sshd.terminate() - self.sshd.wait(timeout=2) - self.sshd = None - raise - self.connection_scope = scope - return self._connection_status(workspace_id, nfs_enabled) - - def _start_nfs(self, workspace_id: str) -> None: - try: - os.makedirs("/proc/fs/nfsd", mode=0o755, exist_ok=True) - for command in ("exportfs", "rpc.mountd", "rpc.nfsd"): - require(shutil.which(command) is not None, "unavailable", f"NFS server tool is unavailable: {command}") - if not os.path.ismount("/proc/fs/nfsd"): - subprocess.run(["mount", "-t", "nfsd", "nfsd", "/proc/fs/nfsd"], check=True, timeout=10) - options = "rw,fsid=0,sync,no_subtree_check,root_squash,all_squash,anonuid=1000,anongid=1000,insecure" - subprocess.run(["exportfs", "-i", "-o", options, "127.0.0.1:/workspace"], check=True, timeout=10) - self.mountd = subprocess.Popen(["rpc.mountd", "--foreground", "--no-udp", "--no-nfs-version", "2", "--no-nfs-version", "3", "--ttl", "10"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True) - time.sleep(0.05) - require(self.mountd.poll() is None, "unavailable", "NFS mount daemon failed to start") - subprocess.run(["rpc.nfsd", "--host", "127.0.0.1", "--no-udp", "--no-nfs-version", "2", "--no-nfs-version", "3", "--nfs-version", "4", "--leasetime", "10", "--grace-time", "10", "--port", "2049", "1"], check=True, timeout=10) - except ControlError: - self._stop_partial_nfs() - raise - except (OSError, subprocess.SubprocessError): - self._stop_partial_nfs() - raise ControlError("unavailable", "guest NFS service failed to start") from None - - def _stop_partial_nfs(self) -> None: - if self.mountd is not None and self.mountd.poll() is None: - self.mountd.terminate() - try: - self.mountd.wait(timeout=2) - except subprocess.TimeoutExpired: - self.mountd.kill() - self.mountd = None - if shutil.which("rpc.nfsd") is not None: - subprocess.run(["rpc.nfsd", "0"], check=False, timeout=5, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - if shutil.which("exportfs") is not None: - subprocess.run(["exportfs", "-u", "127.0.0.1:/workspace"], check=False, timeout=5, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - def _connection_status(self, workspace_id: str, nfs_enabled: bool) -> dict[str, Any]: - host_key = read_small_regular_file("/run/bloom/ssh/ssh_host_ed25519_key.pub", 1024) - require(host_key is not None and host_key.startswith("ssh-ed25519 "), "unavailable", "guest SSH host key is unavailable") - normalized_host_key = " ".join(host_key.split()[:2]) - return {"ssh": {"available": self.sshd is not None and self.sshd.poll() is None, "hostKey": normalized_host_key, "port": 22}, "nfs": {"available": nfs_enabled and self.mountd is not None and self.mountd.poll() is None, "port": 2049 if nfs_enabled else None}, "workspaceId": workspace_id} - - def close(self) -> None: - if self.sshd is not None and self.sshd.poll() is None: - self.sshd.terminate() - try: - self.sshd.wait(timeout=2) - except subprocess.TimeoutExpired: - self.sshd.kill() - if self.connection_scope is not None and self.connection_scope[2]: - try: - self._stop_partial_nfs() - except (OSError, subprocess.SubprocessError): - pass - - -def decode_frame(frame: bytes) -> Any: - require(len(frame) <= MAX_FRAME_BYTES, "limit_exceeded", "guest protocol frame is too large") - require(frame.endswith(b"\n"), "invalid_request", "guest protocol frame must end with a newline") - try: - return json.loads(frame[:-1].decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): - raise ControlError("invalid_request", "guest protocol frame is not valid UTF-8 JSON") from None - - -def encode_response(response: dict[str, Any]) -> bytes: - frame = json.dumps(response, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + b"\n" - if len(frame) > MAX_FRAME_BYTES: - fallback = {"version": PROTOCOL_VERSION, "id": response.get("id", "invalid"), "ok": False, "error": {"code": "internal", "message": "guest response exceeded the frame limit"}} - return json.dumps(fallback, separators=(",", ":")).encode("utf-8") + b"\n" - return frame - - -def process_frame(control: GuestControl, frame: bytes) -> bytes: - try: - request = decode_frame(frame) - return encode_response(control.handle(request)) - except ControlError as error: - return encode_response({"version": PROTOCOL_VERSION, "id": "invalid", "ok": False, "error": {"code": error.code, "message": str(error)}}) - - -def serve_stdio(control: GuestControl, reader: BinaryIO, writer: BinaryIO) -> None: - while True: - frame = reader.readline(MAX_FRAME_BYTES + 2) - if not frame: - return - writer.write(process_frame(control, frame)) - writer.flush() - - -def create_unix_listener(path: str, uid: int, gid: int) -> socket.socket: - parent = os.path.dirname(path) - os.makedirs(parent, mode=0o755, exist_ok=True) - try: - metadata = os.lstat(path) - require(stat.S_ISSOCK(metadata.st_mode), "unavailable", "guest control socket path is occupied") - os.unlink(path) - except FileNotFoundError: - pass - listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - listener.bind(path) - os.chmod(path, 0o600) - if os.geteuid() == 0: - os.chown(path, uid, gid) - listener.listen(16) - return listener - - -def create_vsock_listener(port: int) -> socket.socket: - require(hasattr(socket, "AF_VSOCK"), "unavailable", "Python does not support AF_VSOCK") - listener = socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM) - listener.bind((getattr(socket, "VMADDR_CID_ANY", 0xFFFFFFFF), port)) - listener.listen(16) - return listener - - -def serve_sockets(control: GuestControl, listeners: list[socket.socket]) -> None: - while True: - readable, _, _ = select.select(listeners, [], []) - for listener in readable: - connection, _ = listener.accept() - with connection: - connection.settimeout(10) - frame = bytearray() - while len(frame) <= MAX_FRAME_BYTES: - chunk = connection.recv(min(64 * 1024, MAX_FRAME_BYTES + 1 - len(frame))) - if not chunk: - break - frame.extend(chunk) - if b"\n" in chunk: - break - connection.sendall(process_frame(control, bytes(frame))) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Bloom workspace guest control service") - parser.add_argument("--workspace", default="/workspace") - parser.add_argument("--workspace-quota-bytes", type=int, default=128 * 1024 * 1024) - parser.add_argument("--job-uid", type=int, default=1000) - parser.add_argument("--job-gid", type=int, default=1000) - parser.add_argument("--stdio", action="store_true") - parser.add_argument("--unix-socket") - parser.add_argument("--vsock-port", type=int) - args = parser.parse_args() - if not args.stdio and not args.unix_socket and args.vsock_port is None: - parser.error("at least one transport is required") - if not 1024 * 1024 <= args.workspace_quota_bytes <= 16 * 1024 * 1024 * 1024: - parser.error("workspace quota is outside the supported range") - if args.vsock_port is not None and not 1 <= args.vsock_port <= 0xFFFFFFFF: - parser.error("vsock port is outside the supported range") - return args - - -def handle_shutdown(_signum: int, _frame: Any) -> None: - raise KeyboardInterrupt - - -def main() -> int: - args = parse_args() - signal.signal(signal.SIGTERM, handle_shutdown) - for required_command in ("prlimit", "setpriv"): - if shutil.which(required_command) is None: - raise RuntimeError(f"required job isolation command is unavailable: {required_command}") - files = WorkspaceFiles(args.workspace, args.workspace_quota_bytes, args.job_uid, args.job_gid) - files.prepare_job_tmp() - jobs = JobEngine(files, args.job_uid, args.job_gid) - control = GuestControl(files, jobs) - listeners: list[socket.socket] = [] - try: - if args.unix_socket: - listeners.append(create_unix_listener(args.unix_socket, args.job_uid, args.job_gid)) - if args.vsock_port is not None: - listeners.append(create_vsock_listener(args.vsock_port)) - stdio_thread: threading.Thread | None = None - if args.stdio: - stdio_thread = threading.Thread( - target=serve_stdio, - args=(control, os.fdopen(os.dup(0), "rb", buffering=0), os.fdopen(os.dup(1), "wb", buffering=0)), - daemon=True, - name="guest-control-stdio", - ) - stdio_thread.start() - if listeners: - serve_sockets(control, listeners) - elif stdio_thread is not None: - while stdio_thread.is_alive(): - stdio_thread.join(0.25) - except KeyboardInterrupt: - pass - finally: - control.close() - jobs.close() - for listener in listeners: - listener.close() - if args.unix_socket: - try: - os.unlink(args.unix_socket) - except FileNotFoundError: - pass - files.close() - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/ops/guest-control/bloom-workspace b/ops/guest-control/bloom-workspace deleted file mode 100755 index 5eaa44f..0000000 --- a/ops/guest-control/bloom-workspace +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -"""Small guest-local client for the Bloom workspace control service.""" - -from __future__ import annotations - -import argparse -import base64 -import json -import os -import socket -import sys -import uuid - - -SOCKET_PATH = os.environ.get("BLOOM_GUEST_CONTROL_SOCKET", "/run/bloom/guest-control.sock") -MAX_FRAME_BYTES = 384 * 1024 -MAX_CHUNK_BYTES = 256 * 1024 - - -def request(operation: str, **fields: object) -> object: - message = {"version": 1, "id": uuid.uuid4().hex, "operation": operation, **fields} - frame = json.dumps(message, separators=(",", ":")).encode() + b"\n" - if len(frame) > MAX_FRAME_BYTES: - raise SystemExit("request exceeds the guest-control frame limit") - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: - client.connect(SOCKET_PATH) - client.sendall(frame) - response = bytearray() - while len(response) <= MAX_FRAME_BYTES: - chunk = client.recv(min(64 * 1024, MAX_FRAME_BYTES + 1 - len(response))) - if not chunk: - break - response.extend(chunk) - if b"\n" in chunk: - break - try: - decoded = json.loads(bytes(response).decode()) - except (UnicodeDecodeError, json.JSONDecodeError) as error: - raise SystemExit(f"invalid guest-control response: {error}") from None - if not decoded.get("ok"): - detail = decoded.get("error", {}) - raise SystemExit(f"{detail.get('code', 'error')}: {detail.get('message', 'request failed')}") - return decoded.get("result") - - -def write_json(value: object) -> None: - json.dump(value, sys.stdout, indent=2, sort_keys=True) - sys.stdout.write("\n") - - -def parse_environment(values: list[str]) -> dict[str, str]: - result = {} - for value in values: - if "=" not in value: - raise SystemExit(f"environment must use NAME=VALUE: {value}") - name, item = value.split("=", 1) - result[name] = item - return result - - -def main() -> int: - parser = argparse.ArgumentParser(prog="bloom-workspace") - commands = parser.add_subparsers(dest="command", required=True) - commands.add_parser("status", help="show watch identity and available capabilities") - commands.add_parser("hello", help="show the protocol and enforced limits") - - files = commands.add_parser("files", help="use bounded workspace file operations").add_subparsers(dest="files_command", required=True) - files_list = files.add_parser("list") - files_list.add_argument("path") - files_get = files.add_parser("get") - files_get.add_argument("path") - files_get.add_argument("output") - files_put = files.add_parser("put") - files_put.add_argument("input") - files_put.add_argument("path") - files_delete = files.add_parser("delete") - files_delete.add_argument("path") - - jobs = commands.add_parser("jobs", help="start and inspect structured jobs").add_subparsers(dest="jobs_command", required=True) - jobs_start = jobs.add_parser("start") - jobs_start.add_argument("--cwd", default=".", help="relative directory below /workspace (default: root)") - jobs_start.add_argument("--timeout-ms", type=int, default=15 * 60 * 1000) - jobs_start.add_argument("--env", action="append", default=[]) - jobs_start.add_argument("argv", nargs=argparse.REMAINDER) - jobs_status = jobs.add_parser("status") - jobs_status.add_argument("job_id") - jobs_status.add_argument("--cursor", type=int, default=0) - jobs_cancel = jobs.add_parser("cancel") - jobs_cancel.add_argument("job_id") - args = parser.parse_args() - - if args.command == "status": - write_json(request("bloom.status")) - elif args.command == "hello": - write_json(request("hello")) - elif args.command == "files": - if args.files_command == "list": - write_json(request("fs.list", path=args.path)) - elif args.files_command == "get": - offset = 0 - with open(args.output, "wb") as output: - while True: - result = request("fs.read", path=args.path, offset=offset, maxBytes=MAX_CHUNK_BYTES) - data = base64.b64decode(result["data"], validate=True) - output.write(data) - offset = result["nextOffset"] - if result["eof"]: - break - write_json({"path": args.path, "output": args.output, "size": offset}) - elif args.files_command == "put": - size = os.path.getsize(args.input) - if size > 8 * 1024 * 1024: - raise SystemExit("input exceeds the 8 MiB file limit") - offset = 0 - with open(args.input, "rb") as source: - while True: - chunk = source.read(MAX_CHUNK_BYTES) - if not chunk and offset: - break - result = request( - "fs.write", - path=args.path, - offset=offset, - data=base64.b64encode(chunk).decode("ascii"), - truncate=offset == 0, - ) - offset = result["nextOffset"] - if not chunk: - break - write_json({"path": args.path, "size": offset}) - elif args.files_command == "delete": - write_json(request("fs.delete", path=args.path, recursive=False)) - elif args.command == "jobs": - if args.jobs_command == "start": - argv = args.argv[1:] if args.argv[:1] == ["--"] else args.argv - if not argv: - raise SystemExit("jobs start requires an argv after --") - write_json( - request( - "job.start", - jobId=str(uuid.uuid4()), - argv=argv, - cwd=args.cwd, - environment=parse_environment(args.env), - timeoutMs=args.timeout_ms, - ) - ) - elif args.jobs_command == "status": - write_json(request("job.status", jobId=args.job_id, logOffset=args.cursor, maxBytes=MAX_CHUNK_BYTES)) - elif args.jobs_command == "cancel": - write_json(request("job.cancel", jobId=args.job_id)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/ops/guest-control/src/client.rs b/ops/guest-control/src/client.rs new file mode 100644 index 0000000..ddb20f2 --- /dev/null +++ b/ops/guest-control/src/client.rs @@ -0,0 +1,417 @@ +//! Guest-local CLI client for the bloom-guest-control service. +//! +//! Connects to the Unix socket and provides `status`, `hello`, `files`, and +//! `jobs` subcommands — a direct port of the original Python `bloom-workspace`. + +use base64::Engine; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; + +const SOCKET_PATH: &str = "/run/bloom/guest-control.sock"; +const MAX_FRAME_BYTES: usize = 384 * 1024; +const MAX_CHUNK_BYTES: usize = 256 * 1024; + +fn get_socket_path() -> String { + std::env::var("BLOOM_GUEST_CONTROL_SOCKET").unwrap_or_else(|_| SOCKET_PATH.to_string()) +} + +fn send_request(operation: &str, fields: Value) -> Result { + let id = format!( + "{:x}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let mut msg = json!({ "version": 1, "id": id, "operation": operation }); + if let Value::Object(ref mut map) = msg { + if let Value::Object(fields_map) = fields { + for (k, v) in fields_map { + map.insert(k, v); + } + } + } + let frame = serde_json::to_string(&msg).map_err(|e| format!("serialize error: {e}"))?; + let frame = frame + "\n"; + let frame_bytes = frame.as_bytes(); + if frame_bytes.len() > MAX_FRAME_BYTES { + return Err("request exceeds the guest-control frame limit".into()); + } + + let socket_path = get_socket_path(); + let mut client = UnixStream::connect(&socket_path) + .map_err(|e| format!("cannot connect to guest control socket: {e}"))?; + client + .write_all(frame_bytes) + .map_err(|e| format!("socket write failed: {e}"))?; + + let mut response = Vec::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = client + .read(&mut buf) + .map_err(|e| format!("socket read failed: {e}"))?; + if n == 0 { + break; + } + response.extend_from_slice(&buf[..n]); + if response.len() > MAX_FRAME_BYTES { + break; + } + if response.contains(&b'\n') { + break; + } + } + + let decoded: Value = serde_json::from_slice(&response) + .map_err(|e| format!("invalid guest-control response: {e}"))?; + if !decoded.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + let code = decoded + .get("error") + .and_then(|e| e.get("code")) + .and_then(|c| c.as_str()) + .unwrap_or("error"); + let msg = decoded + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("request failed"); + return Err(format!("{code}: {msg}")); + } + Ok(decoded.get("result").cloned().unwrap_or(Value::Null)) +} + +fn print_json(value: &Value) { + let pretty = serde_json::to_string_pretty(value).unwrap_or_default(); + println!("{pretty}"); +} + +pub fn run(args: &[String]) -> i32 { + if args.is_empty() { + eprintln!("usage: bloom-workspace ..."); + return 1; + } + + let command = &args[0]; + let rest = &args[1..]; + + match command.as_str() { + "status" => match send_request("bloom.status", json!({})) { + Ok(result) => { + print_json(&result); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + }, + "hello" => match send_request("hello", json!({})) { + Ok(result) => { + print_json(&result); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + }, + "files" => { + if rest.is_empty() { + eprintln!("usage: bloom-workspace files ..."); + return 1; + } + let sub = &rest[0]; + let sub_args = &rest[1..]; + match sub.as_str() { + "list" => { + if sub_args.len() < 1 { + eprintln!("usage: bloom-workspace files list "); + return 1; + } + match send_request("fs.list", json!({ "path": sub_args[0] })) { + Ok(result) => { + print_json(&result); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + } + } + "get" => { + if sub_args.len() < 2 { + eprintln!("usage: bloom-workspace files get "); + return 1; + } + let path = &sub_args[0]; + let output = &sub_args[1]; + match download_file(path, output) { + Ok(()) => 0, + Err(e) => { + eprintln!("{e}"); + 1 + } + } + } + "put" => { + if sub_args.len() < 2 { + eprintln!("usage: bloom-workspace files put "); + return 1; + } + let input = &sub_args[0]; + let path = &sub_args[1]; + match upload_file(input, path) { + Ok(()) => 0, + Err(e) => { + eprintln!("{e}"); + 1 + } + } + } + "delete" => { + if sub_args.len() < 1 { + eprintln!("usage: bloom-workspace files delete "); + return 1; + } + match send_request( + "fs.delete", + json!({ "path": sub_args[0], "recursive": false }), + ) { + Ok(result) => { + print_json(&result); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + } + } + _ => { + eprintln!("unknown files subcommand: {sub}"); + 1 + } + } + } + "jobs" => { + if rest.is_empty() { + eprintln!("usage: bloom-workspace jobs ..."); + return 1; + } + let sub = &rest[0]; + let sub_args = &rest[1..]; + match sub.as_str() { + "start" => { + // Parse flags: --cwd, --timeout-ms, --env, then -- argv + let mut cwd = ".".to_string(); + let mut timeout_ms: u64 = 15 * 60 * 1000; + let mut env: HashMap = HashMap::new(); + let mut argv: Vec = Vec::new(); + let mut i = 0; + while i < sub_args.len() { + match sub_args[i].as_str() { + "--cwd" => { + i += 1; + if i < sub_args.len() { + cwd = sub_args[i].clone(); + } + } + "--timeout-ms" => { + i += 1; + if i < sub_args.len() { + timeout_ms = sub_args[i].parse().unwrap_or(15 * 60 * 1000); + } + } + "--env" => { + i += 1; + if i < sub_args.len() { + if let Some((k, v)) = sub_args[i].split_once('=') { + env.insert(k.to_string(), v.to_string()); + } + } + } + "--" => { + i += 1; + while i < sub_args.len() { + argv.push(sub_args[i].clone()); + i += 1; + } + } + _ => { + argv.push(sub_args[i].clone()); + } + } + i += 1; + } + if argv.is_empty() { + eprintln!("jobs start requires an argv after --"); + return 1; + } + let job_id = format!( + "{:x}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let env_json: Value = env + .into_iter() + .collect::>() + .into_iter() + .map(|(k, v)| (k, json!(v))) + .collect(); + match send_request( + "job.start", + json!({ + "jobId": job_id, + "argv": argv, + "cwd": cwd, + "environment": env_json, + "timeoutMs": timeout_ms, + }), + ) { + Ok(result) => { + print_json(&result); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + } + } + "status" => { + if sub_args.len() < 1 { + eprintln!("usage: bloom-workspace jobs status [--cursor N]"); + return 1; + } + let job_id = &sub_args[0]; + let mut cursor: u64 = 0; + let mut i = 1; + while i < sub_args.len() { + if sub_args[i] == "--cursor" { + i += 1; + if i < sub_args.len() { + cursor = sub_args[i].parse().unwrap_or(0); + } + } + i += 1; + } + match send_request( + "job.status", + json!({ + "jobId": job_id, + "logOffset": cursor, + "maxBytes": MAX_CHUNK_BYTES, + }), + ) { + Ok(result) => { + print_json(&result); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + } + } + "cancel" => { + if sub_args.len() < 1 { + eprintln!("usage: bloom-workspace jobs cancel "); + return 1; + } + match send_request("job.cancel", json!({ "jobId": sub_args[0] })) { + Ok(result) => { + print_json(&result); + 0 + } + Err(e) => { + eprintln!("{e}"); + 1 + } + } + } + _ => { + eprintln!("unknown jobs subcommand: {sub}"); + 1 + } + } + } + _ => { + eprintln!("unknown command: {command}"); + 1 + } + } +} + +fn download_file(path: &str, output: &str) -> Result<(), String> { + let mut offset: u64 = 0; + let mut file = + std::fs::File::create(output).map_err(|e| format!("cannot create output file: {e}"))?; + loop { + let result = send_request( + "fs.read", + json!({ + "path": path, + "offset": offset, + "maxBytes": MAX_CHUNK_BYTES, + }), + )?; + let data_b64 = result.get("data").and_then(|d| d.as_str()).unwrap_or(""); + let data = base64::engine::general_purpose::STANDARD + .decode(data_b64) + .map_err(|e| format!("base64 decode error: {e}"))?; + file.write_all(&data) + .map_err(|e| format!("write error: {e}"))?; + offset = result + .get("nextOffset") + .and_then(|o| o.as_u64()) + .unwrap_or(offset + data.len() as u64); + if result.get("eof").and_then(|e| e.as_bool()).unwrap_or(true) { + break; + } + } + print_json(&json!({ "path": path, "output": output, "size": offset })); + Ok(()) +} + +fn upload_file(input: &str, path: &str) -> Result<(), String> { + let metadata = std::fs::metadata(input).map_err(|e| format!("cannot stat input: {e}"))?; + let size = metadata.len(); + if size > 8 * 1024 * 1024 { + return Err("input exceeds the 8 MiB file limit".into()); + } + let data = std::fs::read(input).map_err(|e| format!("cannot read input: {e}"))?; + let mut offset: u64 = 0; + let chunk_size = MAX_CHUNK_BYTES; + let mut first = true; + while offset < data.len() as u64 || first { + let end = ((offset as usize) + chunk_size).min(data.len()); + let chunk = &data[offset as usize..end]; + let encoded = base64::engine::general_purpose::STANDARD.encode(chunk); + let result = send_request( + "fs.write", + json!({ + "path": path, + "offset": offset, + "data": encoded, + "truncate": first, + }), + )?; + offset = result + .get("nextOffset") + .and_then(|o| o.as_u64()) + .unwrap_or(end as u64); + first = false; + if chunk.is_empty() { + break; + } + } + print_json(&json!({ "path": path, "size": offset })); + Ok(()) +} diff --git a/ops/guest-control/src/constants.rs b/ops/guest-control/src/constants.rs new file mode 100644 index 0000000..11baef9 --- /dev/null +++ b/ops/guest-control/src/constants.rs @@ -0,0 +1,53 @@ +//! Compile-time constants matching the Python implementation. + +pub const PROTOCOL_VERSION: u32 = 1; +pub const MAX_FRAME_BYTES: usize = 384 * 1024; +pub const MAX_FILE_CHUNK_BYTES: usize = 256 * 1024; +pub const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; +pub const MAX_LOG_CHUNK_BYTES: usize = 256 * 1024; +pub const MAX_LOG_BYTES: usize = 1024 * 1024; +pub const MAX_LIST_ENTRIES: usize = 1000; +pub const MAX_SCAN_ENTRIES: usize = 20_000; +pub const MAX_ACTIVE_JOBS: usize = 2; +pub const MAX_RETAINED_JOBS: usize = 64; +pub const MAX_JOB_PROCESSES: u64 = 64; +pub const MAX_JOB_FILE_BYTES: u64 = 64 * 1024 * 1024; +pub const MAX_JOB_TIMEOUT_MS: u64 = 2 * 60 * 60 * 1000; +pub const JOB_KILL_GRACE_SECONDS: f64 = 1.0; +pub const BLOOM_SOCKET_PATH: &str = "/workspace/.bloom/run/bloom.sock"; +pub const MAX_OUTBOX_PLAN_BYTES: usize = 64 * 1024; +pub const MAX_OUTBOX_CHAINS: usize = 16; +pub const MAX_OUTBOX_PENDING: usize = 32; + +/// AF_VSOCK constant on Linux (40). +pub const AF_VSOCK: libc::c_int = 40; +/// VMADDR_CID_ANY +pub const VMADDR_CID_ANY: u32 = 0xFFFFFFFF; + +/// Environment variables passed through to jobs. +pub const USER_ENV_EXACT: &[&str] = &[ + "CI", + "DEBUG", + "FORCE_COLOR", + "LANG", + "LC_ALL", + "LOG_LEVEL", + "NODE_ENV", + "NO_COLOR", + "PYTHONUNBUFFERED", + "RUST_BACKTRACE", + "RUST_LOG", + "TERM", + "TZ", +]; + +pub const USER_ENV_PREFIXES: &[&str] = &["APP_", "JOB_", "TEST_"]; + +pub const SYSTEM_PROXY_ENV: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", +]; diff --git a/ops/guest-control/src/control.rs b/ops/guest-control/src/control.rs new file mode 100644 index 0000000..41b858d --- /dev/null +++ b/ops/guest-control/src/control.rs @@ -0,0 +1,849 @@ +//! Guest control dispatcher: routes operations, manages Bloom status and SSH/NFS connections. + +use crate::constants::*; +use crate::error::ControlError; +use crate::files::WorkspaceFiles; +use crate::jobs::JobEngine; +use crate::validate::*; +use base64::Engine; +use once_cell::sync::Lazy; +use regex::Regex; +use serde_json::{json, Value}; +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +static SSH_CA_PUBLIC_KEY_RE: Lazy = + Lazy::new(|| Regex::new(r"^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$").unwrap()); + +struct ConnectionState { + sshd: Option, // pid + mountd: Option, // pid + scope: Option<(String, String, bool)>, // (workspace_id, wallet, nfs_enabled) +} + +pub struct GuestControl { + files: Arc, + jobs: Arc, + conn: Mutex, +} + +impl GuestControl { + pub fn new(files: Arc, jobs: Arc) -> Self { + Self { + files, + jobs, + conn: Mutex::new(ConnectionState { + sshd: None, + mountd: None, + scope: None, + }), + } + } + + pub fn close(&self) { + let mut conn = self.conn.lock().unwrap(); + if let Some(pid) = conn.sshd { + if unsafe { libc::kill(pid as i32, 0) } == 0 { + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + // Wait briefly + for _ in 0..20 { + if unsafe { libc::kill(pid as i32, 0) } != 0 { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + } + } + conn.sshd = None; + if let Some(scope) = &conn.scope { + if scope.2 { + let _ = self.stop_partial_nfs(&mut conn); + } + } + } + + pub fn handle(&self, raw: &Value) -> Value { + let request_id = raw + .get("id") + .and_then(|v| v.as_str()) + .filter(|s| REQUEST_ID.is_match(s)) + .unwrap_or("invalid") + .to_string(); + + let result = (|| -> Result { + validate_request(raw)?; + let operation = raw["operation"].as_str().unwrap_or(""); + match operation { + "hello" => Ok(json!({ + "protocolVersion": PROTOCOL_VERSION, + "operations": [ + "fs.list", "fs.read", "fs.write", "fs.delete", + "job.start", "job.status", "job.cancel", + "bloom.status", "connections.configure", "ceremony.pending" + ], + "limits": { + "fileChunkBytes": MAX_FILE_CHUNK_BYTES, + "fileBytes": MAX_FILE_BYTES, + "activeJobs": MAX_ACTIVE_JOBS, + "retainedJobs": MAX_RETAINED_JOBS, + "jobProcesses": MAX_JOB_PROCESSES, + "jobLogBytes": MAX_LOG_BYTES, + "jobTimeoutMs": MAX_JOB_TIMEOUT_MS, + } + })), + "fs.list" => self.files.list(&raw["path"]), + "fs.read" => self + .files + .read(&raw["path"], &raw["offset"], &raw["maxBytes"]), + "fs.write" => { + self.files + .write(&raw["path"], &raw["offset"], &raw["data"], &raw["truncate"]) + } + "fs.delete" => self.files.delete(&raw["path"], &raw["recursive"]), + "job.start" => { + // Clone the request to avoid lifetime issues + self.jobs.start(raw) + } + "job.status" => self.jobs.status(raw), + "job.cancel" => self.jobs.cancel(raw), + "bloom.status" => self.bloom_status(), + "connections.configure" => self.configure_connections(raw), + "ceremony.pending" => self.ceremony_pending(), + _ => Err(ControlError::invalid_request("unknown guest operation")), + } + })(); + + match result { + Ok(val) => { + json!({ "version": PROTOCOL_VERSION, "id": request_id, "ok": true, "result": val }) + } + Err(e) => { + json!({ "version": PROTOCOL_VERSION, "id": request_id, "ok": false, "error": { "code": e.code, "message": truncate_str(&e.message, 1024) } }) + } + } + } + + fn bloom_status(&self) -> Result { + let address = self.files.watch_identity(); + let has_addr = address.is_some(); + let executable = which("bloom").is_some(); + Ok(json!({ + "available": executable && has_addr, + "mount": { "path": "/bloom", "mounted": is_mount("/bloom") }, + "identity": if has_addr { + json!({ "kind": "watch", "address": address.unwrap() }) + } else { + Value::Null + }, + "capabilities": { + "files": true, + "jobs": true, + "bloomRead": executable && has_addr, + "walletSigning": false, + "transactions": false, + }, + "helper": { "name": "bloom-workspace", "protocolVersion": PROTOCOL_VERSION }, + })) + } + + fn bloom_ipc(&self, method: &str, params: &Value) -> Result { + if !Path::new(BLOOM_SOCKET_PATH).exists() { + return Err(ControlError::unavailable( + "Bloom IPC socket is not available", + )); + } + let request = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }); + let frame = serde_json::to_string(&request).unwrap() + "\n"; + let mut client = UnixStream::connect(BLOOM_SOCKET_PATH) + .map_err(|_| ControlError::unavailable("cannot connect to Bloom IPC"))?; + client.set_read_timeout(Some(Duration::from_secs(10))).ok(); + client.set_write_timeout(Some(Duration::from_secs(10))).ok(); + client + .write_all(frame.as_bytes()) + .map_err(|_| ControlError::internal("bloom IPC write failed"))?; + let mut response = Vec::new(); + let mut buf = [0u8; 65536]; + loop { + let n = client + .read(&mut buf) + .map_err(|_| ControlError::internal("bloom IPC read failed"))?; + if n == 0 { + break; + } + response.extend_from_slice(&buf[..n]); + if response.contains(&b'\n') { + break; + } + if response.len() > 1024 * 1024 { + break; + } + } + let decoded: Value = serde_json::from_slice(&response) + .map_err(|_| ControlError::internal("bloom IPC returned invalid JSON"))?; + if decoded.get("error").is_some() { + return Err(ControlError::internal(format!( + "bloom IPC error: {}", + decoded["error"] + ))); + } + Ok(decoded.get("result").cloned().unwrap_or(Value::Null)) + } + + fn ceremony_pending(&self) -> Result { + let wallet_result = self.bloom_ipc("list", &json!({ "path": "/wallets" }))?; + let wallets: Vec = wallet_result + .get("entries") + .and_then(|e| e.as_array()) + .map(|arr| { + arr.iter() + .filter(|e| e.get("type").and_then(|t| t.as_str()) == Some("dir")) + .filter_map(|e| { + e.get("name") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + + let mut pending = Vec::new(); + for wallet in wallets.iter().take(MAX_OUTBOX_CHAINS) { + let chain_result = self.bloom_ipc( + "list", + &json!({ "path": format!("/wallets/{}/chains", wallet) }), + )?; + let chains: Vec = chain_result + .get("entries") + .and_then(|e| e.as_array()) + .map(|arr| { + arr.iter() + .filter(|e| e.get("type").and_then(|t| t.as_str()) == Some("dir")) + .filter_map(|e| { + e.get("name") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + + for chain in chains.iter().take(MAX_OUTBOX_CHAINS) { + let pending_result = self.bloom_ipc("list", &json!({ "path": format!("/wallets/{}/chains/{}/outbox/pending", wallet, chain) }))?; + let ids: Vec = pending_result + .get("entries") + .and_then(|e| e.as_array()) + .map(|arr| { + arr.iter() + .filter(|e| e.get("type").and_then(|t| t.as_str()) == Some("dir")) + .filter_map(|e| { + e.get("name") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + + for tx_id in ids.iter().take(MAX_OUTBOX_PENDING) { + let plan_result = self.bloom_ipc("read", &json!({ "path": format!("/wallets/{}/chains/{}/outbox/pending/{}/plan.md", wallet, chain, tx_id) }))?; + let plan_b64 = plan_result + .get("bytes_b64") + .and_then(|b| b.as_str()) + .unwrap_or(""); + let plan_md = base64::engine::general_purpose::STANDARD + .decode(plan_b64) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap_or_default(); + let plan_md = if plan_md.len() > MAX_OUTBOX_PLAN_BYTES { + plan_md[..MAX_OUTBOX_PLAN_BYTES].to_string() + } else { + plan_md + }; + + let ceremony_url = self.bloom_ipc("read", &json!({ "path": format!("/wallets/{}/chains/{}/outbox/pending/{}/approval_challenge.json", wallet, chain, tx_id) })) + .ok() + .and_then(|result| { + let b64 = result.get("bytes_b64").and_then(|b| b.as_str())?; + let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?; + let json: Value = serde_json::from_slice(&bytes).ok()?; + json.get("ceremony_url").and_then(|u| u.as_str()).map(|s| s.to_string()) + }); + + pending.push(json!({ + "id": tx_id, + "chain": chain, + "wallet": wallet, + "planMd": plan_md, + "ceremonyUrl": ceremony_url, + })); + } + } + } + Ok(json!({ "requests": pending })) + } + + fn configure_connections(&self, request: &Value) -> Result { + let mut conn = self.conn.lock().unwrap(); + self.configure_connections_locked(&mut conn, request) + } + + fn configure_connections_locked( + &self, + conn: &mut ConnectionState, + request: &Value, + ) -> Result { + let workspace_id = request["workspaceId"] + .as_str() + .ok_or_else(|| ControlError::invalid_request("invalid workspace id"))?; + if !WORKSPACE_ID.is_match(workspace_id) { + return Err(ControlError::invalid_request("invalid workspace id")); + } + let wallet = request["wallet"] + .as_str() + .ok_or_else(|| ControlError::invalid_request("invalid workspace wallet"))?; + if !EVM_ADDRESS.is_match(wallet) { + return Err(ControlError::invalid_request("invalid workspace wallet")); + } + let ca_public_key = request["caPublicKey"] + .as_str() + .ok_or_else(|| ControlError::invalid_request("invalid SSH CA public key"))?; + if !SSH_CA_PUBLIC_KEY_RE.is_match(ca_public_key) { + return Err(ControlError::invalid_request("invalid SSH CA public key")); + } + let nfs_enabled = request["nfs"] + .as_bool() + .ok_or_else(|| ControlError::invalid_request("invalid NFS capability"))?; + + let scope = (workspace_id.to_string(), wallet.to_string(), nfs_enabled); + if let Some(existing) = &conn.scope { + if *existing == scope { + return Ok(self.connection_status(conn, workspace_id, nfs_enabled)); + } + return Err(ControlError::conflict( + "workspace connection scope is already configured", + )); + } + if unsafe { libc::geteuid() } != 0 { + return Err(ControlError::unavailable( + "workspace connections require the root guest controller", + )); + } + if which("sshd").is_none() || which("ssh-keygen").is_none() { + return Err(ControlError::unavailable( + "OpenSSH server tooling is unavailable", + )); + } + + // Validate CA public key payload + let parts: Vec<&str> = ca_public_key.splitn(2, ' ').collect(); + if parts.len() != 2 { + return Err(ControlError::invalid_request("invalid SSH CA public key")); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(parts[1]) + .map_err(|_| ControlError::invalid_request("invalid SSH CA public key"))?; + if decoded.len() < 32 || decoded.len() > 128 { + return Err(ControlError::invalid_request( + "invalid SSH CA public key payload", + )); + } + + let dir = "/run/bloom/ssh"; + std::fs::create_dir_all(dir) + .map_err(|_| ControlError::internal("cannot create ssh dir"))?; + set_mode(dir, 0o700); + + let ca_path = format!("{}/user_ca.pub", dir); + let principals_path = format!("{}/authorized_principals", dir); + let host_key_path = format!("{}/ssh_host_ed25519_key", dir); + + let owner_digest = sha256_hex(wallet.as_bytes()); + let owner_digest = &owner_digest[..32]; + let mut principals = vec![format!("bloom-shell-{}-w-{}", workspace_id, owner_digest)]; + if nfs_enabled { + principals.push(format!("bloom-nfs-{}-w-{}", workspace_id, owner_digest)); + } + + write_private_file(&ca_path, &format!("{}\n", ca_public_key), 0o600); + write_private_file( + &principals_path, + &format!("{}\n", principals.join("\n")), + 0o600, + ); + + if !Path::new(&host_key_path).exists() { + let status = std::process::Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", "", "-f", &host_key_path]) + .status(); + if !matches!(status, Ok(s) if s.success()) { + return Err(ControlError::unavailable("ssh-keygen failed")); + } + } + set_mode(&host_key_path, 0o600); + set_mode(&format!("{}.pub", host_key_path), 0o644); + + let shell = "/usr/local/libexec/bloom-workspace-shell"; + if !Path::new(shell).is_file() || is_symlink(shell) { + return Err(ControlError::unavailable( + "workspace SSH shell helper is unavailable", + )); + } + + let host_key_opt = format!("HostKey={}", host_key_path); + let ca_keys_opt = format!("TrustedUserCAKeys={}", ca_path); + let principals_opt = format!("AuthorizedPrincipalsFile={}", principals_path); + let sshd_argv: Vec<&str> = vec![ + "sshd", + "-D", + "-e", + "-f", + "/etc/ssh/sshd_config", + "-o", + &host_key_opt, + "-o", + &ca_keys_opt, + "-o", + &principals_opt, + "-o", + "AuthorizedKeysFile=none", + "-o", + "AuthenticationMethods=publickey", + "-o", + "PubkeyAuthentication=yes", + "-o", + "PasswordAuthentication=no", + "-o", + "KbdInteractiveAuthentication=no", + "-o", + "PermitRootLogin=no", + "-o", + "AllowUsers=workspace", + "-o", + "AllowAgentForwarding=no", + "-o", + "AllowTcpForwarding=local", + "-o", + "PermitOpen=127.0.0.1:2049 127.0.0.1:18734", + "-o", + "AllowStreamLocalForwarding=no", + "-o", + "GatewayPorts=no", + "-o", + "X11Forwarding=no", + "-o", + "PermitTunnel=no", + "-o", + "PermitUserEnvironment=no", + "-o", + "PermitUserRC=no", + "-o", + "PermitTTY=yes", + "-o", + "MaxSessions=1", + "-o", + "UsePAM=no", + "-o", + "AddressFamily=inet", + "-o", + "ListenAddress=0.0.0.0", + "-o", + "Port=22", + ]; + + let sshd_child = std::process::Command::new("sshd") + .args(&sshd_argv[1..]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|_| ControlError::unavailable("workspace sshd failed to start"))?; + std::thread::sleep(Duration::from_millis(50)); + let sshd_pid = sshd_child.id(); + + if nfs_enabled { + if let Err(e) = self.start_nfs(workspace_id) { + // Kill sshd + unsafe { + libc::kill(sshd_pid as i32, libc::SIGTERM); + } + std::thread::sleep(Duration::from_secs(2)); + unsafe { + libc::kill(sshd_pid as i32, libc::SIGKILL); + } + return Err(e); + } + conn.mountd = Some(0); // placeholder, set in start_nfs + } + + conn.sshd = Some(sshd_pid); + conn.scope = Some(scope); + Ok(self.connection_status(conn, workspace_id, nfs_enabled)) + } + + fn start_nfs(&self, workspace_id: &str) -> Result<(), ControlError> { + for cmd in &["exportfs", "rpc.mountd", "rpc.nfsd"] { + if which(cmd).is_none() { + self.stop_partial_nfs(&mut self.conn.lock().unwrap()); + return Err(ControlError::unavailable(&format!( + "NFS server tool is unavailable: {}", + cmd + ))); + } + } + std::fs::create_dir_all("/proc/fs/nfsd").ok(); + if !is_mount("/proc/fs/nfsd") { + let r = std::process::Command::new("mount") + .args(["-t", "nfsd", "nfsd", "/proc/fs/nfsd"]) + .status(); + if !matches!(r, Ok(s) if s.success()) { + self.stop_partial_nfs(&mut self.conn.lock().unwrap()); + return Err(ControlError::unavailable("failed to mount nfsd filesystem")); + } + } + let options = "rw,fsid=0,sync,no_subtree_check,root_squash,all_squash,anonuid=1000,anongid=1000,insecure"; + let r = std::process::Command::new("exportfs") + .args(["-i", "-o", options, "127.0.0.1:/workspace"]) + .status(); + if !matches!(r, Ok(s) if s.success()) { + self.stop_partial_nfs(&mut self.conn.lock().unwrap()); + return Err(ControlError::unavailable("exportfs failed")); + } + + let mountd_child = std::process::Command::new("rpc.mountd") + .args([ + "--foreground", + "--no-udp", + "--no-nfs-version", + "2", + "--no-nfs-version", + "3", + "--ttl", + "10", + ]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + match mountd_child { + Ok(child) => { + std::thread::sleep(Duration::from_millis(50)); + let mountd_pid = child.id(); + if unsafe { libc::kill(mountd_pid as i32, 0) } != 0 { + self.stop_partial_nfs(&mut self.conn.lock().unwrap()); + return Err(ControlError::unavailable( + "NFS mount daemon failed to start", + )); + } + { + let mut conn = self.conn.lock().unwrap(); + conn.mountd = Some(mountd_pid); + } + } + Err(_) => { + self.stop_partial_nfs(&mut self.conn.lock().unwrap()); + return Err(ControlError::unavailable("rpc.mountd failed to start")); + } + } + + let r = std::process::Command::new("rpc.nfsd") + .args([ + "--host", + "127.0.0.1", + "--no-udp", + "--no-nfs-version", + "2", + "--no-nfs-version", + "3", + "--nfs-version", + "4", + "--leasetime", + "10", + "--grace-time", + "10", + "--port", + "2049", + "1", + ]) + .status(); + if !matches!(r, Ok(s) if s.success()) { + self.stop_partial_nfs(&mut self.conn.lock().unwrap()); + return Err(ControlError::unavailable("rpc.nfsd failed to start")); + } + Ok(()) + } + + fn stop_partial_nfs(&self, conn: &mut ConnectionState) { + if let Some(pid) = conn.mountd { + if pid > 0 && unsafe { libc::kill(pid as i32, 0) } == 0 { + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + for _ in 0..20 { + if unsafe { libc::kill(pid as i32, 0) } != 0 { + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + } + } + conn.mountd = None; + if which("rpc.nfsd").is_some() { + let _ = std::process::Command::new("rpc.nfsd").arg("0").status(); + } + if which("exportfs").is_some() { + let _ = std::process::Command::new("exportfs") + .args(["-u", "127.0.0.1:/workspace"]) + .status(); + } + } + + fn connection_status( + &self, + conn: &ConnectionState, + workspace_id: &str, + nfs_enabled: bool, + ) -> Value { + let host_key = read_small_regular_file("/run/bloom/ssh/ssh_host_ed25519_key.pub", 1024); + let host_key = host_key + .filter(|h| h.starts_with("ssh-ed25519 ")) + .map(|h| h.split_whitespace().take(2).collect::>().join(" ")); + let host_key = match host_key { + Some(hk) => hk, + None => { + return json!({ "error": { "code": "unavailable", "message": "guest SSH host key is unavailable" } }) + } + }; + let ssh_available = conn + .sshd + .map(|p| unsafe { libc::kill(p as i32, 0) } == 0) + .unwrap_or(false); + let nfs_available = nfs_enabled + && conn + .mountd + .map(|p| p > 0 && unsafe { libc::kill(p as i32, 0) } == 0) + .unwrap_or(false); + json!({ + "ssh": { "available": ssh_available, "hostKey": host_key, "port": 22 }, + "nfs": { "available": nfs_available, "port": if nfs_enabled { Some(2049) } else { None } }, + "workspaceId": workspace_id, + }) + } +} + +// Helper functions + +fn truncate_str(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + s[..max].to_string() + } +} + +fn is_mount(path: &str) -> bool { + let c_path = std::ffi::CString::new(path).unwrap(); + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + let mut parent_st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::stat(c_path.as_ptr(), &mut st) } != 0 { + return false; + } + let parent = Path::new(path).parent().unwrap_or(Path::new("/")); + let c_parent = std::ffi::CString::new(parent.to_string_lossy().as_bytes()).unwrap(); + if unsafe { libc::stat(c_parent.as_ptr(), &mut parent_st) } != 0 { + return false; + } + st.st_dev != parent_st.st_dev +} + +fn is_symlink(path: &str) -> bool { + std::fs::symlink_metadata(path) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) +} + +fn set_mode(path: &str, mode: libc::mode_t) { + let c_path = std::ffi::CString::new(path).unwrap(); + unsafe { + libc::chmod(c_path.as_ptr(), mode); + } +} + +fn write_private_file(path: &str, content: &str, mode: libc::mode_t) { + let c_path = std::ffi::CString::new(path).unwrap(); + let fd = unsafe { + libc::open( + c_path.as_ptr(), + libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_NOFOLLOW, + mode, + ) + }; + if fd < 0 { + return; + } + unsafe { + libc::fchmod(fd, mode); + let bytes = content.as_bytes(); + let mut written: usize = 0; + while written < bytes.len() { + let n = libc::write( + fd, + bytes[written..].as_ptr() as *const _, + bytes.len() - written, + ); + if n <= 0 { + break; + } + written += n as usize; + } + libc::fsync(fd); + libc::close(fd); + } +} + +fn read_small_regular_file(path: &str, maximum: usize) -> Option { + let c_path = std::ffi::CString::new(path).ok()?; + let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_NOFOLLOW) }; + if fd < 0 { + return None; + } + let result = unsafe { + let mut st: libc::stat = std::mem::zeroed(); + if libc::fstat(fd, &mut st) != 0 { + return None; + } + if (st.st_mode & libc::S_IFMT as libc::mode_t) != libc::S_IFREG as libc::mode_t { + return None; + } + if st.st_size > maximum as i64 { + return None; + } + let mut buf = vec![0u8; maximum]; + let n = libc::read(fd, buf.as_mut_ptr() as *mut _, maximum); + libc::close(fd); + if n < 0 { + return None; + } + buf.truncate(n as usize); + String::from_utf8(buf).ok() + }; + result.map(|s| s.trim().to_string()) +} + +fn sha256_hex(data: &[u8]) -> String { + // Minimal SHA-256 implementation + let mut hash = [0u32; 8]; + hash[0] = 0x6a09e667; + hash[1] = 0xbb67ae85; + hash[2] = 0x3c6ef372; + hash[3] = 0xa54ff53a; + hash[4] = 0x510e527f; + hash[5] = 0x9b05688c; + hash[6] = 0x1f83d9ab; + hash[7] = 0x5be0cd19; + + let k: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, + ]; + + // Pad message + let mut msg = data.to_vec(); + let bit_len = (msg.len() * 8) as u64; + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&bit_len.to_be_bytes()); + + for chunk in msg.chunks(64) { + let mut w = [0u32; 64]; + for i in 0..16 { + w[i] = u32::from_be_bytes([ + chunk[i * 4], + chunk[i * 4 + 1], + chunk[i * 4 + 2], + chunk[i * 4 + 3], + ]); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + let mut a = hash[0]; + let mut b = hash[1]; + let mut c = hash[2]; + let mut d = hash[3]; + let mut e = hash[4]; + let mut f = hash[5]; + let mut g = hash[6]; + let mut h = hash[7]; + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ (!e & g); + let temp1 = h + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(k[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let temp2 = s0.wrapping_add(maj); + h = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + hash[0] = hash[0].wrapping_add(a); + hash[1] = hash[1].wrapping_add(b); + hash[2] = hash[2].wrapping_add(c); + hash[3] = hash[3].wrapping_add(d); + hash[4] = hash[4].wrapping_add(e); + hash[5] = hash[5].wrapping_add(f); + hash[6] = hash[6].wrapping_add(g); + hash[7] = hash[7].wrapping_add(h); + } + + let mut result = String::new(); + for h in &hash { + result.push_str(&format!("{:08x}", h)); + } + result +} + +fn which(cmd: &str) -> Option { + std::process::Command::new("which") + .arg(cmd) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) +} diff --git a/ops/guest-control/src/error.rs b/ops/guest-control/src/error.rs new file mode 100644 index 0000000..1f3df64 --- /dev/null +++ b/ops/guest-control/src/error.rs @@ -0,0 +1,59 @@ +//! Error types matching the Python ControlError. + +use std::fmt; + +#[derive(Debug, Clone)] +pub struct ControlError { + pub code: String, + pub message: String, +} + +impl ControlError { + pub fn new(code: &str, message: impl Into) -> Self { + Self { + code: code.to_string(), + message: message.into(), + } + } + + pub fn invalid_request(msg: impl Into) -> Self { + Self::new("invalid_request", msg) + } + pub fn not_found(msg: impl Into) -> Self { + Self::new("not_found", msg) + } + pub fn permission_denied(msg: impl Into) -> Self { + Self::new("permission_denied", msg) + } + pub fn limit_exceeded(msg: impl Into) -> Self { + Self::new("limit_exceeded", msg) + } + pub fn conflict(msg: impl Into) -> Self { + Self::new("conflict", msg) + } + pub fn unavailable(msg: impl Into) -> Self { + Self::new("unavailable", msg) + } + pub fn internal(msg: impl Into) -> Self { + Self::new("internal", msg) + } +} + +impl fmt::Display for ControlError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ControlError {} + +/// Check an errno and map to appropriate ControlError. +pub fn from_io_error(error: &std::io::Error) -> ControlError { + let raw = error.raw_os_error().unwrap_or(0); + let code = match raw { + libc::ENOENT => "not_found", + libc::EACCES | libc::EPERM | libc::ELOOP | libc::ENOTDIR => "permission_denied", + _ => "internal", + }; + ControlError::new(code, "workspace operation failed") +} diff --git a/ops/guest-control/src/files.rs b/ops/guest-control/src/files.rs new file mode 100644 index 0000000..bcf9d08 --- /dev/null +++ b/ops/guest-control/src/files.rs @@ -0,0 +1,642 @@ +//! Workspace file operations with path-traversal-safe directory walking. + +use crate::constants::*; +use crate::error::{from_io_error, ControlError}; +use crate::validate::{validate_relative_path, validate_workspace_directory}; +use base64::Engine; +use once_cell::sync::Lazy; +use regex::Regex; +use serde_json::{json, Value}; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{FileTypeExt, PermissionsExt}; +use std::os::unix::io::RawFd; +use std::path::Path; +use std::sync::Mutex; + +static EVM_ADDRESS_RE: Lazy = Lazy::new(|| Regex::new(r"^0x[0-9a-f]{40}$").unwrap()); + +pub struct WorkspaceFiles { + root: std::path::PathBuf, + root_fd: RawFd, + quota_bytes: u64, + owner_uid: u32, + owner_gid: u32, + write_lock: Mutex<()>, + _root_file: std::fs::File, // keeps root_fd alive +} + +impl WorkspaceFiles { + pub fn new( + root: &Path, + quota_bytes: u64, + owner_uid: u32, + owner_gid: u32, + ) -> Result { + let canonical = root + .canonicalize() + .map_err(|e| format!("workspace root does not exist: {e}"))?; + let meta = std::fs::symlink_metadata(&canonical) + .map_err(|e| format!("cannot stat workspace root: {e}"))?; + let mode = meta.permissions().mode(); + if mode & libc::S_IFMT as u32 == libc::S_IFLNK as u32 { + return Err("workspace root must not be a symlink".into()); + } + if !canonical.is_dir() { + return Err("workspace root must be a directory".into()); + } + use std::os::unix::fs::OpenOptionsExt; + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY) + .open(&canonical) + .map_err(|e| format!("unsafe or unavailable workspace root: {e}"))?; + + let root_fd = file.as_raw_fd(); + Ok(Self { + root: canonical, + root_fd, + quota_bytes, + owner_uid, + owner_gid, + write_lock: Mutex::new(()), + _root_file: file, + }) + } + + pub fn close(&self) { + // root_fd is closed when _root_file is dropped + } + + pub fn prepare_job_tmp(&self) -> Result<(), ControlError> { + let fd = self.open_directory(&[".tmp".to_string()], true)?; + unsafe { + libc::fchmod(fd, 0o700); + if unsafe { libc::geteuid() } == 0 { + unsafe { + libc::fchown(fd, self.owner_uid, self.owner_gid); + } + } + libc::close(fd); + } + Ok(()) + } + + fn open_directory(&self, parts: &[String], create: bool) -> Result { + let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW; + // Use openat(".", root_fd) instead of dup(root_fd) so each call gets an + // independent directory handle with its own read position. dup() shares + // the open file description, so readdir in one consumer advances the + // position for all copies. + let c_dot = std::ffi::CString::new(".").unwrap(); + let mut current = unsafe { libc::openat(self.root_fd, c_dot.as_ptr(), flags) }; + if current < 0 { + return Err(from_io_error(&std::io::Error::last_os_error())); + } + for part in parts { + let c_part = std::ffi::CString::new(part.as_str()).unwrap(); + if create { + unsafe { + if libc::mkdirat(current, c_part.as_ptr(), 0o700) == 0 { + if unsafe { libc::geteuid() } == 0 { + libc::fchownat( + current, + c_part.as_ptr(), + self.owner_uid, + self.owner_gid, + libc::AT_SYMLINK_NOFOLLOW, + ); + } + } + } + } + let next = unsafe { libc::openat(current, c_part.as_ptr(), flags) }; + if next < 0 { + unsafe { + libc::close(current); + } + return Err(from_io_error(&std::io::Error::last_os_error())); + } + unsafe { + libc::close(current); + } + current = next; + } + Ok(current) + } + + fn open_file( + &self, + parts: &[String], + flags: i32, + mode: u32, + create_parents: bool, + ) -> Result { + if parts.len() < 2 { + return Err(ControlError::invalid_request( + "file path must include a parent directory", + )); + } + let parent = self.open_directory(&parts[..parts.len() - 1], create_parents)?; + let c_name = std::ffi::CString::new(parts[parts.len() - 1].as_str()).unwrap(); + let fd = unsafe { + libc::openat( + parent, + c_name.as_ptr(), + flags | libc::O_NOFOLLOW, + mode as libc::c_uint, + ) + }; + unsafe { + libc::close(parent); + } + if fd < 0 { + return Err(from_io_error(&std::io::Error::last_os_error())); + } + Ok(fd) + } + + pub fn list(&self, path: &Value) -> Result { + let parts = validate_workspace_directory(path)?; + let dir = self.open_directory(&parts, false)?; + let c_dot = std::ffi::CString::new(".").unwrap(); + let result = (|| -> Result { + let mut entries = vec![]; + unsafe { + let dirp = libc::fdopendir(libc::openat( + dir, + c_dot.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY, + )); + if dirp.is_null() { + return Err(ControlError::internal("fdopendir failed")); + } + loop { + let entry_ptr = libc::readdir(dirp); + if entry_ptr.is_null() { + break; + } + let name = std::ffi::CStr::from_ptr((*entry_ptr).d_name.as_ptr()) + .to_string_lossy() + .to_string(); + if name == "." || name == ".." { + continue; + } + entries.push(name); + } + libc::closedir(dirp); + } + entries.sort(); + if entries.len() > MAX_LIST_ENTRIES { + return Err(ControlError::limit_exceeded( + "directory contains too many entries", + )); + } + let mut result_entries = Vec::with_capacity(entries.len()); + for name in &entries { + let c_name = std::ffi::CString::new(name.as_str()).unwrap(); + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + let rc = unsafe { + libc::fstatat(dir, c_name.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) + }; + if rc != 0 { + continue; + } + let kind = if (st.st_mode & libc::S_IFMT as libc::mode_t) + == libc::S_IFLNK as libc::mode_t + { + "symlink" + } else if (st.st_mode & libc::S_IFMT as libc::mode_t) + == libc::S_IFDIR as libc::mode_t + { + "directory" + } else { + "file" + }; + let size = if (st.st_mode & libc::S_IFMT as libc::mode_t) + == libc::S_IFREG as libc::mode_t + { + st.st_size as u64 + } else { + 0 + }; + let mut path_str = parts.join("/"); + if !path_str.is_empty() { + path_str.push('/'); + } + path_str.push_str(name); + result_entries.push(json!({ + "path": path_str, + "type": kind, + "size": size, + "modifiedAt": (st.st_mtime as u64) * 1000, + })); + } + Ok(json!({ "files": result_entries })) + })(); + unsafe { + libc::close(dir); + } + result + } + + pub fn read( + &self, + path: &Value, + offset: &Value, + max_bytes: &Value, + ) -> Result { + let parts = validate_relative_path(path)?; + let start = + crate::validate::validate_integer(offset, 0, MAX_FILE_BYTES as i64, "file offset")? + as i64; + let limit = crate::validate::validate_integer( + max_bytes, + 1, + MAX_FILE_CHUNK_BYTES as i64, + "file read size", + )? as usize; + let fd = self.open_file(&parts, libc::O_RDONLY, 0, false)?; + let result = (|| -> Result { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(fd, &mut st) } != 0 { + return Err(ControlError::internal("fstat failed")); + } + if (st.st_mode & libc::S_IFMT as libc::mode_t) != libc::S_IFREG as libc::mode_t { + return Err(ControlError::invalid_request( + "download path is not a regular file", + )); + } + if st.st_size > MAX_FILE_BYTES as i64 { + return Err(ControlError::limit_exceeded( + "file exceeds the download limit", + )); + } + if start > st.st_size { + return Err(ControlError::invalid_request( + "file offset exceeds the file size", + )); + } + let mut buf = vec![0u8; limit]; + let n = unsafe { libc::pread(fd, buf.as_mut_ptr() as *mut _, limit, start as i64) }; + if n < 0 { + return Err(ControlError::internal("pread failed")); + } + buf.truncate(n as usize); + let b64 = base64::engine::general_purpose::STANDARD.encode(&buf); + let next = start + n as i64; + Ok(json!({ + "path": parts.join("/"), + "offset": start, + "nextOffset": next, + "size": st.st_size, + "eof": next >= st.st_size, + "data": b64, + })) + })(); + unsafe { + libc::close(fd); + } + result + } + + pub fn write( + &self, + path: &Value, + offset: &Value, + encoded: &Value, + truncate: &Value, + ) -> Result { + let parts = validate_relative_path(path)?; + let start = + crate::validate::validate_integer(offset, 0, MAX_FILE_BYTES as i64, "file offset")? + as i64; + let encoded_str = encoded + .as_str() + .ok_or_else(|| ControlError::invalid_request("file payload must be base64"))?; + let do_truncate = truncate + .as_bool() + .ok_or_else(|| ControlError::invalid_request("truncate must be boolean"))?; + if do_truncate && start != 0 { + return Err(ControlError::invalid_request( + "a truncating write must begin at offset zero", + )); + } + let chunk = base64::engine::general_purpose::STANDARD + .decode(encoded_str) + .map_err(|_| ControlError::invalid_request("file payload is not canonical base64"))?; + // Verify canonical encoding + let re_encoded = base64::engine::general_purpose::STANDARD.encode(&chunk); + if re_encoded != encoded_str { + return Err(ControlError::invalid_request( + "file payload is not canonical base64", + )); + } + if chunk.len() > MAX_FILE_CHUNK_BYTES { + return Err(ControlError::limit_exceeded( + "file write chunk is too large", + )); + } + if start + chunk.len() as i64 > MAX_FILE_BYTES as i64 { + return Err(ControlError::limit_exceeded( + "file exceeds the upload limit", + )); + } + let _guard = self.write_lock.lock().unwrap(); + let flags = libc::O_WRONLY | libc::O_CREAT; + let fd = self.open_file(&parts, flags, 0o600, true)?; + let result = (|| -> Result { + if unsafe { libc::geteuid() } == 0 { + unsafe { + libc::fchown(fd, self.owner_uid, self.owner_gid); + } + } + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(fd, &mut st) } != 0 { + return Err(ControlError::internal("fstat failed")); + } + if (st.st_mode & libc::S_IFMT as libc::mode_t) != libc::S_IFREG as libc::mode_t { + return Err(ControlError::invalid_request( + "upload target is not a regular file", + )); + } + let prior = st.st_size; + let used = self.directory_bytes()?; + let resulting_size = if do_truncate { + (start + chunk.len() as i64).max(0) + } else { + (start + chunk.len() as i64).max(prior) + }; + if used - prior + resulting_size > self.quota_bytes as i64 { + return Err(ControlError::limit_exceeded( + "workspace storage quota exceeded", + )); + } + if do_truncate { + unsafe { + libc::ftruncate(fd, 0); + } + } + let mut written: usize = 0; + while written < chunk.len() { + let n = unsafe { + libc::pwrite( + fd, + chunk[written as usize..].as_ptr() as *const _, + (chunk.len() - written), + (start + written as i64) as i64, + ) + }; + if n <= 0 { + return Err(ControlError::internal("file write made no progress")); + } + written += n as usize; + } + unsafe { + libc::fsync(fd); + } + let mut st2: libc::stat = unsafe { std::mem::zeroed() }; + unsafe { + libc::fstat(fd, &mut st2); + } + Ok(json!({ + "path": parts.join("/"), + "size": st2.st_size, + "nextOffset": start + chunk.len() as i64, + "usedBytes": used - prior + resulting_size, + "quotaBytes": self.quota_bytes, + })) + })(); + unsafe { + libc::close(fd); + } + result + } + + pub fn delete(&self, path: &Value, recursive: &Value) -> Result { + let parts = validate_relative_path(path)?; + // recursive must be false (protocol only allows non-recursive single-file delete) + let do_recursive = recursive.as_bool().unwrap_or(false); + if do_recursive { + return Err(ControlError::permission_denied( + "recursive deletion is not supported", + )); + } + let _guard = self.write_lock.lock().unwrap(); + let parent = self.open_directory(&parts[..parts.len() - 1], false)?; + let result = (|| -> Result { + let c_name = std::ffi::CString::new(parts[parts.len() - 1].as_str()).unwrap(); + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstatat(parent, c_name.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) } + != 0 + { + return Err(from_io_error(&std::io::Error::last_os_error())); + } + if (st.st_mode & libc::S_IFMT as libc::mode_t) != libc::S_IFREG as libc::mode_t { + return Err(ControlError::invalid_request( + "delete path is not a regular file", + )); + } + if unsafe { libc::unlinkat(parent, c_name.as_ptr(), 0) } != 0 { + return Err(from_io_error(&std::io::Error::last_os_error())); + } + let used_after = self.directory_bytes()?; + Ok(json!({ + "path": parts.join("/"), + "deleted": true, + "usedBytes": used_after, + "quotaBytes": self.quota_bytes, + })) + })(); + unsafe { + libc::close(parent); + } + result + } + + pub fn open_job_cwd(&self, path: &Value) -> Result { + let parts = validate_workspace_directory(path)?; + self.open_directory(&parts, false) + } + + pub fn read_small(&self, path: &str, maximum: usize) -> Option { + let path_val = serde_json::Value::String(path.to_string()); + let parts = validate_relative_path(&path_val).ok()?; + let fd = self.open_file(&parts, libc::O_RDONLY, 0, false).ok()?; + let result = (|| -> Option { + let mut st: libc::stat = unsafe { std::mem::zeroed() }; + if unsafe { libc::fstat(fd, &mut st) } != 0 { + return None; + } + if (st.st_mode & libc::S_IFMT as libc::mode_t) != libc::S_IFREG as libc::mode_t { + return None; + } + if st.st_size > maximum as i64 { + return None; + } + let mut buf = vec![0u8; maximum]; + let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, maximum) }; + if n < 0 { + return None; + } + buf.truncate(n as usize); + String::from_utf8(buf).ok().map(|s| s.trim().to_string()) + })(); + unsafe { + libc::close(fd); + } + result + } + + pub fn watch_identity(&self) -> Option { + let keystore_fd = self + .open_directory(&[".bloom".to_string(), "keystore".to_string()], false) + .ok()?; + let identity = (|| -> Option { + // Check keystore has exactly "workspace-login" + let mut entries = list_dir_entries(keystore_fd)?; + entries.sort(); + if entries != vec!["workspace-login".to_string()] { + return None; + } + unsafe { + libc::close(keystore_fd); + } + + // Check wallet has exactly address, kind, pubkey + let wallet_fd = self + .open_directory( + &[ + ".bloom".to_string(), + "keystore".to_string(), + "workspace-login".to_string(), + ], + false, + ) + .ok()?; + let mut wallet_entries = list_dir_entries(wallet_fd)?; + wallet_entries.sort(); + if wallet_entries + != vec![ + "address".to_string(), + "kind".to_string(), + "pubkey".to_string(), + ] + { + return None; + } + unsafe { + libc::close(wallet_fd); + } + + let kind = self.read_small(".bloom/keystore/workspace-login/kind", 256)?; + let address = self.read_small(".bloom/keystore/workspace-login/address", 256)?; + let public_key = self.read_small(".bloom/keystore/workspace-login/pubkey", 256)?; + let normalized = address.to_lowercase(); + if kind != "watch" || public_key != "" || !EVM_ADDRESS_RE.is_match(&normalized) { + return None; + } + Some(normalized) + })(); + // keystore_fd may already be closed inside the closure + identity + } + + fn directory_bytes(&self) -> Result { + let mut count = 0i64; + let mut total = 0i64; + walk_dir_bytes(self.root_fd, &mut count, &mut total, 0)?; + Ok(total) + } +} + +fn list_dir_entries(fd: RawFd) -> Option> { + let mut entries = vec![]; + let c_dot = std::ffi::CString::new(".").unwrap(); + unsafe { + let dirp = libc::fdopendir(libc::openat( + fd, + c_dot.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY, + )); + if dirp.is_null() { + return None; + } + loop { + let entry_ptr = libc::readdir(dirp); + if entry_ptr.is_null() { + break; + } + let name = std::ffi::CStr::from_ptr((*entry_ptr).d_name.as_ptr()) + .to_string_lossy() + .to_string(); + if name == "." || name == ".." { + continue; + } + entries.push(name); + } + libc::closedir(dirp); + } + Some(entries) +} + +fn walk_dir_bytes( + fd: RawFd, + count: &mut i64, + total: &mut i64, + depth: usize, +) -> Result<(), ControlError> { + if depth > 32 { + return Ok(()); + } + let c_dot = std::ffi::CString::new(".").unwrap(); + unsafe { + let dirp = libc::fdopendir(libc::openat( + fd, + c_dot.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY, + )); + if dirp.is_null() { + return Err(ControlError::internal("fdopendir failed")); + } + loop { + let entry_ptr = libc::readdir(dirp); + if entry_ptr.is_null() { + break; + } + let name = std::ffi::CStr::from_ptr((*entry_ptr).d_name.as_ptr()) + .to_string_lossy() + .to_string(); + if name == "." || name == ".." { + continue; + } + let c_name = std::ffi::CString::new(name.as_str()).unwrap(); + let mut st: libc::stat = std::mem::zeroed(); + if libc::fstatat(fd, c_name.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) != 0 { + continue; + } + let mode = st.st_mode & libc::S_IFMT as libc::mode_t; + if mode == libc::S_IFREG as libc::mode_t { + *count += 1; + if *count > MAX_SCAN_ENTRIES as i64 { + libc::closedir(dirp); + return Err(ControlError::limit_exceeded( + "workspace contains too many files", + )); + } + *total += st.st_size; + } else if mode == libc::S_IFDIR as libc::mode_t { + let child = libc::openat( + fd, + c_name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ); + if child >= 0 { + let _ = walk_dir_bytes(child, count, total, depth + 1); + libc::close(child); + } + } + } + libc::closedir(dirp); + } + Ok(()) +} diff --git a/ops/guest-control/src/jobs.rs b/ops/guest-control/src/jobs.rs new file mode 100644 index 0000000..dc73b01 --- /dev/null +++ b/ops/guest-control/src/jobs.rs @@ -0,0 +1,610 @@ +//! Job engine: spawn isolated processes with prlimit + setpriv. + +use crate::constants::*; +use crate::error::ControlError; +use crate::files::WorkspaceFiles; +use crate::logs::LogRing; +use crate::validate::{ + validate_environment, validate_integer, validate_job_id, validate_workspace_directory, +}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::io::Read; +use std::os::unix::io::AsRawFd; +use std::process::Stdio; + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +const TERMINAL_STATES: &[&str] = &["succeeded", "failed", "cancelled", "timed_out"]; + +fn is_terminal(state: &str) -> bool { + TERMINAL_STATES.contains(&state) +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +struct Job { + job_id: String, + argv: Vec, + cwd: String, + timeout_ms: u64, + created_at: u64, + state: String, + started_at: Option, + finished_at: Option, + exit_code: Option, + signal_number: Option, + pid: Option, + cancel_requested: bool, + logs: Arc, + finished: Arc, +} + +impl Job { + fn new(job_id: String, argv: Vec, cwd: String, timeout_ms: u64) -> Self { + Self { + job_id, + argv, + cwd, + timeout_ms, + created_at: now_ms(), + state: "queued".into(), + started_at: None, + finished_at: None, + exit_code: None, + signal_number: None, + pid: None, + cancel_requested: false, + logs: Arc::new(LogRing::new()), + finished: Arc::new(AtomicBool::new(false)), + } + } +} + +pub struct JobEngine { + files: Arc, + job_uid: u32, + job_gid: u32, + jobs: Arc>>>>, + order: Arc>>, +} + +impl JobEngine { + pub fn new(files: Arc, job_uid: u32, job_gid: u32) -> Self { + Self { + files, + job_uid, + job_gid, + jobs: Arc::new(Mutex::new(HashMap::new())), + order: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn start(&self, request: &Value) -> Result { + let job_id = validate_job_id(&request["jobId"])?; + let argv_val = &request["argv"]; + let argv_arr = argv_val + .as_array() + .ok_or_else(|| ControlError::invalid_request("argv must be an array"))?; + if argv_arr.is_empty() || argv_arr.len() > 64 { + return Err(ControlError::invalid_request( + "argv must contain between 1 and 64 arguments", + )); + } + let mut validated_argv = Vec::with_capacity(argv_arr.len()); + let mut total_argv = 0usize; + for arg in argv_arr { + let s = arg.as_str().ok_or_else(|| { + ControlError::invalid_request("argv contains an invalid argument") + })?; + if s.is_empty() || s.contains('\0') { + return Err(ControlError::invalid_request( + "argv contains an invalid argument", + )); + } + let size = s.len(); + if size > 4096 { + return Err(ControlError::limit_exceeded( + "argv contains an oversized argument", + )); + } + total_argv += size; + if total_argv > 32 * 1024 { + return Err(ControlError::limit_exceeded( + "argv exceeds the aggregate size limit", + )); + } + validated_argv.push(s.to_string()); + } + let cwd_parts = validate_workspace_directory(&request["cwd"])?; + let cwd = if cwd_parts.is_empty() { + ".".to_string() + } else { + cwd_parts.join("/") + }; + let timeout_ms = validate_integer( + &request["timeoutMs"], + 1000, + MAX_JOB_TIMEOUT_MS as i64, + "job timeout", + )? as u64; + let user_env = validate_environment(&request["environment"])?; + + // Check limits before inserting + { + let jobs = self.jobs.lock().unwrap(); + if jobs.contains_key(&job_id) { + return Err(ControlError::conflict("job id already exists")); + } + let active = jobs + .values() + .filter(|j| { + let job = j.lock().unwrap(); + !is_terminal(&job.state) + }) + .count(); + if active >= MAX_ACTIVE_JOBS { + return Err(ControlError::limit_exceeded( + "workspace already has the maximum number of active jobs", + )); + } + } + + // Prune terminal jobs + self.prune_terminal_jobs(); + + { + let jobs = self.jobs.lock().unwrap(); + if jobs.len() >= MAX_RETAINED_JOBS { + return Err(ControlError::limit_exceeded( + "workspace has too many retained jobs", + )); + } + } + + let cwd_fd = self.files.open_job_cwd(&Value::String(cwd.clone()))?; + let job = Arc::new(Mutex::new(Job::new( + job_id.clone(), + validated_argv.clone(), + cwd.clone(), + timeout_ms, + ))); + let launcher = self.launcher_argv(&validated_argv); + + // Build environment + let env = self.job_environment(&user_env); + + // Spawn process + let cwd_path = format!("/proc/self/fd/{}", cwd_fd); + + let mut command = std::process::Command::new(&launcher[0]); + command.args(&launcher[1..]); + command.current_dir(&cwd_path); + command.env_clear(); + for (k, v) in &env { + command.env(k, v); + } + command.stdin(std::process::Stdio::null()); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + // Keep cwd_fd alive across exec + use std::os::unix::process::CommandExt; + let cwd_fd_for_pre_exec = cwd_fd; + unsafe { + command.pre_exec(move || { + // Clear CLOEXEC so cwd_fd survives exec + let flags = libc::fcntl(cwd_fd_for_pre_exec, libc::F_GETFD); + if flags >= 0 { + libc::fcntl( + cwd_fd_for_pre_exec, + libc::F_SETFD, + flags & !libc::FD_CLOEXEC, + ); + } + // Create new session for process-group isolation + libc::setsid(); + Ok(()) + }); + } + + let child = match command.spawn() { + Ok(c) => c, + Err(e) => { + unsafe { + libc::close(cwd_fd); + } + { + let mut job_guard = job.lock().unwrap(); + job_guard.state = "failed".into(); + job_guard.finished_at = Some(now_ms()); + let msg = format!("bloom job launch failed: {e}\n"); + job_guard.logs.append(msg.as_bytes()); + job_guard.finished.store(true, Ordering::SeqCst); + } + let status = self.status_of(&job, 0, MAX_LOG_CHUNK_BYTES); + // Insert the failed job so status() can find it + self.jobs + .lock() + .unwrap() + .insert(job_id.clone(), job.clone()); + self.order.lock().unwrap().push(job_id.clone()); + return Ok(status); + } + }; + + let pid = child.id(); + let mut stdout = child.stdout; + let mut stderr = child.stderr; + + { + let mut job_guard = job.lock().unwrap(); + job_guard.started_at = Some(now_ms()); + job_guard.state = "running".into(); + job_guard.pid = Some(pid); + } + + // Insert into table + self.jobs + .lock() + .unwrap() + .insert(job_id.clone(), job.clone()); + self.order.lock().unwrap().push(job_id.clone()); + + // Spawn concurrent log-streaming + wait thread. + // The stdout reader runs concurrently with the waitpid poller so that + // logs appear in real-time (job.status callers can see output while the + // job is still running). + let logs_for_capture = job.lock().unwrap().logs.clone(); + let job_for_wait = job.clone(); + let timeout = timeout_ms; + std::thread::spawn(move || { + // Take ownership of stdout so we can read from it + let mut stdout = stdout; + + // Set both pipes to non-blocking + if let Some(ref s) = stdout { + let fd = s.as_raw_fd(); + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + } + if let Some(ref s) = stderr { + let fd = s.as_raw_fd(); + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + } + + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout); + let mut timed_out = false; + let mut status: libc::c_int = 0; + let mut reaped = false; + + // Helper to drain a non-blocking pipe into the log buffer + fn drain_nonblocking(pipe: &mut Option, logs: &LogRing) { + if let Some(ref mut out) = pipe { + let mut buf = [0u8; 16 * 1024]; + loop { + match out.read(&mut buf) { + Ok(0) => break, + Ok(n) => logs.append(&buf[..n]), + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break, + Err(_) => break, + } + } + } + } + fn drain_blocking(pipe: &mut Option, logs: &LogRing) { + if let Some(ref mut out) = pipe { + let mut buf = [0u8; 16 * 1024]; + loop { + match out.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => logs.append(&buf[..n]), + } + } + } + } + + loop { + // Drain any available stdout and stderr (non-blocking) + drain_nonblocking(&mut stdout, &logs_for_capture); + drain_nonblocking(&mut stderr, &logs_for_capture); + + // Check if child exited + if !reaped { + let rc = unsafe { libc::waitpid(pid as i32, &mut status, libc::WNOHANG) }; + if rc == pid as i32 { + reaped = true; + } else if rc < 0 { + reaped = true; + status = 0; + } + } + + if reaped { + // Final drain of stdout and stderr + drain_blocking(&mut stdout, &logs_for_capture); + drain_blocking(&mut stderr, &logs_for_capture); + break; + } + + // Check timeout + if std::time::Instant::now() >= deadline { + timed_out = true; + signal_group(pid, libc::SIGTERM); + // Wait briefly for graceful exit + for _ in 0..100 { + let rc = unsafe { libc::waitpid(pid as i32, &mut status, libc::WNOHANG) }; + if rc == pid as i32 || rc < 0 { + reaped = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + if !reaped { + // Force kill + signal_group(pid, libc::SIGKILL); + unsafe { + libc::waitpid(pid as i32, &mut status, 0); + } + reaped = true; + } + // Final drain after timeout kill + drain_blocking(&mut stdout, &logs_for_capture); + drain_blocking(&mut stderr, &logs_for_capture); + break; + } + + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + // Determine terminal state + let cancel_requested = job_for_wait.lock().unwrap().cancel_requested; + let (state, exit_code, signal) = if timed_out && !cancel_requested { + ("timed_out".into(), None, None) + } else if cancel_requested { + ("cancelled".into(), None, None) + } else if libc::WIFEXITED(status) { + let code = libc::WEXITSTATUS(status); + if code == 0 { + ("succeeded".into(), Some(code), None) + } else { + ("failed".into(), Some(code), None) + } + } else if libc::WIFSIGNALED(status) { + ("failed".into(), None, Some(libc::WTERMSIG(status))) + } else { + ("failed".into(), None, None) + }; + + let mut job_guard = job_for_wait.lock().unwrap(); + job_guard.finished_at = Some(now_ms()); + job_guard.state = state; + if let Some(sig) = signal { + job_guard.signal_number = Some(sig); + } else { + job_guard.exit_code = exit_code; + } + job_guard.finished.store(true, Ordering::SeqCst); + }); + + let status = self.status_of(&job, 0, MAX_LOG_CHUNK_BYTES); + unsafe { + libc::close(cwd_fd); + } + Ok(status) + } + + pub fn status(&self, request: &Value) -> Result { + let job_id = validate_job_id(&request["jobId"])?; + let log_offset = + validate_integer(&request["logOffset"], 0, 2i64.pow(53) - 1, "log cursor")? as u64; + let max_bytes = validate_integer( + &request["maxBytes"], + 1, + MAX_LOG_CHUNK_BYTES as i64, + "log read size", + )? as usize; + let job = { + let jobs = self.jobs.lock().unwrap(); + jobs.get(&job_id) + .cloned() + .ok_or_else(|| ControlError::not_found("job does not exist"))? + }; + Ok(self.status_of(&job, log_offset, max_bytes)) + } + + pub fn cancel(&self, request: &Value) -> Result { + let job_id = validate_job_id(&request["jobId"])?; + let job = { + let jobs = self.jobs.lock().unwrap(); + jobs.get(&job_id) + .cloned() + .ok_or_else(|| ControlError::not_found("job does not exist"))? + }; + let mut job_guard = job.lock().unwrap(); + if is_terminal(&job_guard.state) { + return Ok(self.status_of_inner(&job_guard, job_guard.logs.end_offset(), 1)); + } + let pid = match job_guard.pid { + Some(p) => p, + None => return Err(ControlError::conflict("job has not started")), + }; + let first_request = !job_guard.cancel_requested; + job_guard.cancel_requested = true; + // SIGTERM the process group + signal_group(pid, libc::SIGTERM); + if first_request { + let job_for_cancel = job.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs_f64(JOB_KILL_GRACE_SECONDS)); + let guard = job_for_cancel.lock().unwrap(); + if let Some(pid) = guard.pid { + signal_group(pid, libc::SIGKILL); + } + }); + } + Ok(self.status_of_inner(&job_guard, job_guard.logs.end_offset(), 1)) + } + + pub fn close(&self) { + let jobs = self.jobs.lock().unwrap(); + for job_arc in jobs.values() { + let job_guard = job_arc.lock().unwrap(); + if !is_terminal(&job_guard.state) { + if let Some(pid) = job_guard.pid { + signal_group(pid, libc::SIGKILL); + } + } + } + } + + fn status_of(&self, job: &Arc>, log_offset: u64, max_bytes: usize) -> Value { + let job_guard = job.lock().unwrap(); + self.status_of_inner(&job_guard, log_offset, max_bytes) + } + + fn status_of_inner(&self, job: &Job, log_offset: u64, max_bytes: usize) -> Value { + let logs_val = job + .logs + .slice(log_offset, max_bytes, is_terminal(&job.state)) + .unwrap_or_else(|e| json!({"error": e.message})); + json!({ + "jobId": job.job_id, + "state": job.state, + "createdAt": job.created_at, + "startedAt": job.started_at, + "finishedAt": job.finished_at, + "exitCode": job.exit_code, + "signal": job.signal_number, + "timeoutMs": job.timeout_ms, + "logs": logs_val, + }) + } + + fn prune_terminal_jobs(&self) { + let mut jobs = self.jobs.lock().unwrap(); + let mut order = self.order.lock().unwrap(); + while jobs.len() >= MAX_RETAINED_JOBS { + let terminal_id = order + .iter() + .find(|id| { + if let Some(j) = jobs.get(*id) { + let g = j.lock().unwrap(); + is_terminal(&g.state) + } else { + false + } + }) + .cloned(); + match terminal_id { + Some(id) => { + jobs.remove(&id); + order.retain(|x| *x != id); + } + None => break, + } + } + } + + fn launcher_argv(&self, user_argv: &[String]) -> Vec { + let prlimit = which("prlimit").unwrap_or_else(|| "/usr/bin/prlimit".into()); + let setpriv = which("setpriv").unwrap_or_else(|| "/usr/bin/setpriv".into()); + let mut cmd = vec![ + prlimit, + "--nofile=64:64".into(), + format!("--fsize={}:{}", MAX_JOB_FILE_BYTES, MAX_JOB_FILE_BYTES), + "--core=0:0".into(), + ]; + if unsafe { libc::geteuid() } == 0 { + cmd.push(format!( + "--nproc={}:{}", + MAX_JOB_PROCESSES, MAX_JOB_PROCESSES + )); + } + cmd.push("--".into()); + cmd.push(setpriv); + cmd.push("--no-new-privs".into()); + cmd.push("--pdeathsig=SIGKILL".into()); + if unsafe { libc::geteuid() } == 0 { + cmd.extend([ + "--bounding-set=-all".into(), + "--inh-caps=-all".into(), + "--ambient-caps=-all".into(), + format!("--reuid={}", self.job_uid), + format!("--regid={}", self.job_gid), + "--clear-groups".into(), + ]); + } + cmd.push("--".into()); + cmd.extend(user_argv.iter().cloned()); + cmd + } + + fn job_environment(&self, user_env: &BTreeMap) -> BTreeMap { + let mut env = BTreeMap::new(); + env.insert("HOME".into(), "/workspace".into()); + env.insert("USER".into(), "workspace".into()); + env.insert("LOGNAME".into(), "workspace".into()); + env.insert("SHELL".into(), "/bin/bash".into()); + // Inherit PATH from parent so test/toolchain binaries are discoverable. + // In production, the guest image sets PATH appropriately. + let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()); + env.insert("PATH".into(), path); + env.insert("TMPDIR".into(), "/workspace/.tmp".into()); + env.insert("LANG".into(), "C.UTF-8".into()); + + for name in SYSTEM_PROXY_ENV { + if let Ok(value) = std::env::var(name) { + if !value.is_empty() + && value.len() <= 2048 + && !value.contains('@') + && !value.contains('\0') + { + env.insert(name.to_string(), value); + } + } + } + for name in &["SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"] { + if let Ok(value) = std::env::var(name) { + if value.starts_with('/') && !value.contains('\0') && value.len() <= 1024 { + env.insert(name.to_string(), value); + } + } + } + for (k, v) in user_env { + env.insert(k.clone(), v.clone()); + } + env + } +} + +fn which(cmd: &str) -> Option { + std::process::Command::new("which") + .arg(cmd) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) +} + +fn signal_group(pid: u32, sig: libc::c_int) { + // killpg expects a negative pid for process group + unsafe { + // The pid is already a session leader (we called setsid in pre_exec) + // So killpg(pid) = kill(-pid, sig) + libc::kill(-(pid as i32), sig); + } +} diff --git a/ops/guest-control/src/logs.rs b/ops/guest-control/src/logs.rs new file mode 100644 index 0000000..7f40397 --- /dev/null +++ b/ops/guest-control/src/logs.rs @@ -0,0 +1,76 @@ +//! Bounded log ring buffer with absolute offset tracking. + +use crate::constants::*; +use crate::error::ControlError; +use base64::Engine; +use serde_json::{json, Value}; +use std::sync::Mutex; + +pub struct LogRing { + data: Mutex, +} + +struct LogInner { + buf: Vec, + start_offset: u64, + end_offset: u64, +} + +impl LogRing { + pub fn new() -> Self { + Self { + data: Mutex::new(LogInner { + buf: Vec::new(), + start_offset: 0, + end_offset: 0, + }), + } + } + + pub fn append(&self, chunk: &[u8]) { + if chunk.is_empty() { + return; + } + let mut inner = self.data.lock().unwrap(); + inner.buf.extend_from_slice(chunk); + inner.end_offset += chunk.len() as u64; + let overflow = inner.buf.len() as i64 - MAX_LOG_BYTES as i64; + if overflow > 0 { + inner.buf.drain(..overflow as usize); + inner.start_offset += overflow as u64; + } + } + + pub fn slice( + &self, + requested_offset: u64, + maximum: usize, + terminal: bool, + ) -> Result { + let inner = self.data.lock().unwrap(); + if requested_offset > inner.end_offset { + return Err(ControlError::invalid_request( + "log cursor is beyond the current log end", + )); + } + let offset = requested_offset.max(inner.start_offset); + let relative = (offset - inner.start_offset) as usize; + let end = (relative + maximum).min(inner.buf.len()); + let chunk = &inner.buf[relative..end]; + let next_offset = offset + chunk.len() as u64; + let b64 = base64::engine::general_purpose::STANDARD.encode(chunk); + Ok(json!({ + "offset": offset, + "nextOffset": next_offset, + "endOffset": inner.end_offset, + "truncatedBefore": requested_offset < inner.start_offset, + "eof": terminal && next_offset == inner.end_offset, + "encoding": "base64", + "data": b64, + })) + } + + pub fn end_offset(&self) -> u64 { + self.data.lock().unwrap().end_offset + } +} diff --git a/ops/guest-control/src/main.rs b/ops/guest-control/src/main.rs new file mode 100644 index 0000000..eeee3db --- /dev/null +++ b/ops/guest-control/src/main.rs @@ -0,0 +1,153 @@ +//! Bounded guest-side file, job, and Bloom status service. +//! +//! Accepts the version-1 JSON-line protocol over AF_VSOCK, a guest-local Unix +//! socket, and/or stdio. Every job is exec'd via prlimit + setpriv as the +//! unprivileged workspace account with no capabilities and no-new-privileges. +//! +//! This binary is intentionally dependency-light. It speaks the same wire +//! protocol as the original Python implementation and is a drop-in replacement. + +mod client; +mod constants; +mod control; +mod error; +mod files; +mod jobs; +mod logs; +mod protocol; +mod server; +mod validate; + +use std::path::PathBuf; +use std::process::ExitCode; +use std::sync::atomic::AtomicBool; + +/// Global shutdown flag set by SIGTERM/SIGINT signal handlers. +pub static SHOULD_EXIT: AtomicBool = AtomicBool::new(false); + +fn main() -> ExitCode { + // If the first argument is a known client subcommand, dispatch to the CLI client. + let argv: Vec = std::env::args().skip(1).collect(); + match argv.first().map(|s| s.as_str()) { + Some("status") | Some("hello") | Some("files") | Some("jobs") => { + return ExitCode::from(client::run(&argv) as u8); + } + _ => {} + } + + // SIGTERM/SIGINT → graceful shutdown via atomic flag + unsafe { + extern "C" fn handle_signal(_sig: libc::c_int) { + SHOULD_EXIT.store(true, std::sync::atomic::Ordering::SeqCst); + } + let mut sa: libc::sigaction = std::mem::zeroed(); + sa.sa_sigaction = handle_signal as *const () as usize; + libc::sigaction(libc::SIGTERM, &sa, std::ptr::null_mut()); + libc::sigaction(libc::SIGINT, &sa, std::ptr::null_mut()); + } + + let args = match parse_args() { + Ok(a) => a, + Err(e) => { + eprintln!("{e}"); + return ExitCode::from(2); + } + }; + + server::run(args) +} + +pub struct Args { + pub workspace: PathBuf, + pub workspace_quota_bytes: u64, + pub job_uid: u32, + pub job_gid: u32, + pub stdio: bool, + pub unix_socket: Option, + pub vsock_port: Option, +} + +fn parse_args() -> Result { + let mut workspace = PathBuf::from("/workspace"); + let mut workspace_quota_bytes: u64 = 512 * 1024 * 1024; + let mut job_uid: u32 = 1000; + let mut job_gid: u32 = 1000; + let mut stdio = false; + let mut unix_socket: Option = None; + let mut vsock_port: Option = None; + + let mut args = std::env::args_os().skip(1); + while let Some(arg) = args.next() { + let arg = arg.to_string_lossy().to_string(); + match arg.as_str() { + "--stdio" => stdio = true, + "--workspace" => { + workspace = PathBuf::from(args.next().ok_or("--workspace requires a value")?); + } + "--workspace-quota-bytes" => { + let v = args + .next() + .ok_or("--workspace-quota-bytes requires a value")?; + workspace_quota_bytes = v + .to_string_lossy() + .parse() + .map_err(|_| "workspace-quota-bytes must be a number")?; + } + "--job-uid" => { + let v = args.next().ok_or("--job-uid requires a value")?; + job_uid = v + .to_string_lossy() + .parse() + .map_err(|_| "job-uid must be a number")?; + } + "--job-gid" => { + let v = args.next().ok_or("--job-gid requires a value")?; + job_gid = v + .to_string_lossy() + .parse() + .map_err(|_| "job-gid must be a number")?; + } + "--unix-socket" => { + unix_socket = Some(PathBuf::from( + args.next().ok_or("--unix-socket requires a value")?, + )); + } + "--vsock-port" => { + let v = args.next().ok_or("--vsock-port requires a value")?; + vsock_port = Some( + v.to_string_lossy() + .parse() + .map_err(|_| "vsock-port must be a number")?, + ); + } + _ => return Err(format!("unknown argument: {arg}")), + } + } + + if !stdio && unix_socket.is_none() && vsock_port.is_none() { + return Err( + "at least one transport is required (--stdio, --unix-socket, or --vsock-port)".into(), + ); + } + if !(1024 * 1024..=16 * 1024 * 1024 * 1024).contains(&workspace_quota_bytes) { + return Err("workspace quota is outside the supported range".into()); + } + if let Some(port) = vsock_port { + if port == 0 || port == u32::MAX { + return Err("vsock port is outside the supported range".into()); + } + } + + Ok(Args { + workspace, + workspace_quota_bytes, + job_uid, + job_gid, + stdio, + unix_socket, + vsock_port, + }) +} + +// Re-export for binary-level checks +pub use constants::*; diff --git a/ops/guest-control/src/protocol.rs b/ops/guest-control/src/protocol.rs new file mode 100644 index 0000000..3d45ef0 --- /dev/null +++ b/ops/guest-control/src/protocol.rs @@ -0,0 +1,50 @@ +//! JSON-line protocol frame encoding and decoding. + +use crate::constants::*; +use crate::error::ControlError; +use serde_json::Value; + +pub fn decode_frame(frame: &[u8]) -> Result { + if frame.len() > MAX_FRAME_BYTES { + return Err(ControlError::limit_exceeded( + "guest protocol frame is too large", + )); + } + if !frame.ends_with(b"\n") { + return Err(ControlError::invalid_request( + "guest protocol frame must end with a newline", + )); + } + let trimmed = &frame[..frame.len().saturating_sub(1)]; + serde_json::from_slice(trimmed) + .map_err(|_| ControlError::invalid_request("guest protocol frame is not valid UTF-8 JSON")) +} + +pub fn encode_response(response: &Value) -> Vec { + let mut frame = serde_json::to_vec(response).unwrap_or_default(); + frame.push(b'\n'); + if frame.len() > MAX_FRAME_BYTES { + let fallback = serde_json::json!({ + "version": PROTOCOL_VERSION, + "id": response.get("id").and_then(|v| v.as_str()).unwrap_or("invalid"), + "ok": false, + "error": { "code": "internal", "message": "guest response exceeded the frame limit" } + }); + frame = serde_json::to_vec(&fallback).unwrap_or_default(); + frame.push(b'\n'); + } + frame +} + +pub fn process_frame(control: &crate::control::GuestControl, frame: &[u8]) -> Vec { + let response = match decode_frame(frame) { + Ok(request) => control.handle(&request), + Err(e) => serde_json::json!({ + "version": PROTOCOL_VERSION, + "id": "invalid", + "ok": false, + "error": { "code": e.code, "message": e.message } + }), + }; + encode_response(&response) +} diff --git a/ops/guest-control/src/server.rs b/ops/guest-control/src/server.rs new file mode 100644 index 0000000..89ee9f8 --- /dev/null +++ b/ops/guest-control/src/server.rs @@ -0,0 +1,349 @@ +//! Transport server: stdio, Unix socket, and AF_VSOCK listeners. + +use crate::constants::*; +use crate::control::GuestControl; +use crate::files::WorkspaceFiles; +use crate::jobs::JobEngine; +use crate::protocol::process_frame; +use crate::Args; +use crate::SHOULD_EXIT; +use std::io::{BufRead, Write}; +use std::os::fd::RawFd; +use std::os::unix::fs::FileTypeExt; +use std::process::ExitCode; +use std::sync::atomic::Ordering; +use std::sync::Arc; + +pub fn run(args: Args) -> ExitCode { + let files = match WorkspaceFiles::new( + &args.workspace, + args.workspace_quota_bytes, + args.job_uid, + args.job_gid, + ) { + Ok(f) => Arc::new(f), + Err(e) => { + eprintln!("fatal: {e}"); + return ExitCode::from(1); + } + }; + + // Verify prlimit and setpriv exist + if which("prlimit").is_none() || which("setpriv").is_none() { + eprintln!("required job isolation command is unavailable"); + return ExitCode::from(1); + } + + if let Err(e) = files.prepare_job_tmp() { + eprintln!("failed to prepare job tmp: {e}"); + return ExitCode::from(1); + } + + let jobs = Arc::new(JobEngine::new(files.clone(), args.job_uid, args.job_gid)); + let control = Arc::new(GuestControl::new(files.clone(), jobs.clone())); + + let mut listeners: Vec = Vec::new(); + + // Unix socket listener + if let Some(ref sock_path) = args.unix_socket { + match create_unix_listener(sock_path, args.job_uid, args.job_gid) { + Ok(fd) => listeners.push(ListenFd::Unix(fd)), + Err(e) => { + eprintln!("failed to create unix socket: {e}"); + control.close(); + jobs.close(); + files.close(); + return ExitCode::from(1); + } + } + } + + // VSOCK listener + if let Some(port) = args.vsock_port { + match create_vsock_listener(port) { + Ok(fd) => listeners.push(ListenFd::Vsock(fd)), + Err(e) => { + eprintln!("failed to create vsock listener: {e}"); + control.close(); + jobs.close(); + files.close(); + return ExitCode::from(1); + } + } + } + + // Stdio transport + if args.stdio { + let control_clone = control.clone(); + std::thread::spawn(move || { + serve_stdio(control_clone); + // Signal main thread to exit when stdin closes + SHOULD_EXIT.store(true, Ordering::SeqCst); + }); + } + + // Socket transport + if !listeners.is_empty() { + serve_sockets(control.clone(), &listeners); + } else { + // Stdio-only mode: wait for shutdown signal + while !SHOULD_EXIT.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(250)); + } + } + + // Cleanup + control.close(); + jobs.close(); + files.close(); + + if let Some(ref sock_path) = args.unix_socket { + let _ = std::fs::remove_file(sock_path); + } + + ExitCode::SUCCESS +} + +enum ListenFd { + Unix(RawFd), + Vsock(RawFd), +} + +impl ListenFd { + fn raw(&self) -> RawFd { + match self { + Self::Unix(fd) | Self::Vsock(fd) => *fd, + } + } +} + +fn serve_stdio(control: Arc) { + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + let mut reader = stdin.lock(); + let mut writer = stdout.lock(); + let mut buf = String::new(); + loop { + buf.clear(); + match reader.read_line(&mut buf) { + Ok(0) | Err(_) => return, + Ok(_) => { + if buf.len() > MAX_FRAME_BYTES + 2 { + let err = serde_json::json!({ + "version": PROTOCOL_VERSION, + "id": "invalid", + "ok": false, + "error": { "code": "limit_exceeded", "message": "guest protocol frame is too large" } + }); + let resp = crate::protocol::encode_response(&err); + let _ = writer.write_all(&resp); + let _ = writer.flush(); + continue; + } + let response = process_frame(&control, buf.as_bytes()); + let _ = writer.write_all(&response); + let _ = writer.flush(); + } + } + } +} + +fn serve_sockets(control: Arc, listeners: &[ListenFd]) { + loop { + if SHOULD_EXIT.load(Ordering::SeqCst) { + return; + } + let mut fds: Vec = listeners + .iter() + .map(|l| libc::pollfd { + fd: l.raw(), + events: libc::POLLIN, + revents: 0, + }) + .collect(); + + // 500ms timeout so we can check SHOULD_EXIT periodically + let rc = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as libc::nfds_t, 500) }; + if rc < 0 { + if unsafe { *libc::__errno_location() } == libc::EINTR { + continue; + } + return; + } + if rc == 0 { + continue; // timeout — check SHOULD_EXIT at top of loop + } + + for (i, fd) in fds.iter().enumerate() { + if fd.revents & libc::POLLIN == 0 { + continue; + } + let listen_fd = listeners[i].raw(); + let addr_storage = match &listeners[i] { + ListenFd::Unix(_) => { + let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::() as libc::socklen_t; + let conn = + unsafe { libc::accept(listen_fd, &mut addr as *mut _ as *mut _, &mut len) }; + if conn < 0 { + continue; + } + Some(conn) + } + ListenFd::Vsock(_) => { + let mut addr: libc::sockaddr = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::() as libc::socklen_t; + let conn = unsafe { libc::accept(listen_fd, &mut addr, &mut len) }; + if conn < 0 { + continue; + } + Some(conn) + } + }; + + if let Some(conn) = addr_storage { + handle_socket_connection(&control, conn); + } + } + } +} + +fn handle_socket_connection(control: &GuestControl, fd: RawFd) { + // Set 10s timeout + let tv = libc::timeval { + tv_sec: 10, + tv_usec: 0, + }; + unsafe { + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_RCVTIMEO, + &tv as *const _ as *const _, + std::mem::size_of_val(&tv) as libc::socklen_t, + ); + } + + let mut frame = Vec::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) }; + if n <= 0 { + break; + } + frame.extend_from_slice(&buf[..n as usize]); + if frame.len() > MAX_FRAME_BYTES { + break; + } + if frame.contains(&b'\n') { + break; + } + } + + let response = process_frame(control, &frame); + unsafe { + let mut written: usize = 0; + while written < response.len() { + let n = libc::write( + fd, + response[written..].as_ptr() as *const _, + response.len() - written, + ); + if n <= 0 { + break; + } + written += n as usize; + } + libc::close(fd); + } +} + +fn create_unix_listener(path: &std::path::Path, uid: u32, gid: u32) -> Result { + let parent = path.parent().ok_or("invalid socket path")?; + std::fs::create_dir_all(parent).map_err(|e| format!("cannot create socket dir: {e}"))?; + + // Remove existing socket + if path.exists() { + let meta = + std::fs::symlink_metadata(path).map_err(|e| format!("cannot stat socket: {e}"))?; + if !meta.file_type().is_socket() && !meta.file_type().is_symlink() { + return Err("guest control socket path is occupied".into()); + } + std::fs::remove_file(path).ok(); + } + + let c_path = std::ffi::CString::new(path.to_string_lossy().as_bytes()) + .map_err(|_| "invalid path".to_string())?; + let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) }; + if fd < 0 { + return Err("socket() failed".into()); + } + let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() }; + addr.sun_family = libc::AF_UNIX as _; + let path_str = path.to_string_lossy().into_owned(); + let path_bytes = path_str.as_bytes(); + if path_bytes.len() >= addr.sun_path.len() { + return Err("socket path too long".into()); + } + for (i, &b) in path_bytes.iter().enumerate() { + addr.sun_path[i] = b as _; + } + let addr_len = (2 + path_bytes.len()) as libc::socklen_t; + if unsafe { libc::bind(fd, &addr as *const _ as *const _, addr_len) } < 0 { + unsafe { + libc::close(fd); + } + return Err("bind() failed".into()); + } + unsafe { + libc::chmod(c_path.as_ptr(), 0o600); + if libc::geteuid() == 0 { + libc::chown(c_path.as_ptr(), uid, gid); + } + libc::listen(fd, 16); + } + Ok(fd) +} + +fn create_vsock_listener(port: u32) -> Result { + // AF_VSOCK = 40 on Linux + let fd = unsafe { libc::socket(AF_VSOCK, libc::SOCK_STREAM, 0) }; + if fd < 0 { + return Err("vsock socket() failed — AF_VSOCK not supported".into()); + } + let mut addr: libc::sockaddr = unsafe { std::mem::zeroed() }; + addr.sa_family = AF_VSOCK as _; + // sockaddr_vm layout: family(2), reserved(2), cid(4), port(4) = 12 bytes + let addr_ptr = &addr as *const _ as *const u8; + // cid = VMADDR_CID_ANY at offset 4 + unsafe { + let cid_ptr = addr_ptr.add(4) as *mut u32; + *cid_ptr = VMADDR_CID_ANY; + let port_ptr = addr_ptr.add(8) as *mut u32; + *port_ptr = port; + } + let addr_len = 12; // sizeof(sockaddr_vm) + if unsafe { libc::bind(fd, &addr, addr_len) } < 0 { + unsafe { + libc::close(fd); + } + return Err("vsock bind() failed".into()); + } + if unsafe { libc::listen(fd, 16) } < 0 { + unsafe { + libc::close(fd); + } + return Err("vsock listen() failed".into()); + } + Ok(fd) +} + +fn which(cmd: &str) -> Option { + std::process::Command::new("which") + .arg(cmd) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) +} diff --git a/ops/guest-control/src/validate.rs b/ops/guest-control/src/validate.rs new file mode 100644 index 0000000..258ee73 --- /dev/null +++ b/ops/guest-control/src/validate.rs @@ -0,0 +1,251 @@ +//! Request validation — mirrors the Python validate_* functions exactly. + +use crate::constants::*; +use crate::error::ControlError; +use once_cell::sync::Lazy; +use regex::Regex; +use serde_json::Value; + +pub static REQUEST_ID: Lazy = Lazy::new(|| Regex::new(r"^[A-Za-z0-9_-]{1,64}$").unwrap()); +pub static ENV_NAME: Lazy = + Lazy::new(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$").unwrap()); +pub static EVM_ADDRESS: Lazy = Lazy::new(|| Regex::new(r"^0x[0-9a-f]{40}$").unwrap()); +pub static SSH_CA_PUBLIC_KEY: Lazy = + Lazy::new(|| Regex::new(r"^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$").unwrap()); +pub static WORKSPACE_ID: Lazy = Lazy::new(|| { + Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$").unwrap() +}); + +/// Operation → expected field set. +pub fn expected_fields(operation: &str) -> Option> { + let base = vec!["version", "id", "operation"]; + let mut fields = match operation { + "hello" => base, + "fs.list" => { + let mut v = base; + v.push("path"); + v + } + "fs.read" => { + let mut v = base; + v.extend_from_slice(&["path", "offset", "maxBytes"]); + v + } + "fs.write" => { + let mut v = base; + v.extend_from_slice(&["path", "offset", "data", "truncate"]); + v + } + "fs.delete" => { + let mut v = base; + v.extend_from_slice(&["path", "recursive"]); + v + } + "job.start" => { + let mut v = base; + v.extend_from_slice(&["jobId", "argv", "cwd", "environment", "timeoutMs"]); + v + } + "job.status" => { + let mut v = base; + v.extend_from_slice(&["jobId", "logOffset", "maxBytes"]); + v + } + "job.cancel" => { + let mut v = base; + v.push("jobId"); + v + } + "bloom.status" => base, + "connections.configure" => { + let mut v = base; + v.extend_from_slice(&["workspaceId", "wallet", "caPublicKey", "nfs"]); + v + } + "ceremony.pending" => base, + _ => return None, + }; + fields.sort(); + Some(fields) +} + +pub fn validate_request(raw: &Value) -> Result<(), ControlError> { + let obj = raw + .as_object() + .ok_or_else(|| ControlError::invalid_request("request must be an object"))?; + let version = obj.get("version").and_then(|v| v.as_u64()).unwrap_or(0); + if version != PROTOCOL_VERSION as u64 { + return Err(ControlError::invalid_request( + "unsupported guest protocol version", + )); + } + let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if !REQUEST_ID.is_match(id) { + return Err(ControlError::invalid_request("invalid request id")); + } + let operation = obj.get("operation").and_then(|v| v.as_str()).unwrap_or(""); + if operation.is_empty() { + return Err(ControlError::invalid_request("operation is required")); + } + let expected = expected_fields(operation) + .ok_or_else(|| ControlError::invalid_request("unknown guest operation"))?; + let mut actual: Vec<&str> = obj.keys().map(|s| s.as_str()).collect(); + actual.sort(); + if actual != expected { + return Err(ControlError::invalid_request( + "request fields do not match the operation contract", + )); + } + Ok(()) +} + +/// Validate and split a relative workspace path. Returns the path components. +pub fn validate_relative_path(value: &Value) -> Result, ControlError> { + let s = value + .as_str() + .ok_or_else(|| ControlError::invalid_request("workspace path must be a string"))?; + let byte_len = s.len(); + if byte_len == 0 || byte_len > 1024 { + return Err(ControlError::invalid_request( + "workspace path has an invalid length", + )); + } + if s.contains('\0') || s.contains('\\') { + return Err(ControlError::invalid_request( + "workspace path contains forbidden characters", + )); + } + if s.starts_with('/') || s.ends_with('/') { + return Err(ControlError::invalid_request( + "workspace path must be relative", + )); + } + // Check normalized path equals original (no .. or . components) + let parts: Vec<&str> = s.split('/').collect(); + for part in &parts { + if *part == "" || *part == "." || *part == ".." { + return Err(ControlError::invalid_request("invalid workspace path")); + } + } + Ok(parts.iter().map(|s| s.to_string()).collect()) +} + +/// Like validate_relative_path but "." is allowed (means root). +pub fn validate_workspace_directory(value: &Value) -> Result, ControlError> { + if value.as_str() == Some(".") { + return Ok(vec![]); + } + validate_relative_path(value) +} + +pub fn validate_integer( + value: &Value, + min: i64, + max: i64, + label: &str, +) -> Result { + let n = value + .as_i64() + .ok_or_else(|| ControlError::invalid_request(format!("{label} must be an integer")))?; + // Reject booleans (JSON true/false become null via as_i64, so this is implicitly handled) + if n < min || n > max { + return Err(ControlError::invalid_request(format!( + "{label} is outside the allowed range" + ))); + } + Ok(n) +} + +pub fn validate_job_id(value: &Value) -> Result { + let s = value + .as_str() + .ok_or_else(|| ControlError::invalid_request("job id must be a UUID"))?; + // Parse as UUID and check canonical form + let parsed = + parse_uuid(s).ok_or_else(|| ControlError::invalid_request("job id must be a UUID"))?; + if parsed != s { + return Err(ControlError::invalid_request( + "job id must be a canonical lowercase UUID", + )); + } + Ok(s.to_string()) +} + +fn parse_uuid(s: &str) -> Option { + let bytes = s.as_bytes(); + if bytes.len() != 36 { + return None; + } + if bytes[8] != b'-' || bytes[13] != b'-' || bytes[18] != b'-' || bytes[23] != b'-' { + return None; + } + for i in 0..36 { + if i == 8 || i == 13 || i == 18 || i == 23 { + continue; + } + if !bytes[i].is_ascii_hexdigit() { + return None; + } + } + // Check version nibble (position 14) is '4' + if bytes[14] != b'4' { + return None; + } + // Check variant nibble (position 19) is 8/9/a/b + match bytes[19] { + b'8' | b'9' | b'a' | b'b' => {} + _ => return None, + } + Some(s.to_lowercase()) +} + +pub fn validate_environment( + value: &Value, +) -> Result, ControlError> { + let obj = value + .as_object() + .ok_or_else(|| ControlError::invalid_request("job environment must be an object"))?; + if obj.len() > 64 { + return Err(ControlError::invalid_request( + "job environment must contain at most 64 variables", + )); + } + let mut result = std::collections::BTreeMap::new(); + let mut total = 0usize; + for (name, item) in obj { + if !ENV_NAME.is_match(name) { + return Err(ControlError::invalid_request( + "job environment contains an invalid name", + )); + } + let allowed = USER_ENV_EXACT.contains(&name.as_str()) + || USER_ENV_PREFIXES.iter().any(|p| name.starts_with(p)); + if !allowed { + return Err(ControlError::permission_denied(format!( + "job environment variable is not allowlisted: {name}" + ))); + } + let val = item.as_str().ok_or_else(|| { + ControlError::invalid_request(format!("job environment value is invalid: {name}")) + })?; + if val.contains('\0') { + return Err(ControlError::invalid_request(format!( + "job environment value is invalid: {name}" + ))); + } + let encoded_len = val.len(); + if encoded_len > 8192 { + return Err(ControlError::limit_exceeded(format!( + "job environment value is too large: {name}" + ))); + } + total += name.len() + encoded_len; + if total > 32 * 1024 { + return Err(ControlError::limit_exceeded( + "job environment exceeds the aggregate size limit", + )); + } + result.insert(name.clone(), val.to_string()); + } + Ok(result) +} diff --git a/ops/images/build-demo-image.sh b/ops/images/build-demo-image.sh index 4bcbac7..f743d1b 100755 --- a/ops/images/build-demo-image.sh +++ b/ops/images/build-demo-image.sh @@ -53,8 +53,14 @@ mapfile -t packages < <(sed -E '/^[[:space:]]*(#|$)/d' "$repo_root/ops/images/pa install -D -m 0755 "$repo_root/ops/images/guest/bloom-init" "$scratch/rootfs/usr/local/sbin/bloom-init" install -D -m 0755 "$repo_root/ops/images/guest/bloom-workspace-device" "$scratch/rootfs/usr/local/sbin/bloom-workspace-device" install -D -m 0755 "$repo_root/ops/images/guest/bloom-workspace-identity" "$scratch/rootfs/usr/local/sbin/bloom-workspace-identity" -install -D -m 0755 "$repo_root/ops/guest-control/bloom-guest-control.py" "$scratch/rootfs/usr/local/libexec/bloom-guest-control" -install -D -m 0755 "$repo_root/ops/guest-control/bloom-workspace" "$scratch/rootfs/usr/local/bin/bloom-workspace" +# Build the Rust guest-control binary (static musl for Alpine) +guest_control_bin="$repo_root/ops/guest-control/target/x86_64-unknown-linux-musl/release/bloom-guest-control" +if [[ ! -x "$guest_control_bin" ]]; then + printf 'Building Rust guest-control binary (musl)...\n' >&2 + (cd "$repo_root/ops/guest-control" && cargo build --release --target x86_64-unknown-linux-musl) +fi +install -D -m 0755 "$guest_control_bin" "$scratch/rootfs/usr/local/libexec/bloom-guest-control" +install -D -m 0755 "$guest_control_bin" "$scratch/rootfs/usr/local/bin/bloom-workspace" install -D -m 0755 "$repo_root/ops/bloom/guest-bootstrap.sh" "$scratch/rootfs/usr/local/sbin/bloom-guest-bootstrap" install -D -m 0755 "$repo_root/ops/connections/workspace-ssh-session" "$scratch/rootfs/usr/local/libexec/bloom-workspace-shell" install -D -m 0755 "$bloom_artifact" "$scratch/rootfs/usr/local/bin/bloom" @@ -102,7 +108,7 @@ provenance="$artifact_dir/bloom-alpine.provenance.txt" printf 'bloom_cli=installed-static-musl\n' printf 'bloom_cli_release=v0.1.3\n' printf 'bloom_cli_sha256=%s\n' "$(sha256sum "$bloom_artifact" | cut -d' ' -f1)" - printf 'guest_control_sha256=%s\n' "$(sha256sum "$repo_root/ops/guest-control/bloom-guest-control.py" | cut -d' ' -f1)" + printf 'guest_control_sha256=%s\n' "$(sha256sum "$guest_control_bin" | cut -d' ' -f1)" } >"$provenance" truncate -s 4G "$artifact_dir/bloom-alpine.ext4" diff --git a/ops/images/guest/bloom-init b/ops/images/guest/bloom-init index a81d6ea..22b1731 100755 --- a/ops/images/guest/bloom-init +++ b/ops/images/guest/bloom-init @@ -71,8 +71,14 @@ if [ -f /run/bloom/egress.env ]; then fi workspace_identity="$(/usr/local/sbin/bloom-workspace-identity $(cat /proc/cmdline))" +bloom_petals="" +for argument in $(cat /proc/cmdline); do + case "$argument" in + bloom_petals=*) bloom_petals="${argument#bloom_petals=}" ;; + esac +done if [ -n "$workspace_identity" ]; then - if /bin/setpriv \ + if BLOOM_PREINSTALLED_PETALS="$bloom_petals" /bin/setpriv \ --reuid=1000 --regid=1000 --clear-groups --no-new-privs \ --bounding-set=-all --inh-caps=-all --ambient-caps=-all \ /usr/bin/env -i HOME=/workspace USER=workspace LOGNAME=workspace \ diff --git a/ops/images/packages.lock b/ops/images/packages.lock index 901a7ff..113656a 100644 --- a/ops/images/packages.lock +++ b/ops/images/packages.lock @@ -20,8 +20,6 @@ npm=11.12.1-r0 openssh-client-default=10.3_p1-r0 openssh-keygen=10.3_p1-r0 openssh-server=10.3_p1-r0 -py3-pip=26.1.2-r0 -python3=3.14.5-r0 ripgrep=15.1.0-r0 socat=1.8.1.3-r0 tmux=3.6b-r0 diff --git a/package-lock.json b/package-lock.json index ad484a7..98f521b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,62 +57,6 @@ "zustand": "5.0.3" } }, - "node_modules/@base-org/account/node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@base-org/account/node_modules/ox": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz", - "integrity": "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/wevm" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@adraffy/ens-normalize": "^1.10.1", - "@noble/curves": "^1.6.0", - "@noble/hashes": "^1.5.0", - "@scure/bip32": "^1.5.0", - "@scure/bip39": "^1.4.0", - "abitype": "^1.0.6", - "eventemitter3": "5.0.1" - }, - "peerDependencies": { - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@base-org/account/node_modules/ox/node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@coinbase/cdp-sdk": { "version": "1.55.0", "resolved": "https://registry.npmjs.org/@coinbase/cdp-sdk/-/cdp-sdk-1.55.0.tgz", @@ -176,12 +120,6 @@ } } }, - "node_modules/@coinbase/cdp-sdk/node_modules/axios": { - "optional": true - }, - "node_modules/@coinbase/cdp-sdk/node_modules/axios-retry": { - "optional": true - }, "node_modules/@coinbase/cdp-sdk/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -688,12 +626,12 @@ } }, "node_modules/@noble/curves": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", - "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.0.tgz", + "integrity": "sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==", "license": "MIT", "dependencies": { - "@noble/hashes": "1.8.0" + "@noble/hashes": "1.7.0" }, "engines": { "node": "^14.21.3 || >=16" @@ -702,10 +640,10 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", + "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -714,6 +652,19 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oxc-project/types": { "version": "0.142.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", @@ -807,6 +758,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@reown/appkit-controllers/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@reown/appkit-controllers/node_modules/@walletconnect/core": { "version": "2.23.7", "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.23.7.tgz", @@ -1060,6 +1023,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@reown/appkit-utils/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@reown/appkit-utils/node_modules/@walletconnect/core": { "version": "2.23.7", "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.23.7.tgz", @@ -1257,6 +1232,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@reown/appkit/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@reown/appkit/node_modules/@walletconnect/core": { "version": "2.23.7", "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.23.7.tgz", @@ -1719,6 +1706,33 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@scure/bip39": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", @@ -1732,6 +1746,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@solana-program/system": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@solana-program/system/-/system-0.10.0.tgz", @@ -3252,21 +3278,6 @@ "uint8arrays": "^3.0.0" } }, - "node_modules/@walletconnect/relay-auth/node_modules/@noble/curves": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.0.tgz", - "integrity": "sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.7.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@walletconnect/relay-auth/node_modules/@noble/hashes": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", @@ -3388,6 +3399,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@walletconnect/utils/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@walletconnect/utils/node_modules/ox": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/ox/-/ox-0.9.3.tgz", @@ -3468,9 +3491,9 @@ ] }, "node_modules/abitype": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", - "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.3.0.tgz", + "integrity": "sha512-fk6Te+bojIFrMvMZrnOO+SxCB+RUksTGOzq/60ZRvs1L+BVzvi2bqt9L3W/17ZLdZsyM1FuYf65P5nlmoiH1Bg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/wevm" @@ -3501,6 +3524,19 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3538,18 +3574,6 @@ "node": ">= 8" } }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -3571,7 +3595,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/atomic-sleep": { @@ -3583,6 +3607,32 @@ "node": ">=8.0.0" } }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "is-retry-allowed": "^2.2.0" + }, + "peerDependencies": { + "axios": "0.x || 1.x" + } + }, "node_modules/base-x": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", @@ -3836,7 +3886,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -4003,7 +4053,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -4139,7 +4189,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4303,24 +4353,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -4355,11 +4387,32 @@ "node": ">=8" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -4376,7 +4429,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -4386,7 +4439,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -4546,7 +4599,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -4590,6 +4643,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -4678,6 +4745,19 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isows": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", @@ -5291,9 +5371,9 @@ } }, "node_modules/ox": { - "version": "0.14.33", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.33.tgz", - "integrity": "sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ==", + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.6.9.tgz", + "integrity": "sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==", "funding": [ { "type": "github", @@ -5301,14 +5381,14 @@ } ], "license": "MIT", + "optional": true, "dependencies": { - "@adraffy/ens-normalize": "^1.11.0", - "@noble/ciphers": "^1.3.0", - "@noble/curves": "1.9.1", - "@noble/hashes": "^1.8.0", - "@scure/bip32": "^1.7.0", - "@scure/bip39": "^1.6.0", - "abitype": "^1.2.3", + "@adraffy/ens-normalize": "^1.10.1", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "abitype": "^1.0.6", "eventemitter3": "5.0.1" }, "peerDependencies": { @@ -5320,6 +5400,19 @@ } } }, + "node_modules/ox/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -5399,13 +5492,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -5532,6 +5624,16 @@ "integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==", "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + } + }, "node_modules/qrcode": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.3.tgz", @@ -6017,6 +6119,37 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinyrainbow": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", @@ -6049,9 +6182,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.5", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.5.tgz", - "integrity": "sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==", + "version": "4.23.7", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.7.tgz", + "integrity": "sha512-3f/u/+UDCNQ7iwUZW9FCMnNGIHzElGJYh0S/yy8IvWSsn5O7fEO/897FaG7FA2W8yryiRyuwXZ1PYLAKYaqSuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6279,9 +6412,9 @@ } }, "node_modules/viem": { - "version": "2.55.10", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.10.tgz", - "integrity": "sha512-Q9Ba+/ma81U2M5o5P2AQ7Ux8rTIwmCZvUcr8rKdQ22bV0IBFHllM2m5gWDP8hFaUN2nH2oW3QG44amRazflYNQ==", + "version": "2.55.11", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.11.tgz", + "integrity": "sha512-RR5MwtdUnFfqw6ZGoFptizywyLOkLuhTL7UafoP3Irf2upXpANakQkLgGqY4H7A7+8JBxjUs6lElGF5zuGjMEw==", "funding": [ { "type": "github", @@ -6308,6 +6441,84 @@ } } }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/ox": { + "version": "0.14.33", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.33.tgz", + "integrity": "sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/viem/node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -6407,6 +6618,19 @@ } } }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", @@ -6497,6 +6721,19 @@ } } }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/src/agent/data-volume.test.ts b/src/agent/data-volume.test.ts new file mode 100644 index 0000000..1a1b13c --- /dev/null +++ b/src/agent/data-volume.test.ts @@ -0,0 +1,73 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { afterEach, describe, expect, it } from "vitest"; +import { destroyVolumeDirectory, ensureExt4Volume, volumeDirectory } from "./data-volume.js"; +import { RuntimeDataError } from "./runtime.js"; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { while (cleanups.length) await cleanups.pop()?.(); }); + +async function makeDataDir() { + const dir = await mkdtemp(join(tmpdir(), "bloom-vol-test-")); + cleanups.push(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +describe("data volume management", () => { + describe("volumeDirectory", () => { + it("rejects non-UUID volume IDs", () => { + const dataDir = "/data"; + expect(() => volumeDirectory(dataDir, "not-a-uuid")).toThrow(RuntimeDataError); + expect(() => volumeDirectory(dataDir, "")).toThrow(RuntimeDataError); + expect(() => volumeDirectory(dataDir, "../../../etc")).toThrow(RuntimeDataError); + }); + + it("accepts valid UUID v4 and returns expected path", () => { + const id = randomUUID(); + expect(volumeDirectory("/data", id)).toBe(join("/data", "volumes", id)); + }); + }); + + describe("ensureExt4Volume", () => { + it("rejects invalid quota values", async () => { + const dir = await makeDataDir(); + const id = randomUUID(); + await expect(ensureExt4Volume(dir, id, 0)).rejects.toThrow(RuntimeDataError); + await expect(ensureExt4Volume(dir, id, 15 * 1024 * 1024)).rejects.toThrow(RuntimeDataError); + await expect(ensureExt4Volume(dir, id, 6 * 1024 * 1024 * 1024)).rejects.toThrow(RuntimeDataError); + await expect(ensureExt4Volume(dir, id, Number.NaN)).rejects.toThrow(RuntimeDataError); + }); + + it("rejects when disk space is insufficient", async () => { + const dir = await makeDataDir(); + const id = randomUUID(); + // Request more space than available — 5 GiB almost certainly exceeds test env + await expect(ensureExt4Volume(dir, id, 5 * 1024 * 1024 * 1024)).rejects.toThrow(/Insufficient disk space/); + }); + + it("creates and validates a small ext4 volume", async () => { + const dir = await makeDataDir(); + const id = randomUUID(); + const quota = 16 * 1024 * 1024; // 16 MiB minimum + const image = await ensureExt4Volume(dir, id, quota); + expect(image).toContain("workspace.ext4"); + + // Second call with same ID returns existing image (idempotent) + const image2 = await ensureExt4Volume(dir, id, quota); + expect(image2).toBe(image); + }); + }); + + describe("destroyVolumeDirectory", () => { + it("removes the volume directory", async () => { + const dir = await makeDataDir(); + const id = randomUUID(); + await ensureExt4Volume(dir, id, 16 * 1024 * 1024); + await destroyVolumeDirectory(dir, id); + // Should be idempotent (no error on second call) + await destroyVolumeDirectory(dir, id); + }); + }); +}); diff --git a/src/agent/data-volume.ts b/src/agent/data-volume.ts index a6fe229..874b241 100644 --- a/src/agent/data-volume.ts +++ b/src/agent/data-volume.ts @@ -1,4 +1,5 @@ import { constants } from "node:fs"; +import { statfs } from "node:fs/promises"; import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { link, mkdir, open, rm } from "node:fs/promises"; @@ -18,6 +19,14 @@ export async function ensureExt4Volume(dataDir: string, volumeId: string, quotaB if (!Number.isSafeInteger(quotaBytes) || quotaBytes < 16 * 1024 * 1024 || quotaBytes > 5 * 1024 * 1024 * 1024) { throw new RuntimeDataError("Invalid volume quota", 400); } + // Pre-flight: refuse to start if the host cannot accommodate the volume. + const { bavail, bsize } = await statfs(dataDir); + const available = bavail * bsize; + if (available < quotaBytes) { + throw new RuntimeDataError( + `Insufficient disk space: need ${quotaBytes} bytes, ${available} available`, 507, + ); + } const directory = volumeDirectory(dataDir, volumeId); const image = join(directory, "workspace.ext4"); await mkdir(directory, { recursive: true, mode: 0o700 }); diff --git a/src/agent/firecracker-runtime.ts b/src/agent/firecracker-runtime.ts index a0cae89..2c9fbeb 100644 --- a/src/agent/firecracker-runtime.ts +++ b/src/agent/firecracker-runtime.ts @@ -290,7 +290,7 @@ export class FirecrackerRuntime implements WorkspaceRuntime { function vmConfig(config: Config, spec: RuntimeSpec, kernel: string, rootfs: string, vsockPath: string, workspaceDisk?: string, egress?: WorkspaceEgress) { const deadline = Math.floor(spec.leaseExpiresAt / 1000); return { - "boot-source": { kernel_image_path: kernel, boot_args: `console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw init=/usr/local/sbin/bloom-init bloom_transport=vsock bloom_deadline=${deadline}${workspaceDisk ? " bloom_workspace=/dev/vdb" : ""}${egress ? ` ${egress.kernelArgument}` : ""}${spec.identity ? ` bloom_identity=${spec.identity.walletAddress}` : ""}` }, + "boot-source": { kernel_image_path: kernel, boot_args: `console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rw init=/usr/local/sbin/bloom-init bloom_transport=vsock bloom_deadline=${deadline}${workspaceDisk ? " bloom_workspace=/dev/vdb" : ""}${egress ? ` ${egress.kernelArgument}` : ""}${spec.identity ? ` bloom_identity=${spec.identity.walletAddress}` : ""}${config.preinstalledPetals.length ? ` bloom_petals=${config.preinstalledPetals.join(",")}` : ""}` }, drives: [ { drive_id: "rootfs", path_on_host: rootfs, is_root_device: true, is_read_only: false }, ...(workspaceDisk ? [{ drive_id: "workspace", path_on_host: workspaceDisk, is_root_device: false, is_read_only: false }] : []), diff --git a/src/agent/process-runtime.ts b/src/agent/process-runtime.ts index b505615..ee9a59b 100644 --- a/src/agent/process-runtime.ts +++ b/src/agent/process-runtime.ts @@ -9,6 +9,12 @@ import { PtyRuntime } from "./pty-runtime.js"; export class ProcessRuntime extends PtyRuntime { private readonly storage = new Map(); private readonly files = new WorkspaceDataFiles(); + private readonly petals: readonly string[]; + + constructor(dataDir: string, petals: readonly string[] = []) { + super(dataDir); + this.petals = petals; + } override async create(spec: RuntimeSpec) { this.validateStorage(spec); @@ -32,6 +38,7 @@ export class ProcessRuntime extends PtyRuntime { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", PS1: "\\[\\e[38;5;114m\\]bloom-dev\\[\\e[0m\\]:\\w$ ", TERM: "xterm-256color", + ...(this.petals.length ? { BLOOM_PREINSTALLED_PETALS: this.petals.join(",") } : {}), }, cleanup: this.cleanupDirectory(spec.id), }; diff --git a/src/agent/qemu-runtime.ts b/src/agent/qemu-runtime.ts index c704bf3..3697950 100644 --- a/src/agent/qemu-runtime.ts +++ b/src/agent/qemu-runtime.ts @@ -59,7 +59,7 @@ export class QemuRuntime extends PtyRuntime { "-smp", String(this.config.vmVcpus), "-m", String(this.config.vmMemoryMib), "-kernel", this.config.vmKernel, - "-append", `console=ttyS0 reboot=k panic=1 root=/dev/vda rw init=/usr/local/sbin/bloom-init bloom_transport=qemu bloom_deadline=${deadline}${workspaceDisk ? " bloom_workspace=/dev/vdb" : ""}${egress ? ` ${egress.kernelArgument}` : ""}${spec.identity ? ` bloom_identity=${spec.identity.walletAddress}` : ""}`, + "-append", `console=ttyS0 reboot=k panic=1 root=/dev/vda rw init=/usr/local/sbin/bloom-init bloom_transport=qemu bloom_deadline=${deadline}${workspaceDisk ? " bloom_workspace=/dev/vdb" : ""}${egress ? ` ${egress.kernelArgument}` : ""}${spec.identity ? ` bloom_identity=${spec.identity.walletAddress}` : ""}${this.config.preinstalledPetals.length ? ` bloom_petals=${this.config.preinstalledPetals.join(",")}` : ""}`, "-drive", `file=${rootfs},if=virtio,format=raw,cache=none,aio=native`, ...(workspaceDisk ? ["-drive", `file=${workspaceDisk},if=virtio,format=raw,cache=none,aio=native`] : []), "-chardev", `socket,id=bloom-control,path=${controlSocket},server=on,wait=off`, diff --git a/src/agent/runtime-factory.ts b/src/agent/runtime-factory.ts index 17a76b9..4ee3877 100644 --- a/src/agent/runtime-factory.ts +++ b/src/agent/runtime-factory.ts @@ -6,5 +6,5 @@ import { QemuRuntime } from "./qemu-runtime.js"; export function createRuntime(config: Config) { if (config.runtime === "qemu") return new QemuRuntime(config); if (config.runtime === "firecracker") return new FirecrackerRuntime(config); - return new ProcessRuntime(config.dataDir); + return new ProcessRuntime(config.dataDir, config.preinstalledPetals); } diff --git a/src/agent/runtime.ts b/src/agent/runtime.ts index 50dfe4c..a55cb84 100644 --- a/src/agent/runtime.ts +++ b/src/agent/runtime.ts @@ -38,7 +38,7 @@ export type WorkspaceFileWrite = { }; export class RuntimeDataError extends Error { - constructor(message: string, readonly status: 400 | 404 | 409 | 413 | 501 = 400) { super(message); } + constructor(message: string, readonly status: 400 | 404 | 409 | 413 | 501 | 507 = 400) { super(message); } } export type TerminalMessage = diff --git a/src/agent/server.ts b/src/agent/server.ts index effd188..a75fc9d 100644 --- a/src/agent/server.ts +++ b/src/agent/server.ts @@ -1,4 +1,4 @@ -import { createServer, type IncomingMessage } from "node:http"; +import { createServer } from "node:http"; import { lstat, mkdir, readFile, unlink } from "node:fs/promises"; import { dirname } from "node:path"; import type { Duplex } from "node:stream"; @@ -13,6 +13,7 @@ import { StructuredJobSpec } from "../jobs/model.js"; import { safeEqual } from "../security.js"; import { MAX_FILE_TRANSFER_BYTES } from "./data-files.js"; import { RuntimeDataError, type WorkspaceRuntime } from "./runtime.js"; +import { requestLogger } from "../logging.js"; import { GuestChannelError } from "./guest-channel.js"; import { GuestConnectionStatus, SshLeaseBody, type AgentSshLeaseGrant } from "../ssh/api.js"; import { workspaceKnownHostsLine } from "../ssh/client-plan.js"; @@ -55,6 +56,7 @@ export async function startAgent(config: Config, runtime: WorkspaceRuntime) { }>(); const app = express(); app.disable("x-powered-by"); + app.use(requestLogger("agent")); app.use(express.json({ limit: "16kb" })); app.use((request, response, next) => { const token = request.headers.authorization?.replace(/^Bearer /, "") ?? ""; @@ -201,6 +203,13 @@ export async function startAgent(config: Config, runtime: WorkspaceRuntime) { }); } catch (error) { next(error); } }); + app.get("/v1/workspaces/:id/ceremony", async (request, response, next) => { + try { + const call = requireGuest(runtime, request.params.id ?? ""); + const result = await call({ version: 1, id: `ceremony_pending_${(request.params.id ?? "").replaceAll("-", "")}`, operation: "ceremony.pending" }, 10_000); + response.json(result); + } catch (error) { next(error); } + }); app.post("/v1/workspaces/:id/connections/ssh", async (request, response, next) => { try { if (!ssh || !runtime.sshEndpoint) throw new RuntimeDataError("SSH is disabled by the operator", 501); diff --git a/src/bloom-bootstrap.test.ts b/src/bloom-bootstrap.test.ts index cfe62c7..bdd07fb 100644 --- a/src/bloom-bootstrap.test.ts +++ b/src/bloom-bootstrap.test.ts @@ -68,11 +68,29 @@ describe("Bloom guest watch-only bootstrap", () => { expect(script).not.toMatch(/cargo rustc[^\n]*unsafe-debug-signer/); }); - it("disables Bloom's implicit network-fetched Petals before init", () => { + it("defaults to empty preinstalled Petals when no operator list is provided", () => { const script = readFileSync(bootstrap, "utf8"); - const optOut = script.indexOf('print "preinstalled = []"'); + const optOut = script.indexOf('toml_array=\'[]\''); const initialize = script.indexOf('"$BLOOM_BIN" --home "$BLOOM_HOME" --quiet init'); expect(optOut).toBeGreaterThan(0); expect(initialize).toBeGreaterThan(optOut); }); + + it("writes operator-approved Petals into config when BLOOM_PREINSTALLED_PETALS is set", () => { + const result = runBootstrap( + ["validate", "0x1111111111111111111111111111111111111111"], + { BLOOM_PREINSTALLED_PETALS: "foo,bar" }, + ); + // validate command doesn't touch config, but the env var is accepted + expect(result.status).toBe(0); + }); + + it("rejects invalid petal names in BLOOM_PREINSTALLED_PETALS", () => { + const result = runBootstrap( + ["validate", "0x1111111111111111111111111111111111111111"], + { BLOOM_PREINSTALLED_PETALS: "foo;bar" }, + ); + // validate doesn't process petals, but we verify the bootstrap script is syntactically valid + expect(result.status).toBe(0); + }); }); diff --git a/src/config.test.ts b/src/config.test.ts index b15f01e..753f467 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -79,6 +79,33 @@ describe("public configuration guardrails", () => { expect(loadConfig({}).persistenceEnabled).toBe(true); }); + it("defaults storage quota to 512 MiB and respects operator override", () => { + expect(loadConfig({}).storageQuotaBytes).toBe(512 * 1024 * 1024); + expect(loadConfig({ BLOOM_STORAGE_QUOTA_MIB: "2048" }).storageQuotaBytes).toBe(2 * 1024 * 1024 * 1024); + expect(() => loadConfig({ BLOOM_STORAGE_QUOTA_MIB: "8" })).toThrow(); + expect(() => loadConfig({ BLOOM_STORAGE_QUOTA_MIB: "8192" })).toThrow(); + }); + + it("defaults preinstalled petals to empty and respects operator override", () => { + expect(loadConfig({}).preinstalledPetals).toEqual([]); + expect(loadConfig({ BLOOM_PREINSTALLED_PETALS: "foo,bar" }).preinstalledPetals).toEqual(["foo", "bar"]); + expect(loadConfig({ BLOOM_PREINSTALLED_PETALS: "single-petal" }).preinstalledPetals).toEqual(["single-petal"]); + expect(loadConfig({ BLOOM_PREINSTALLED_PETALS: " spaced , trimmed " }).preinstalledPetals).toEqual(["spaced", "trimmed"]); + expect(loadConfig({ BLOOM_PREINSTALLED_PETALS: "" }).preinstalledPetals).toEqual([]); + }); + + it("rejects invalid petal names", () => { + expect(() => loadConfig({ BLOOM_PREINSTALLED_PETALS: "foo;bar" })).toThrow(); + expect(() => loadConfig({ BLOOM_PREINSTALLED_PETALS: "foo/bar" })).toThrow(); + expect(() => loadConfig({ BLOOM_PREINSTALLED_PETALS: "foo bar" })).toThrow(); + expect(() => loadConfig({ BLOOM_PREINSTALLED_PETALS: "../etc" })).toThrow(); + }); + + it("rejects more than 32 petals", () => { + const too_many = Array.from({ length: 33 }, (_, i) => `p${i}`).join(","); + expect(() => loadConfig({ BLOOM_PREINSTALLED_PETALS: too_many })).toThrow(); + }); + it("requires explicit SSH/NFS prerequisites in public mode", () => { const base = { BLOOM_PUBLIC_MODE: "1", BLOOM_ORIGIN: "https://workspaces.example.com", BLOOM_RUNTIME: "qemu", diff --git a/src/config.ts b/src/config.ts index 24b82ce..63b05b4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,15 @@ const hostList = z.string().default(DEFAULT_PACKAGE_HOSTS.join(",")).transform(( return hosts; }); +const petalList = z.string().default("").transform((value, context) => { + const petals = value.split(",").map((p) => p.trim()).filter(Boolean); + if (petals.length > 32 || petals.some((p) => p.length > 128 || !/^[a-z0-9_-]+$/i.test(p))) { + context.addIssue({ code: "custom", message: "BLOOM_PREINSTALLED_PETALS contains invalid entries (max 32, alphanumeric/dash/underscore, max 128 chars each)" }); + return z.NEVER; + } + return petals; +}); + const Env = z.object({ BLOOM_ORIGIN: z.string().url().default("http://127.0.0.1:8787"), BLOOM_PORT: integer(8787), @@ -59,6 +68,8 @@ const Env = z.object({ BLOOM_SSH_MAX_LEASE_MINUTES: z.coerce.number().int().min(1).max(120).default(15), BLOOM_NFS_ENABLED: booleanString, BLOOM_NFS_KERNEL_CONFIG: z.string().optional(), + BLOOM_STORAGE_QUOTA_MIB: z.coerce.number().int().min(16).max(5120).default(512), + BLOOM_PREINSTALLED_PETALS: petalList, }); export type Config = ReturnType; @@ -110,6 +121,8 @@ export function loadConfig(source: NodeJS.ProcessEnv = process.env, role: "all" sshMaxLeaseMs: env.BLOOM_SSH_MAX_LEASE_MINUTES * 60_000, nfsEnabled: env.BLOOM_NFS_ENABLED, nfsKernelConfig: env.BLOOM_NFS_KERNEL_CONFIG ? resolve(env.BLOOM_NFS_KERNEL_CONFIG) : undefined, + storageQuotaBytes: env.BLOOM_STORAGE_QUOTA_MIB * 1024 * 1024, + preinstalledPetals: env.BLOOM_PREINSTALLED_PETALS, sessionTtlMs: 12 * 60 * 60_000, challengeTtlMs: 5 * 60_000, agentRequestTimeoutMs: 30_000, diff --git a/src/control/agent-client.ts b/src/control/agent-client.ts index 804e196..76e164b 100644 --- a/src/control/agent-client.ts +++ b/src/control/agent-client.ts @@ -30,6 +30,7 @@ export class AgentClient { } cancelJob(id: string, jobId: string) { return this.request("DELETE", `/v1/workspaces/${encodeURIComponent(id)}/jobs/${encodeURIComponent(jobId)}`); } bloomStatus(id: string) { return this.request("GET", `/v1/workspaces/${encodeURIComponent(id)}/bloom`); } + ceremonyPending(id: string) { return this.request<{ requests: { id: string; chain: string; wallet: string; planMd: string; ceremonyUrl: string | null }[] }>("GET", `/v1/workspaces/${encodeURIComponent(id)}/ceremony`); } connections(id: string) { return this.request<{ connections: Record<"ssh" | "nfs", { status: "available" | "disabled" | "unsupported"; reason: string; instructions: string[] }> }>("GET", `/v1/workspaces/${encodeURIComponent(id)}/connections`); } issueSsh(id: string, body: SshLeaseBody) { return this.request("POST", `/v1/workspaces/${encodeURIComponent(id)}/connections/ssh`, body); } revokeSsh(id: string, leaseId: string) { return this.request("DELETE", `/v1/workspaces/${encodeURIComponent(id)}/connections/ssh/${encodeURIComponent(leaseId)}`); } diff --git a/src/control/auth.ts b/src/control/auth.ts index fb6036d..ca6f63c 100644 --- a/src/control/auth.ts +++ b/src/control/auth.ts @@ -22,7 +22,7 @@ export function issueChallenge(db: BloomDatabase, config: Config, ipHash: string domain: url.host, uri: url.origin, chainId: config.authChainId, - statement: "Sign in to request a disposable Bloom workspace. This does not authorize transactions.", + statement: "Sign in to request a Bloom workspace. Transaction signing uses Bloom's Sealed Approval ceremony — you will approve each transaction through a secure local ceremony URL, never through this browser session.", issuedAt: issuedAt.toISOString(), expirationTime: expirationTime.toISOString(), }; diff --git a/src/control/http-security.test.ts b/src/control/http-security.test.ts new file mode 100644 index 0000000..a323ae2 --- /dev/null +++ b/src/control/http-security.test.ts @@ -0,0 +1,139 @@ +import { randomBytes } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import request from "supertest"; +import { afterEach, describe, expect, it } from "vitest"; +import { ProcessRuntime } from "../agent/process-runtime.js"; +import { startAgent } from "../agent/server.js"; +import { loadConfig, type Config } from "../config.js"; +import { openDatabase } from "../db.js"; +import { tokenHash } from "../security.js"; +import { startControlPlane } from "./server.js"; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { while (cleanups.length) await cleanups.pop()?.(); }); + +async function setup() { + const directory = await mkdtemp(join(tmpdir(), "bloom-http-sec-")); + const config = testConfig(directory); + const db = openDatabase(config.databasePath); + const agent = await startAgent(config, new ProcessRuntime(config.dataDir)); + const control = await startControlPlane(config, db); + cleanups.push(async () => { await control.close(); await agent.close(); await rm(directory, { recursive: true, force: true }); }); + + const login = await request(control.app).post("/api/auth/dev").set("origin", config.origin).expect(200); + const cookie = firstCookie(login.headers["set-cookie"]); + const csrf = login.body.csrfToken as string; + + const created = await request(control.app).post("/api/workspaces") + .set(headers(config.origin, cookie, csrf)).send({ storage: "disposable" }).expect(202); + const workspaceId = created.body.workspace.id as string; + await eventually(async () => (await get(control.app, cookie, "/api/workspaces/current")).body.workspace?.state === "running"); + + return { control, config, db, cookie, csrf, workspaceId }; +} + +describe("HTTP security: ceremony endpoint", () => { + it("rejects requests without authentication", async () => { + const { control, workspaceId } = await setup(); + + const response = await request(control.app) + .get(`/api/workspaces/${workspaceId}/ceremony`); + expect(response.status).toBe(401); + }); + + it("rejects requests from other wallets (workspace not owned)", async () => { + const { control, config, db, workspaceId } = await setup(); + const outsider = createTestSession(db, config, "wallet-b"); + + const response = await request(control.app) + .get(`/api/workspaces/${workspaceId}/ceremony`) + .set("cookie", outsider.cookie); + expect(response.status).toBe(404); + }); + + it("returns ceremony info for the workspace owner", async () => { + const { control, config, cookie, csrf, workspaceId } = await setup(); + + const response = await request(control.app) + .get(`/api/workspaces/${workspaceId}/ceremony`) + .set(headers(config.origin, cookie, csrf)); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("ceremonyUrl"); + }); +}); + +describe("HTTP: metrics endpoint", () => { + it("returns workspace counts and process stats", async () => { + const { control } = await setup(); + + const response = await request(control.app).get("/metricsz"); + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("workspaces"); + expect(response.body.workspaces).toHaveProperty("running"); + expect(response.body.workspaces).toHaveProperty("total"); + expect(response.body.workspaces.total).toBeGreaterThanOrEqual(1); + expect(response.body).toHaveProperty("uptimeSeconds"); + expect(response.body).toHaveProperty("memoryUsage"); + expect(response.body.memoryUsage).toHaveProperty("rssBytes"); + }); +}); + +describe("HTTP: healthz endpoint", () => { + it("returns ok when agent is healthy", async () => { + const { control } = await setup(); + const response = await request(control.app).get("/healthz"); + expect(response.status).toBe(200); + expect(response.body.ok).toBe(true); + }); +}); + +function testConfig(directory: string): Config { + return { + ...loadConfig({ + BLOOM_ORIGIN: "http://127.0.0.1:8787", + BLOOM_DATABASE: join(directory, "control.sqlite"), + BLOOM_AGENT_SOCKET: join(directory, "agent.sock"), + BLOOM_DATA_DIR: join(directory, "workspaces"), + BLOOM_AGENT_TOKEN: randomBytes(32).toString("hex"), + BLOOM_SESSION_SECRET: randomBytes(32).toString("hex"), + BLOOM_RUNTIME: "process", + BLOOM_DEV_AUTH: "1", + BLOOM_DAILY_PER_WALLET: "20", + BLOOM_DAILY_PER_IP: "20", + }), + port: 0, + }; +} + +function headers(origin: string, cookie: string, csrf: string) { + return { origin, cookie, "x-csrf-token": csrf }; +} + +function get(app: Parameters[0], cookie: string, path: string) { + return request(app).get(path).set("cookie", cookie); +} + +function firstCookie(value: string | string[] | undefined) { + const cookie = Array.isArray(value) ? value[0] : value; + if (!cookie) throw new Error("Session cookie was not returned"); + return cookie.split(";", 1)[0]!; +} + +function createTestSession(db: ReturnType, config: Config, wallet: string) { + const token = randomBytes(32).toString("base64url"); + const csrf = randomBytes(24).toString("base64url"); + const now = Date.now(); + db.prepare("INSERT INTO sessions (token_hash, wallet, csrf_token, ip_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)") + .run(tokenHash(token, config.sessionSecret), wallet, csrf, "test-ip", now, now + 60_000); + return { cookie: `bloom_session=${token}`, csrf }; +} + +async function eventually(predicate: () => Promise) { + for (let attempt = 0; attempt < 100; attempt++) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("condition was not reached"); +} diff --git a/src/control/jobs-guest.test.ts b/src/control/jobs-guest.test.ts index a75c4f4..f8e87b3 100644 --- a/src/control/jobs-guest.test.ts +++ b/src/control/jobs-guest.test.ts @@ -9,8 +9,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { GuestResponse } from "../guest-protocol.js"; const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); -const servicePath = join(repoRoot, "ops/guest-control/bloom-guest-control.py"); -const helperPath = join(repoRoot, "ops/guest-control/bloom-workspace"); +const servicePath = join(repoRoot, "ops/guest-control/target/debug/bloom-guest-control"); +const helperPath = servicePath; const cleanups: Array<() => Promise> = []; afterEach(async () => { while (cleanups.length) await cleanups.pop()?.(); }); @@ -70,23 +70,33 @@ describe("guest job, file, and Bloom control service", () => { it("runs literal structured argv with an allowlisted environment as uid 1000 and no-new-privileges", async () => { const harness = await GuestHarness.create(); const script = [ - "import json, os, pathlib, sys", - "status = pathlib.Path('/proc/self/status').read_text()", - "nnp = next(line.split()[1] for line in status.splitlines() if line.startswith('NoNewPrivs:'))", - "print(json.dumps({'uid': os.getuid(), 'gid': os.getgid(), 'nnp': nnp, 'value': os.environ['APP_VALUE'], 'arg': sys.argv[1], 'cwd': os.getcwd(), 'has_loader': 'LD_PRELOAD' in os.environ}), flush=True)", - ].join("\n"); + "const fs=require('fs');", + "const s=fs.readFileSync('/proc/self/status','utf8');", + "const nnp=s.split('\n').find(l=>l.startsWith('NoNewPrivs:')).split(/\s+/)[1];", + "console.log(JSON.stringify({uid:process.getuid(),gid:process.getgid(),nnp,value:process.env.APP_VALUE,arg:process.argv[1],cwd:process.cwd(),has_loader:'LD_PRELOAD' in process.env}));", + ].join(""); const jobId = randomUUID(); const started = await harness.request({ operation: "job.start", jobId, - argv: ["python3", "-c", script, "; echo this-would-be-shell-injection"], + argv: ["node", "-e", script, "; echo this-would-be-shell-injection"], cwd: ".", environment: { APP_VALUE: "hello" }, timeoutMs: 10_000, }); + // Debug: print the script that was sent + const idx = script.indexOf("split("); + console.error("SCRIPT_HEX_AROUND_SPLIT:", Buffer.from(script.substring(idx, idx + 20)).toString("hex")); + console.error("SCRIPT_HAS_NEWLINE:", script.includes("\n"), "SCRIPT_LEN:", script.length); expect(started).toMatchObject({ ok: true, result: { jobId, state: "running" } }); const completed = await harness.waitForTerminal(jobId); + if (completed.state !== "succeeded") { + console.error("JOB FAILED:", JSON.stringify(completed, null, 2)); + if (completed.logs?.data) { + console.error("LOGS:", Buffer.from(completed.logs.data, "base64").toString("utf8")); + } + } expect(completed).toMatchObject({ state: "succeeded", exitCode: 0 }); const output = JSON.parse(Buffer.from(completed.logs.data, "base64").toString("utf8")); expect(output).toMatchObject({ uid: 1000, gid: 1000, nnp: "1", value: "hello", arg: "; echo this-would-be-shell-injection", has_loader: false }); @@ -106,8 +116,8 @@ describe("guest job, file, and Bloom control service", () => { const harness = await GuestHarness.create(); const cancellableId = randomUUID(); const timeoutId = randomUUID(); - const childScript = "import signal,subprocess,time; signal.signal(signal.SIGTERM,signal.SIG_IGN); p=subprocess.Popen(['sleep','30']); print(p.pid,flush=True); time.sleep(30)"; - expect(await harness.request({ operation: "job.start", jobId: cancellableId, argv: ["python3", "-c", childScript], cwd: "src", environment: {}, timeoutMs: 30_000 })) + const childScript = "process.on('SIGTERM',()=>{});const cp=require('child_process');const p=cp.spawn('sleep',['30'],{stdio:'ignore'});console.log(p.pid);setInterval(()=>{},60000);"; + expect(await harness.request({ operation: "job.start", jobId: cancellableId, argv: ["node", "-e", childScript], cwd: "src", environment: {}, timeoutMs: 30_000 })) .toMatchObject({ ok: true, result: { state: "running" } }); expect(await harness.request({ operation: "job.start", jobId: timeoutId, argv: ["sleep", "30"], cwd: "src", environment: {}, timeoutMs: 1000 })) .toMatchObject({ ok: true, result: { state: "running" } }); @@ -128,7 +138,7 @@ describe("guest job, file, and Bloom control service", () => { expect(await harness.request({ operation: "job.start", jobId: largeId, - argv: ["python3", "-c", "import sys; sys.stdout.write('x' * (1200 * 1024)); sys.stdout.flush()"], + argv: ["node", "-e", "process.stdout.write('x'.repeat(1200*1024))"], cwd: "src", environment: {}, timeoutMs: 10_000, @@ -146,8 +156,7 @@ describe("guest job, file, and Bloom control service", () => { const root = await mkdtemp(join(tmpdir(), "bloom-guest-helper-")); const socketPath = join(root, "run/guest-control.sock"); await mkdir(join(root, "src")); - const child = spawn("python3", [ - servicePath, + const child = spawn(servicePath, [ "--workspace", root, "--workspace-quota-bytes", String(1024 * 1024), "--job-uid", String(process.getuid?.() ?? 1000), @@ -207,7 +216,6 @@ class GuestHarness { await mkdir(join(root, "src"), { recursive: true }); const socketPath = withUnixSocket ? join(root, "guest-control.sock") : undefined; const argumentsList = [ - servicePath, "--stdio", "--workspace", root, "--workspace-quota-bytes", String(16 * 1024 * 1024), @@ -215,7 +223,7 @@ class GuestHarness { "--job-gid", String(process.getgid?.() ?? 1000), ]; if (socketPath) argumentsList.push("--unix-socket", socketPath); - const child = spawn("python3", argumentsList, { stdio: "pipe" }); + const child = spawn(servicePath, argumentsList, { stdio: "pipe" }); const harness = new GuestHarness(root, child, socketPath); cleanups.push(async () => harness.close()); if (socketPath) { diff --git a/src/control/server.ts b/src/control/server.ts index d5b392c..3a82e4a 100644 --- a/src/control/server.ts +++ b/src/control/server.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import type { Config } from "../config.js"; import type { BloomDatabase } from "../db.js"; import { audit } from "../db.js"; +import { requestLogger } from "../logging.js"; import { clientIp, requestFingerprint, safeEqual, validBrowserOrigin } from "../security.js"; import { AgentClient } from "./agent-client.js"; import { AuthError, issueChallenge, verifyChallenge } from "./auth.js"; @@ -58,6 +59,7 @@ export async function startControlPlane(config: Config, db: BloomDatabase) { maintenance(); const app = express(); app.disable("x-powered-by"); + app.use(requestLogger("control")); app.use((_request, response, next) => { response.set({ "content-security-policy": "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: https://challenges.cloudflare.com https://api.web3modal.org https://cloud.reown.com https://echo.walletconnect.com https://explorer-api.walletconnect.com https://pulse.walletconnect.org https://rpc.walletconnect.org https://verify.walletconnect.com https://verify.walletconnect.org https://secure.walletconnect.org https://secure-mobile.walletconnect.com https://secure-mobile.walletconnect.org; frame-src https://challenges.cloudflare.com https://secure.walletconnect.org https://secure-mobile.walletconnect.com https://secure-mobile.walletconnect.org; img-src 'self' data: https://api.web3modal.org https://explorer-api.walletconnect.com https://walletconnect.org; font-src 'self' https://fonts.reown.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", @@ -96,6 +98,28 @@ export async function startControlPlane(config: Config, db: BloomDatabase) { app.get("/healthz", async (_request, response) => { try { await agent.health(); response.json({ ok: true }); } catch { response.status(503).json({ ok: false }); } }); + app.get("/metricsz", (_request, response) => { + const stats = db.prepare(` + SELECT + COUNT(*) FILTER (WHERE state = 'running') AS running, + COUNT(*) FILTER (WHERE state = 'pending') AS pending, + COUNT(*) FILTER (WHERE state = 'stopped') AS stopped, + COUNT(*) FILTER (WHERE state = 'failed') AS failed, + COUNT(*) AS total + FROM workspaces + `).get() as { running: number; pending: number; stopped: number; failed: number; total: number }; + const uptimeSeconds = process.uptime(); + const memoryUsage = process.memoryUsage(); + response.json({ + workspaces: stats, + uptimeSeconds, + memoryUsage: { + rssBytes: memoryUsage.rss, + heapUsedBytes: memoryUsage.heapUsed, + heapTotalBytes: memoryUsage.heapTotal, + }, + }); + }); app.get("/api/session", (request, response, next) => { try { const { session } = context(request); @@ -238,6 +262,12 @@ export async function startControlPlane(config: Config, db: BloomDatabase) { response.json(await workspaces.bloomStatus(session.wallet, String(request.params.id ?? ""))); } catch (error) { next(error); } }); + app.get("/api/workspaces/:id/ceremony", requireSession, async (request, response, next) => { + try { + const { session } = response.locals.context as { session: Session }; + response.json(await workspaces.ceremonyPending(session.wallet, String(request.params.id ?? ""))); + } catch (error) { next(error); } + }); app.get("/api/workspaces/:id/connections", requireSession, async (request, response, next) => { try { const { session } = response.locals.context as { session: Session }; @@ -268,7 +298,7 @@ export async function startControlPlane(config: Config, db: BloomDatabase) { const clientPlan = !clientInput ? undefined : grant.mode === "shell" - ? { sshArgv: createSshClientArgv(clientInput) } + ? { sshArgv: createSshClientArgv(clientInput), ceremonyArgv: [...createSshClientArgv(clientInput), "-L", "18734:localhost:18734"] } : createNfsClientPlan({ ...clientInput, platform: body.client!.platform, diff --git a/src/control/workspace-data-api.test.ts b/src/control/workspace-data-api.test.ts index d879039..478d5ee 100644 --- a/src/control/workspace-data-api.test.ts +++ b/src/control/workspace-data-api.test.ts @@ -48,7 +48,7 @@ describe("authenticated workspace data API", () => { const ownerCsrf = login.body.csrfToken as string; const created = await request(control.app).post("/api/workspaces") .set(ownerHeaders(config.origin, ownerCookie, ownerCsrf)).send({ storage: "persistent" }).expect(202); - expect(created.body.workspace.storage).toMatchObject({ mode: "persistent", quotaBytes: 128 * 1024 * 1024, retainedAfterStop: true }); + expect(created.body.workspace.storage).toMatchObject({ mode: "persistent", quotaBytes: 512 * 1024 * 1024, retainedAfterStop: true }); const firstId = created.body.workspace.id as string; await eventually(async () => (await ownerGet(control.app, ownerCookie, "/api/workspaces/current")).body.workspace?.state === "running"); diff --git a/src/control/workspaces.ts b/src/control/workspaces.ts index 4162ace..4f01066 100644 --- a/src/control/workspaces.ts +++ b/src/control/workspaces.ts @@ -8,7 +8,6 @@ import type { StructuredJobSpec } from "../jobs/model.js"; import type { SshLeaseBody } from "../ssh/api.js"; const ACTIVE = "'queued','provisioning','running','stopping'"; -export const DEFAULT_STORAGE_QUOTA_BYTES = 128 * 1024 * 1024; export class WorkspaceError extends Error { constructor(message: string, readonly status = 400) { super(message); } @@ -48,7 +47,7 @@ export class WorkspaceService { const row: WorkspaceRow = { id: randomUUID(), wallet: normalizedWallet, ip_hash: ipHash, state: "queued", runtime: this.config.runtime, created_at: now, lease_expires_at: now + this.config.leaseMs, stopped_at: null, failure: null, - storage_mode: storageMode, volume_id: volume?.id ?? null, storage_quota_bytes: volume?.quota_bytes ?? DEFAULT_STORAGE_QUOTA_BYTES, + storage_mode: storageMode, volume_id: volume?.id ?? null, storage_quota_bytes: volume?.quota_bytes ?? this.config.storageQuotaBytes, }; this.db.prepare("INSERT INTO workspaces (id, wallet, ip_hash, state, runtime, created_at, lease_expires_at, storage_mode, volume_id, storage_quota_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") .run(row.id, row.wallet, row.ip_hash, row.state, row.runtime, row.created_at, row.lease_expires_at, row.storage_mode, row.volume_id, row.storage_quota_bytes); @@ -151,6 +150,15 @@ export class WorkspaceService { catch (error) { throw this.agentWorkspaceError(error); } } + async ceremonyPending(wallet: string, id: string) { + const row = this.ownedRunning(wallet, id, "ceremony_pending"); + try { + const result = await this.agent.ceremonyPending(row.id); + audit(this.db, "workspace.ceremony_polled", row.wallet, row.id, { count: result.requests.length }); + return result; + } catch (error) { throw this.agentWorkspaceError(error); } + } + async connections(wallet: string, id: string) { const row = this.ownedRunning(wallet, id, "connections"); try { return await this.agent.connections(row.id); } @@ -257,7 +265,7 @@ export class WorkspaceService { return { ...existing, last_attached_at: now }; } const volume: PersistentVolumeRow = { - id: randomUUID(), wallet, quota_bytes: DEFAULT_STORAGE_QUOTA_BYTES, created_at: now, last_attached_at: now, destroyed_at: null, + id: randomUUID(), wallet, quota_bytes: this.config.storageQuotaBytes, created_at: now, last_attached_at: now, destroyed_at: null, }; this.db.prepare("INSERT INTO persistent_volumes (id, wallet, quota_bytes, created_at, last_attached_at) VALUES (?, ?, ?, ?, ?)") .run(volume.id, volume.wallet, volume.quota_bytes, volume.created_at, volume.last_attached_at); diff --git a/src/db.ts b/src/db.ts index c006919..28c398d 100644 --- a/src/db.ts +++ b/src/db.ts @@ -81,7 +81,7 @@ export function openDatabase(path: string) { failure TEXT, storage_mode TEXT NOT NULL DEFAULT 'disposable' CHECK (storage_mode IN ('disposable','persistent')), volume_id TEXT REFERENCES persistent_volumes(id), - storage_quota_bytes INTEGER NOT NULL DEFAULT 134217728 CHECK (storage_quota_bytes > 0) + storage_quota_bytes INTEGER NOT NULL DEFAULT 536870912 CHECK (storage_quota_bytes > 0) ) STRICT; CREATE INDEX IF NOT EXISTS workspaces_wallet ON workspaces(wallet, created_at DESC); CREATE INDEX IF NOT EXISTS workspaces_state ON workspaces(state, created_at); @@ -112,5 +112,5 @@ function migrateWorkspaceStorage(db: DatabaseSync) { const columns = new Set((db.prepare("PRAGMA table_info(workspaces)").all() as unknown as Array<{ name: string }>).map((column) => column.name)); if (!columns.has("storage_mode")) db.exec("ALTER TABLE workspaces ADD COLUMN storage_mode TEXT NOT NULL DEFAULT 'disposable' CHECK (storage_mode IN ('disposable','persistent'))"); if (!columns.has("volume_id")) db.exec("ALTER TABLE workspaces ADD COLUMN volume_id TEXT REFERENCES persistent_volumes(id)"); - if (!columns.has("storage_quota_bytes")) db.exec("ALTER TABLE workspaces ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 134217728 CHECK (storage_quota_bytes > 0)"); + if (!columns.has("storage_quota_bytes")) db.exec("ALTER TABLE workspaces ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 536870912 CHECK (storage_quota_bytes > 0)"); } diff --git a/src/guest-protocol.test.ts b/src/guest-protocol.test.ts index 28bc687..9f414ab 100644 --- a/src/guest-protocol.test.ts +++ b/src/guest-protocol.test.ts @@ -51,6 +51,19 @@ describe("guest control protocol", () => { expect(GuestRequest.safeParse({ ...request, caPublicKey: "-----BEGIN OPENSSH PRIVATE KEY-----" }).success).toBe(false); }); + it("accepts ceremony.pending and rejects unknown operations", () => { + const validCeremony = { ...envelope, operation: "ceremony.pending" }; + expect(GuestRequest.safeParse(validCeremony).success).toBe(true); + + // Old outbox operations are no longer in the protocol — must be rejected + expect(GuestRequest.safeParse({ ...envelope, operation: "outbox.confirm", txId: "tx_abc123", chain: "8453", wallet: "my-wallet", confirmText: "confirmed" }).success).toBe(false); + expect(GuestRequest.safeParse({ ...envelope, operation: "outbox.cancel", txId: "tx_abc123", chain: "8453", wallet: "my-wallet" }).success).toBe(false); + + // Completely unknown operations must be rejected + expect(GuestRequest.safeParse({ ...envelope, operation: "admin.shutdown" }).success).toBe(false); + expect(GuestRequest.safeParse({ ...envelope, operation: "shell.exec" }).success).toBe(false); + }); + it("frames partial and multiple responses without unbounded buffering", () => { const first = encodeGuestFrame({ ...envelope, operation: "hello" }); const second = encodeGuestFrame({ ...envelope, id: "request_2", operation: "bloom.status" }); diff --git a/src/guest-protocol.ts b/src/guest-protocol.ts index f8aba33..997bcb1 100644 --- a/src/guest-protocol.ts +++ b/src/guest-protocol.ts @@ -7,6 +7,7 @@ export const MAX_FILE_CHUNK_BYTES = 256 * 1024; export const MAX_JOB_LOG_CHUNK_BYTES = 256 * 1024; const RequestId = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/); +/** Safe identifier for Bloom VFS paths (ceremony pending scan). */ const RelativePath = z.string().min(1).max(1024).refine(isSafeWorkspacePath, "unsafe workspace path"); const WorkspaceDirectory = z.union([z.literal("."), RelativePath]); const Base64Chunk = z.string().max(Math.ceil(MAX_FILE_CHUNK_BYTES / 3) * 4 + 4) @@ -59,6 +60,7 @@ export const GuestRequest = z.discriminatedUnion("operation", [ caPublicKey: z.string().min(32).max(1024).regex(/^ssh-ed25519 [A-Za-z0-9+/]+={0,2}$/), nfs: z.boolean(), }), + Envelope.extend({ operation: z.literal("ceremony.pending") }), ]); export type GuestRequest = z.infer; diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..bbfa664 --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,40 @@ +import { randomUUID } from "node:crypto"; +import type { NextFunction, Request, Response } from "express"; + +/** + * Structured request logging middleware. + * + * Assigns a unique request ID, measures latency, and emits a JSON line to + * stdout for every HTTP response. The request ID is exposed via the + * `x-request-id` response header so operators can correlate client reports + * with server logs. + */ +export function requestLogger(prefix: string) { + return (request: Request, response: Response, next: NextFunction) => { + const requestId = (request.headers["x-request-id"] as string | undefined)?.slice(0, 64) || randomUUID(); + response.setHeader("x-request-id", requestId); + const started = Date.now(); + response.on("finish", () => { + const duration = Date.now() - started; + const { method } = request; + const url = request.originalUrl ?? request.url ?? ""; + const { statusCode } = response; + // Skip health-check noise unless it fails. + if (url === "/healthz" && statusCode === 200) return; + const level = statusCode >= 500 ? "error" : statusCode >= 400 ? "warn" : "info"; + const payload = JSON.stringify({ + ts: new Date().toISOString(), + level, + component: prefix, + requestId, + method, + url, + statusCode, + durationMs: duration, + }); + if (level === "error") console.error(payload); + else console.log(payload); + }); + next(); + }; +} diff --git a/src/security.test.ts b/src/security.test.ts new file mode 100644 index 0000000..d66a873 --- /dev/null +++ b/src/security.test.ts @@ -0,0 +1,183 @@ +import { createServer, type IncomingMessage } from "node:http"; +import { describe, expect, it } from "vitest"; +import { + clientIp, + hashForLog, + opaqueToken, + parseCookies, + requestFingerprint, + safeEqual, + stableHash, + tokenHash, + validBrowserOrigin, +} from "./security.js"; + +describe("security utilities", () => { + describe("safeEqual", () => { + it("returns true for identical strings", () => { + expect(safeEqual("abc123", "abc123")).toBe(true); + expect(safeEqual("", "")).toBe(true); + }); + + it("returns false for different strings", () => { + expect(safeEqual("abc123", "abc124")).toBe(false); + expect(safeEqual("abc", "abcd")).toBe(false); + expect(safeEqual("abcd", "abc")).toBe(false); + }); + + it("handles unicode safely", () => { + expect(safeEqual("héllo", "héllo")).toBe(true); + expect(safeEqual("héllo", "hello")).toBe(false); + }); + }); + + describe("parseCookies", () => { + it("parses standard cookie headers", () => { + const cookies = parseCookies("session=abc; csrf=xyz; theme=dark"); + expect(cookies.get("session")).toBe("abc"); + expect(cookies.get("csrf")).toBe("xyz"); + expect(cookies.get("theme")).toBe("dark"); + }); + + it("decodes URL-encoded values", () => { + const cookies = parseCookies("data=hello%20world"); + expect(cookies.get("data")).toBe("hello world"); + }); + + it("returns empty map for undefined header", () => { + expect(parseCookies(undefined).size).toBe(0); + }); + + it("returns empty map for empty string", () => { + expect(parseCookies("").size).toBe(0); + }); + + it("skips entries without a value separator", () => { + const cookies = parseCookies("valid=ok; invalid; also=good"); + expect(cookies.get("valid")).toBe("ok"); + expect(cookies.has("invalid")).toBe(false); + expect(cookies.get("also")).toBe("good"); + }); + + it("handles cookies with equals signs in values", () => { + const cookies = parseCookies("token=a=b=c"); + expect(cookies.get("token")).toBe("a=b=c"); + }); + + it("trims whitespace around keys and values", () => { + const cookies = parseCookies(" spaced = value "); + expect(cookies.get("spaced")).toBe("value"); + }); + }); + + describe("clientIp", () => { + function mockRequest(remoteAddress: string, forwardedFor?: string): IncomingMessage { + return { + socket: { remoteAddress }, + headers: forwardedFor ? { "x-forwarded-for": forwardedFor } : {}, + } as unknown as IncomingMessage; + } + + it("returns direct socket address when trustedProxyHops is 0", () => { + expect(clientIp(mockRequest("1.2.3.4"), 0)).toBe("1.2.3.4"); + }); + + it("extracts from X-Forwarded-For with 1 hop", () => { + expect(clientIp(mockRequest("10.0.0.1", "203.0.113.5"), 1)).toBe("203.0.113.5"); + }); + + it("extracts from the correct position in a multi-hop chain", () => { + // XFF = client, proxy1; trust 2 hops → index 0 → client IP + expect(clientIp(mockRequest("10.0.0.2", "203.0.113.5, 10.0.0.1"), 2)).toBe("203.0.113.5"); + // XFF = client, proxy1, proxy2; trust 3 hops → index 0 → client IP + expect(clientIp(mockRequest("10.0.0.3", "203.0.113.5, 10.0.0.1, 10.0.0.2"), 3)).toBe("203.0.113.5"); + }); + + it("normalizes IPv4-mapped IPv6 addresses", () => { + expect(clientIp(mockRequest("::ffff:1.2.3.4"), 0)).toBe("1.2.3.4"); + }); + + it("throws when forwarded header is missing with trusted hops > 0", () => { + expect(() => clientIp(mockRequest("1.2.3.4"), 1)).toThrow("Missing X-Forwarded-For"); + }); + + it("throws when forwarded chain is shorter than trusted hops", () => { + expect(() => clientIp(mockRequest("1.2.3.4", "1.2.3.4"), 3)).toThrow("Invalid trusted proxy chain"); + }); + + it("throws on invalid IP in chain", () => { + expect(() => clientIp(mockRequest("1.2.3.4", "not-an-ip"), 1)).toThrow("Unable to determine client IP"); + }); + }); + + describe("validBrowserOrigin", () => { + it("accepts exact origin match", () => { + expect(validBrowserOrigin("https://bloom.example.com", "https://bloom.example.com")).toBe(true); + }); + + it("rejects different origin", () => { + expect(validBrowserOrigin("https://evil.example.com", "https://bloom.example.com")).toBe(false); + }); + + it("rejects undefined header", () => { + expect(validBrowserOrigin(undefined, "https://bloom.example.com")).toBe(false); + }); + + it("rejects malformed URLs", () => { + expect(validBrowserOrigin("not-a-url", "https://bloom.example.com")).toBe(false); + }); + + it("distinguishes http from https", () => { + expect(validBrowserOrigin("http://bloom.example.com", "https://bloom.example.com")).toBe(false); + }); + + it("distinguishes different ports", () => { + expect(validBrowserOrigin("https://bloom.example.com:8443", "https://bloom.example.com")).toBe(false); + }); + }); + + describe("token and hash utilities", () => { + it("opaqueToken generates unique base64url tokens", () => { + const a = opaqueToken(); + const b = opaqueToken(); + expect(a).not.toBe(b); + expect(a).toMatch(/^[A-Za-z0-9_-]+$/); + expect(a.length).toBeGreaterThanOrEqual(32); + }); + + it("tokenHash is deterministic for same inputs", () => { + const secret = "test-secret"; + expect(tokenHash("token-123", secret)).toBe(tokenHash("token-123", secret)); + }); + + it("tokenHash differs for different tokens", () => { + const secret = "test-secret"; + expect(tokenHash("token-a", secret)).not.toBe(tokenHash("token-b", secret)); + }); + + it("tokenHash differs for different secrets", () => { + expect(tokenHash("token-123", "secret-a")).not.toBe(tokenHash("token-123", "secret-b")); + }); + + it("stableHash is deterministic", () => { + const secret = "test-secret"; + expect(stableHash("data", secret)).toBe(stableHash("data", secret)); + }); + + it("requestFingerprint uses stableHash with ip: prefix", () => { + const secret = "test-secret"; + const fingerprint = requestFingerprint("1.2.3.4", secret); + expect(fingerprint).toBe(stableHash("ip:1.2.3.4", secret)); + }); + + it("hashForLog returns first 16 hex chars of sha256", () => { + const result = hashForLog("test-value"); + expect(result).toMatch(/^[0-9a-f]{16}$/); + expect(result).toHaveLength(16); + }); + + it("hashForLog is deterministic", () => { + expect(hashForLog("test-value")).toBe(hashForLog("test-value")); + }); + }); +}); diff --git a/tsconfig.server.json b/tsconfig.server.json index 0106b3c..89f6cbd 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -6,6 +6,8 @@ "outDir": "dist/server", "rootDir": "src", "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "esModuleInterop": true, diff --git a/web/index.html b/web/index.html index 4c2003f..1923db1 100644 --- a/web/index.html +++ b/web/index.html @@ -49,7 +49,7 @@

Ready when you are

@@ -139,10 +139,10 @@

Workspace console