From b1505729479f28b0000526912cc6262fdbfc105c Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 26 Sep 2026 01:59:59 +0000 Subject: [PATCH 1/2] Select the sandbox backend at install with --sandbox; stop enrolling the install host install.sh --sandbox docker|microsandbox|e2b|none (default docker, none for --web-only) saves the deployment through /core/v1/sandbox/deployment once the services are healthy, as Web's setup would. Docker and microsandbox use Web's Standard size from the bundled copy of apps/web/src/features/sandbox/standard-sizes.json and the bundle's Runtime release; E2B takes --e2b-api-key-file and --e2b-template and is refused before anything is installed when public_url is loopback. A repair or conversion never selects a backend. A refused selection prints Core's message and exits 1 with the services running. The installer no longer adds its own host as a node: local_node.py becomes sandbox_setup.py without the enrollment code or PARSAR_NODE_ENROLLMENT_TOKEN, and --sandbox-provider and --provider fail naming --sandbox. The summary says what the sandboxes are and where to add nodes, or why a loopback install can't yet. --- CONTRIBUTING.md | 39 ++--- README.md | 40 +++-- deploy/install/convert.py | 2 +- deploy/install/install.py | 161 ++++++++++++------ deploy/install/installer_fakes.py | 34 +++- deploy/install/local_node.py | 69 -------- deploy/install/sandbox_setup.py | 111 ++++++++++++ deploy/install/test_convert.py | 2 + deploy/install/test_install.py | 93 +++++++--- deploy/install/test_local_node.py | 73 -------- deploy/install/test_sandbox_setup.py | 54 ++++++ docs/configuration.md | 30 ++-- docs/getting-started/README.md | 8 +- docs/getting-started/install.md | 88 +++++----- docs/getting-started/operations.md | 11 +- docs/getting-started/quickstart.md | 8 +- scripts/build-core-distribution.sh | 4 +- scripts/core-distribution-manifest.py | 2 +- services/agents-api/HOSTED-SANDBOX-MANAGER.md | 22 +-- 19 files changed, 518 insertions(+), 333 deletions(-) delete mode 100644 deploy/install/local_node.py create mode 100644 deploy/install/sandbox_setup.py delete mode 100644 deploy/install/test_local_node.py create mode 100644 deploy/install/test_sandbox_setup.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b2875a12..685e0d57e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -831,9 +831,9 @@ initial node configuration reads and registration; durable node credentials auth retained configuration reads and node transport. Project keys cannot read nodes, placement or allocations. -One execution owner manages local and remote nodes through the same finite -Provider protocol. Local opt-in uses the same standalone node installer and -service as a remote host, with a guest-reachable, non-loopback HTTPS origin. +One execution owner manages every node through the same finite Provider protocol. +The Core host joins through the same standalone node installer and service as any +other host; every node needs a guest-reachable, non-loopback HTTPS origin. All managed nodes actively connect over authenticated TLS. Persist private node identity and highest owner epoch; refuse another process using the same identity or a changed backend namespace. Reserve each NodeID before transport upgrade and @@ -1835,14 +1835,15 @@ The first installer targets a trusted Linux amd64 Docker host. It installs a private dedicated PostgreSQL service and separate Core and console services in Compose by default, with zero execution nodes. The default requires neither KVM nor systemd user services, imports no Runtime image, mounts neither the Docker -socket nor host devices into Core, and generates no managed Provider configuration. -Local sandbox placement is opt-in: `--sandbox-provider true --provider microsandbox` -or `--sandbox-provider true --provider docker`. Enabling the option without naming -a provider selects microsandbox; `--provider` without enabling the option is an -error. Local opt-in requires a non-loopback HTTPS `--public-url` reachable from -sandbox guests. Web-only mode cannot enable a sandbox provider. Core-only mode retains the -same opt-in rule. Missing KVM fails when microsandbox is selected without changing -that choice. +socket nor host devices into Core, and never adds its own host as a node. +`--sandbox docker|microsandbox|e2b|none` (default `docker`; `none` and nothing else +with `--web-only`) is a one-time install action: once the services are healthy, the +installer POSTs `/core/v1/sandbox/deployment` as Web's setup would, and never on a +repair or conversion. It is not written to `config.json`; PostgreSQL owns the +selection. E2B needs a non-loopback HTTPS `public_url`, `--e2b-api-key-file` and +`--e2b-template`, and is refused before anything is installed. A loopback Docker or +microsandbox selection is saved, but no node can serve it until `public_url` is +guest-reachable HTTPS. `--sandbox-provider` and `--provider` are retired and fail. The thin distribution supplies native Core binaries. Provider helpers, the node agent, Runtime launcher and pinned msb runtime/firmware are separate, same-revision assets. Nodes obtain them only from the console's payload, so a console serves @@ -1866,11 +1867,11 @@ The basic API image and binary builds remain independent artifacts. The standalone API release and Core distribution both include the Hosted Sandbox Manager guide at the relative path used by their packaged README. Include the guide in each artifact checksum list so extracted documentation matches its build. -The node asset includes the sandbox-node binary. Local opt-in initializes an empty -deployment through the same administrator API, then invokes the ordinary node -installer. Its initial size matches Web's setup proposal: 2 CPUs with 2048 MiB for -Docker, or 4096 MiB and 8192 MiB for each disk for microsandbox. An existing -database selection is never overwritten by installer defaults. Node configuration and identity live under +The node asset includes the sandbox-node binary. The installer's Docker and +microsandbox selections use Web's Standard size from +`apps/web/src/features/sandbox/standard-sizes.json`, which the distribution build +copies into the bundle; keep no second copy of those values. An existing database +selection is never overwritten by installer defaults. Node configuration and identity live under `~/.parsar/nodes//`; microsandbox uses its separate short private Runtime home. Zero-node installs create no node identity state but retain the paired Core key for first setup. @@ -1878,9 +1879,9 @@ Core key for first setup. One Runtime image contains the existing daemon, shared helpers and three native harness packages. Their differences remain in the adapters. Core keeps exclusive ownership of Session allocation, initialization, cancellation, snapshots and -cleanup. When a sandbox provider is enabled, the installer imports its Runtime -image and prepares running conditions; it never creates an execution Session or -supplies a model credential. Applications use the +cleanup. The node installer imports the Runtime image and prepares running +conditions; neither installer creates an execution Session or supplies a model +credential. Applications use the existing write-only model execution extension, with the installation's persistent credential encryption key. Provider identity/backend namespace and native history must not change on a repeated install. diff --git a/README.md b/README.md index de8d84ab8..3c02f3eb0 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,8 @@ Parsar product. Core and its administrator Web console ship together. Projects own assets; multiple API keys in one Project share its assets and execution principal. Projects and API keys live in the database. Management credentials cannot call the Agent API. The -default installation runs Core, Web -and PostgreSQL with zero execution nodes. Add execution nodes through Web when -you are ready. Core creates each required sandbox from the shared Runtime image. Model +default installation runs Core, Web and PostgreSQL and selects Docker sandboxes, +with zero execution nodes. Add execution nodes through Web when you are ready. Core creates each required sandbox from the shared Runtime image. Model credentials are supplied through the existing write-only API extension. Hosted deployments select E2B cloud or one provider across their own local/remote @@ -32,7 +31,9 @@ retain their node across disconnects and resume. ./install.sh --public-url https://core.example ``` - This starts Core, Web and PostgreSQL with zero execution nodes. Release + This starts Core, Web and PostgreSQL and selects Docker sandboxes at Web's + Standard size, with zero execution nodes; `--sandbox` chooses microsandbox, E2B + or none instead. The installer never adds this host as a node. Release bundles are tied to a source revision; an older published bundle does not include current management changes. See the [installation guide](docs/getting-started/install.md) for obtaining/building a matching bundle and the host/network prerequisites. @@ -43,14 +44,14 @@ retain their node across disconnects and resume. issue a key within it for your application. Save the one-time plaintext response privately; Core stores its digest. Rotate by issuing another key in the same Project and revoking the old one. -3. **Add a node.** Open **Nodes** and choose E2B cloud or your own machines with - Docker/microsandbox. E2B needs its account credentials and qualified Runtime - template, with no node installation; your own machines also need a sandbox size. - Either way, finish setup with **Save configuration**. The paired console address - is used by default; advanced network settings allow a different reachable HTTPS - origin. For your own machines, then select **Add node**, and copy and run the - command on a prepared Linux host. Web shows when the node is online and its - provider is ready. All nodes in a deployment use the same provider. +3. **Add a node.** Open **Nodes**, select **Add node**, and copy and run the + command on a prepared Linux host, this one included. Web shows when the node is + online and its provider is ready. All nodes in a deployment use the same + provider. Nodes need the HTTPS public URL: a loopback installation keeps its + sandbox selection but can't add nodes until `public_url` is set. After + `--sandbox none`, **Nodes** first asks for E2B cloud (its account key and a ready + template build, with no node installation) or your own machines with + Docker/microsandbox and a sandbox size; finish with **Save configuration**. Installation and node enrollment do not call a model. Once a node is ready, run an optional API example with your own model credentials. @@ -71,14 +72,17 @@ local console/API access. For node enrollment, use the reachable origin describe above. Flags only seed the installation's `config.json`; later changes go there and take effect with `~/.parsar/core/parsar apply`, and `parsar status`, `start` and `stop` replace `install.sh --status` and `--stop`. -Installing a local provider is optional, needs that HTTPS `--public-url`, and is not -required for adding nodes in Web. +`--sandbox` is a one-time choice of the sandbox backend, saved in the database like +Web's setup; change it later in Web. `--sandbox-provider` and `--provider` are +retired: the installer no longer adds its own host as a node. ```sh -./install.sh # Core + Web + PostgreSQL, loopback access, zero nodes -./install.sh --core-only # Core + PostgreSQL, zero nodes -./install.sh --sandbox-provider true --provider microsandbox --public-url https://core.example -./install.sh --sandbox-provider true --provider docker --public-url https://core.example +./install.sh # Core + Web + PostgreSQL, loopback access, Docker sandboxes, zero nodes +./install.sh --core-only # Core + PostgreSQL, Docker sandboxes, zero nodes +./install.sh --sandbox microsandbox --public-url https://core.example +./install.sh --sandbox e2b --e2b-api-key-file "$HOME/e2b-api-key" \ + --e2b-template ':' --public-url https://core.example +./install.sh --sandbox none # choose the sandbox backend in Web later ``` Web-only installation connects the console server to an existing Core; see the diff --git a/deploy/install/convert.py b/deploy/install/convert.py index 7510135f5..2bb0a2906 100644 --- a/deploy/install/convert.py +++ b/deploy/install/convert.py @@ -386,7 +386,7 @@ def preflight(root, bundle_manifest, images, public_url_override, run): "source_commit": bundle_manifest["source_commit"], "images": images, "secrets_sha256": {Path(target).name: configuration.sha256((root / source).read_bytes()) for source, target in plan.moves if target != "secrets/core.key"}, - "core_installation_id": None, "generated": {}, "local_node": None, + "core_installation_id": None, "generated": {}, "converted_from": {"source_commit": old["source_commit"], "at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "remove": [name for name in plan.deletions if name.startswith(("config/", "state/"))]}} diff --git a/deploy/install/install.py b/deploy/install/install.py index a9d02c210..5bfa23d14 100644 --- a/deploy/install/install.py +++ b/deploy/install/install.py @@ -27,12 +27,14 @@ import configuration from configuration import valid_core_origin import convert -import local_node import native_service import parsar_cli +import sandbox_setup from distribution import DistributionError, artifact, image_identities, ensure_docker_image SETTING_FLAGS = ("core_only", "web_only", "native_core", "core_port", "web_port", "core_url", "public_url") +RETIRED_NODE_FLAGS = ("--sandbox-provider and --provider are retired: use --sandbox docker|microsandbox|e2b|none. " + "The installer no longer adds this host as a node; add it with Add node on the Nodes page in Web.") class InstallError(Exception): @@ -66,8 +68,8 @@ def verify_bundle(bundle): raise InstallError("Distribution checksum mismatch: " + name) required = {"manifest.json", "install.sh", "install.py", "configuration.py", "config_model.py", "config.schema.json", "parsar_cli.py", "convert.py", "parsar.pyz", "native_service.py", - "local_node.py", "node_spec.py", "node-install.pyz", "self-hosted-install.pyz", "distribution.py", - "runtime/seccomp.json"} + "sandbox_setup.py", "standard-sizes.json", "node_spec.py", "node-install.pyz", + "self-hosted-install.pyz", "distribution.py", "runtime/seccomp.json"} required.update(f"images/{name}.tar" for name in ("core", "web", "database")) required.update("native/bin/" + name for name in ("agents-api", "agents-api-migrate")) required.add("native/e2b/agents-api-e2b-provider") @@ -118,10 +120,13 @@ def arguments(argv=None): modes.add_argument("--core-only", action="store_true", default=None) modes.add_argument("--web-only", action="store_true", default=None) parser.add_argument("--native-core", action="store_true", default=None, help="Run Core as a systemd user service") - parser.add_argument("--sandbox-provider", choices=("true", "false"), nargs="?", const="true", - help="Prepare a local sandbox provider (default: false)") - parser.add_argument("--provider", choices=("microsandbox", "docker"), - help="Local sandbox provider when enabled (default: microsandbox)") + parser.add_argument("--sandbox", choices=sandbox_setup.CHOICES, + help="Sandbox backend Core starts with, at Web's Standard size (default: docker; " + "none with --web-only). Add nodes afterwards on the Nodes page in Web") + parser.add_argument("--e2b-api-key-file", type=Path, help="With --sandbox e2b: private file containing the E2B API key") + parser.add_argument("--e2b-template", help="With --sandbox e2b: the ready template build, template-id:build-uuid") + parser.add_argument("--sandbox-provider", nargs="?", const=True, help=argparse.SUPPRESS) + parser.add_argument("--provider", nargs="?", const=True, help=argparse.SUPPRESS) parser.add_argument("--install-dir", type=Path, default=Path.home() / ".parsar/core") parser.add_argument("--core-port", type=int) parser.add_argument("--web-port", type=int) @@ -138,6 +143,8 @@ def arguments(argv=None): args = parser.parse_args(argv) if args.admin_token_file: parser.error("--admin-token-file was renamed; use --core-key-file") + if args.sandbox_provider is not None or args.provider is not None: + parser.error(RETIRED_NODE_FLAGS) for retired in ("status", "stop"): if getattr(args, retired): parser.error(f"--{retired} is retired; run {args.install_dir / 'parsar'} {retired}") @@ -169,18 +176,22 @@ def check_flags(args, document): mode, native = ("core-only" if args.core_only else "web-only" if args.web_only else "all"), args.native_core else: mode, native = document.get("mode"), document.get("native_core") - sandbox = args.sandbox_provider == "true" - if args.provider and not sandbox: - raise InstallError("--provider requires --sandbox-provider true") - if mode == "web-only" and sandbox: - raise InstallError("--web-only cannot install a sandbox provider") + if mode == "web-only" and args.sandbox not in (None, "none"): + raise InstallError("--web-only has no Core; choose the sandbox backend on the Core host") + choice = args.sandbox or ("none" if mode == "web-only" else "docker") + if choice == "e2b" and not (args.e2b_api_key_file and args.e2b_template): + raise InstallError("--sandbox e2b requires --e2b-api-key-file and --e2b-template") + if choice != "e2b" and (args.e2b_api_key_file or args.e2b_template): + raise InstallError("--e2b-api-key-file and --e2b-template require --sandbox e2b") + if choice == "e2b" and not sandbox_setup.e2b_template(args.e2b_template): + raise InstallError("--e2b-template must name a template build as template-id:build-uuid") if mode == "web-only" and native: raise InstallError("--web-only cannot install native Core") if mode == "web-only" and not args.core_key_file: raise InstallError("--web-only requires --core-key-file (and --core-url, or web.core_url in --config)") if mode != "web-only" and args.core_key_file: raise InstallError("--core-key-file requires --web-only") - return (args.provider or "microsandbox") if sandbox else None + return choice def seed_config(args, document): @@ -210,24 +221,35 @@ def origin_port(value): return parsed.port or (443 if parsed.scheme == "https" else 80) -def check_public_url(config, provider): - if provider: - if urlsplit(config["public_url"] or "").scheme != "https" or loopback_origin(config["public_url"]): - raise InstallError("Local sandbox installation requires public_url with HTTPS reachable from sandbox " - "guests; loopback origins cannot be used") +def nodes_reach(public_url): + """Nodes and their sandboxes need an HTTPS public URL that is not loopback.""" + return urlsplit(public_url or "").scheme == "https" and not loopback_origin(public_url) -def read_core_key_file(source): +def check_public_url(config, choice): + # E2B's sandboxes reach Core from E2B's cloud; Core would refuse the selection. + if choice == "e2b" and not nodes_reach(config["public_url"]): + raise InstallError("E2B needs an HTTPS public_url that is not loopback (--public-url, or public_url " + "in the --config file)") + + +def read_private_file(source, name): + """The one token in an absolute, private regular file of at most 4 KiB.""" try: info = source.lstat() except OSError: - raise InstallError("Core key file must be an absolute, private regular file") from None + raise InstallError(f"{name} must be an absolute, private regular file") from None if (not source.is_absolute() or not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) & 0o077 or info.st_size > 4096): - raise InstallError("Core key file must be an absolute, private regular file") + raise InstallError(f"{name} must be an absolute, private regular file") token = source.read_text().strip() if not token or any(c.isspace() for c in token) or "\x00" in token: - raise InstallError("Invalid Core key file") + raise InstallError("Invalid " + name) + return token + + +def read_core_key_file(source): + token = read_private_file(source, "Core key file") if len(token) < 32: raise InstallError("The Core key must have at least 32 characters") return token @@ -337,7 +359,7 @@ def layout(root): return "other" -def create(root, args, config, manifest, images, provider=None): +def create(root, args, config, manifest, images): """Write the new installation's secrets, config.json and state.json.""" mode = config["mode"] token = read_core_key_file(args.core_key_file) if mode == "web-only" else secrets.token_hex(32) @@ -354,9 +376,7 @@ def create(root, args, config, manifest, images, provider=None): "uid": os.getuid(), "gid": os.getgid(), "mode": mode, "native_core": config.get("native_core", False), "source_commit": manifest["source_commit"], "images": images, "secrets_sha256": configuration.secret_digests(root, mode), "core_installation_id": None, - "converted_from": None, "generated": {}, - # A requested local node is enrolled once the services first start; a repair retries it. - "local_node": provider} + "converted_from": None, "generated": {}} state["secrets_sha256"].pop("core.key") # state.json first: whenever config.json exists, the installation can be repaired. write(root / "state.json", json.dumps(state, indent=2) + "\n") @@ -367,8 +387,11 @@ def unfinished_conversion(state): return bool(state.get("converted_from")) and not state["converted_from"].get("finished") -def finish(root, bundle, manifest, fresh=False): - """Put the bundle's files in place, apply config.json and start the services.""" +def finish(root, bundle, manifest, fresh=False, selection=None): + """Put the bundle's files in place, apply config.json and start the services. + + A new installation then saves its sandbox selection; a repair or conversion never does. + """ state = parsar_cli.load_state(root) converting = unfinished_conversion(state) # A converted installation replaces the earlier release's binaries and payload once. @@ -382,20 +405,52 @@ def finish(root, bundle, manifest, fresh=False): # An earlier-release Core has no /core/v1/installation (404); apply noted it and Web still works. if mode == "web-only" and parsar_cli.paired_core(root, config)[0] not in (200, 404): raise InstallError("Core key authentication failed. Inspect secrets/core.key and web.core_url; no model was called") - provider = state.get("local_node") - if provider: - local_node.install(root, dict(state, provider=provider, core_port=config["ports"]["core"], - public_url=config["public_url"]), manifest, bundle, run) - state = parsar_cli.load_state(root) - if provider or converting: - state["local_node"] = None - if converting: - state["converted_from"] = dict(state["converted_from"], finished=True) + deployment = failure = None + if selection: + try: + deployment = sandbox_setup.initialize(root, config, state, selection) + except sandbox_setup.SandboxSetupError as error: + failure = error + if converting: + state = parsar_cli.load_state(root) + state["converted_from"] = dict(state["converted_from"], finished=True) parsar_cli.save_state(root, state) - summary(root, config, provider, fresh) - - -def summary(root, config, provider, fresh): + summary(root, config, fresh, selection, deployment) + if failure: + where = ("on the Nodes page in Web" if mode == "all" else + "through the Core management API (POST /core/v1/sandbox/deployment)") + raise InstallError(f"{str(failure).rstrip('.')}. Services are installed and running; " + f"choose the sandbox backend {where}") + + +def size(resources): + memory = resources["memory_mib"] + return f'{resources["cpus"]} CPUs, ' + (f"{memory // 1024} GiB" if memory % 1024 == 0 else f"{memory} MiB") + + +def sandbox_lines(root, config, selection, deployment): + """What a new installation's sandboxes are and how nodes are added.""" + web = config["mode"] == "all" + if selection is None: + return ["Sandboxes: none chosen. Choose a sandbox backend " + ( + "on the Nodes page in Web." if web else + "through the Core management API (POST /core/v1/sandbox/deployment).")] + if deployment is None: + return [] # The error that follows says what to do. + if selection["provider"] == "e2b": + resources = (deployment.get("specification") or {}).get("resources") or {} + built = f" ({size(resources)})" if {"cpus", "memory_mib"} <= set(resources) else "" + return [f'Sandboxes: E2B template {selection["e2b"]["template"]}{built}. E2B runs them; no nodes are needed.'] + line = f'Sandboxes: {sandbox_setup.NAMES[selection["provider"]]}, Standard ({size(selection["resources"])}).' + if not nodes_reach(config["public_url"]): + # Each sandbox calls Core at public_url; a loopback address is the sandbox itself. + return [line, "Nodes need an HTTPS public URL that other machines and their sandboxes can reach: set " + f"public_url in {root / 'config.json'} and run {root / 'parsar'} apply, then add nodes."] + add = "in Web, open Nodes and choose Add node" if web else "in a Web console paired with this Core, open Nodes and choose Add node" + return [line, f"Add nodes: {add}, then paste the command on each host, this one included."] + + +def summary(root, config, fresh, selection=None, deployment=None): mode, public_url, ports = config["mode"], config["public_url"], config["ports"] if mode != "core-only": # The console accepts only its configured origin, so a public URL has no loopback console. @@ -420,13 +475,10 @@ def summary(root, config, provider, fresh): print("Keep the Core key private; it also authorizes the Core management API.") print(f"Settings: {root / 'config.json'}. Edit it, then run {root / 'parsar'} apply.") print(f"Manage the services with {root / 'parsar'} status, start and stop.") - if provider: - print("Provider: " + provider + ". Local node enrolled; Core provisions Sessions on demand.") - # Only a new installation says it has no nodes; a repaired or converted one keeps its own. - elif fresh and mode == "all": - print("No execution node was installed by this run. Choose a sandbox backend and add nodes on the Nodes page in Web.") - elif fresh and mode == "core-only": - print("No execution node was installed by this run.") + # Only a new installation reports its sandboxes; a repaired or converted one keeps its own. + if fresh and mode != "web-only": + for line in sandbox_lines(root, config, selection, deployment): + print(line) print("Services installed. No model request was made. See docs/getting-started/quickstart.md.") @@ -499,11 +551,13 @@ def main(argv=None): if kind == "other": raise InstallError("Installation directory is not empty; refusing to overwrite existing state") document = seed_document(args) - provider = check_flags(args, document) + choice = check_flags(args, document) config = seed_config(args, document) - check_public_url(config, provider) + check_public_url(config, choice) if config["mode"] == "web-only": read_core_key_file(args.core_key_file) + e2b = ({"api_key": read_private_file(args.e2b_api_key_file, "E2B API key file"), "template": args.e2b_template} + if choice == "e2b" else None) check_host() manifest = verify_bundle(bundle) if config.get("native_core"): @@ -511,16 +565,17 @@ def main(argv=None): for key in ("core", "web", "database"): if key in config["ports"]: free_port(config["ports"][key]) + selection = None if choice == "none" else sandbox_setup.selection(bundle, manifest, choice, e2b) images = image_loader(manifest, bundle)(image_names(config["mode"], config.get("native_core", False))) - create(root, args, config, manifest, images, provider) - finish(root, bundle, manifest, fresh=True) + create(root, args, config, manifest, images) + finish(root, bundle, manifest, fresh=True, selection=selection) if __name__ == "__main__": try: main() except (InstallError, convert.ConvertError, parsar_cli.ParsarError, config_model.ConfigError, - local_node.LocalNodeError, DistributionError, RuntimeError) as error: + sandbox_setup.SandboxSetupError, DistributionError, RuntimeError) as error: print(str(error), file=sys.stderr) sys.exit(1) except (OSError, ValueError, KeyError, subprocess.CalledProcessError): diff --git a/deploy/install/installer_fakes.py b/deploy/install/installer_fakes.py index 78dfbade2..ce2efa4e7 100644 --- a/deploy/install/installer_fakes.py +++ b/deploy/install/installer_fakes.py @@ -16,8 +16,10 @@ import native_service import parsar_cli +import sandbox_setup LABEL = "io.parsar.inputs" +STANDARD_SIZES = Path(__file__).resolve().parents[2] / "apps/web/src/features/sandbox/standard-sizes.json" def sha256(data): @@ -42,10 +44,14 @@ def __init__(self, test): self.nodes = [] self.remote_core = {} # web-only: origin -> (status, installation_id) self.deployment_core_url = "" # what an old Core reports for its sandbox deployment + self.deployment = {"provider": ""} # what sandbox_setup reads and posts + self.deployment_posts = [] + self.deployment_refusal = None # Core's message when it refuses the POST for target, name, value in ((subprocess, "run", mock.Mock(side_effect=self.run)), (parsar_cli, "http", self.http), (parsar_cli, "time", SimpleNamespace(sleep=lambda seconds: None)), - (native_service, "_process_environment", self.process_environment)): + (native_service, "_process_environment", self.process_environment), + (sandbox_setup, "send", self.sandbox_send)): patcher = mock.patch.object(target, name, value) patcher.start() test.addCleanup(patcher.stop) @@ -234,6 +240,29 @@ def http(self, url, headers=None, timeout=5): return 200, json.dumps({"core_url": self.deployment_core_url}).encode() return 404, b"" + def sandbox_send(self, req): + origin, _, path = req.full_url.partition("://")[2].partition("/") + port = int(origin.rsplit(":", 1)[1]) + digests = self.core_listening(port) + if digests is None: + raise ConnectionRefusedError() + if sha256(req.get_header("Authorization", "").removeprefix("Bearer ")) not in digests: + return 401, b'{"error": {"message": "Invalid Core key"}}' + if path != "core/v1/sandbox/deployment": + return 404, b"" + if req.get_method() == "POST": + selection = json.loads(req.data) + self.deployment_posts.append(selection) + if self.deployment_refusal: + return 409, json.dumps({"error": {"message": self.deployment_refusal}}).encode() + # E2B adopts the template build's size. + self.deployment = {"provider": selection["provider"], "specification": { + "resources": selection.get("resources", {"cpus": 2, "memory_mib": 2048})}} + native = self.native["active"] and self.native["addr"] == port + environment = self.native["environment"] if native else self.core.get("environment", "") + installation = re.search(r'^AGENTS_API_SANDBOX_INSTALLATION_ID="([^"]+)"$', environment, re.M) + return 200, json.dumps(dict(self.deployment, installation_id=installation and installation[1])).encode() + MANIFEST = { "source_commit": "a" * 40, @@ -245,7 +274,7 @@ def http(self, url, headers=None, timeout=5): "microsandbox": {"runtime_sha256": "5" * 64, "firmware_sha256": "6" * 64}, } MODULES = ("install.py", "configuration.py", "config_model.py", "config.schema.json", "parsar_cli.py", "convert.py", - "native_service.py", "local_node.py", "node_spec.py", "distribution.py", "install.sh") + "native_service.py", "sandbox_setup.py", "node_spec.py", "distribution.py", "install.sh") def write_checksums(bundle): @@ -265,6 +294,7 @@ def make_bundle(directory, manifest, commit=None): (bundle / "runtime/seccomp.json").write_text('{"defaultAction":"SCMP_ACT_ERRNO"}') for name in MODULES: (bundle / name).write_bytes(Path(__file__).with_name(name).read_bytes()) + (bundle / "standard-sizes.json").write_bytes(STANDARD_SIZES.read_bytes()) for name in ("node-install.pyz", "self-hosted-install.pyz"): (bundle / name).write_bytes(b"synthetic verified Python bootstrap") (bundle / "parsar.pyz").write_bytes(b"synthetic parsar command " + manifest["source_commit"].encode()) diff --git a/deploy/install/local_node.py b/deploy/install/local_node.py deleted file mode 100644 index 2c4350574..000000000 --- a/deploy/install/local_node.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Install an explicitly requested local node through the ordinary admin API.""" -import json -import http.client -import os -from pathlib import Path -import sys -import urllib.error -import urllib.request - -import node_spec - - -class LocalNodeError(Exception): - pass - - -class NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, request, fp, code, message, headers, url): - raise LocalNodeError("Local node setup cannot follow redirects") - - -def request(core, token, method, path, value=None): - data = json.dumps(value).encode() if value is not None else None - headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"} - req = urllib.request.Request(core + "/core/v1/sandbox/" + path, data=data, headers=headers, method=method) - try: - with urllib.request.build_opener(NoRedirect()).open(req, timeout=30) as response: - raw = response.read(16385) - if len(raw) > 16384: - raise ValueError() - result = json.loads(raw) - if not isinstance(result, dict): - raise ValueError() - return result - except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead, ValueError): - raise LocalNodeError("Local node setup was not confirmed; preserve installation state and inspect Core before rerunning") from None - - -def install(root, state, manifest, bundle, run): - core = f'http://127.0.0.1:{state["core_port"]}' - admin = (root / "secrets/core.key").read_text().strip() - current = request(core, admin, "GET", "deployment") - if current.get("installation_id") != state["installation_id"]: - raise LocalNodeError("Core deployment identity differs; preserve its installation state") - if not current.get("provider"): - # The same initial size that Web's sandbox setup proposes for this provider. - resources = {"cpus": 2, "memory_mib": 2048} - if state["provider"] == "microsandbox": - resources.update(memory_mib=4096, root_disk_mib=8192, environment_disk_mib=8192) - current = request(core, admin, "POST", "deployment", { - "provider": state["provider"], "resources": resources, "runtime": node_spec.release(manifest)}) - if current.get("provider") != state["provider"] or current.get("core_url") != state["public_url"]: - raise LocalNodeError("Existing deployment selection differs; change it through maintenance instead of reinstalling") - node_root = Path.home() / ".parsar/nodes" / state["installation_id"] - # A completed registration needs only the retained credential. An interrupted - # registration may use a new token; the node first tries its original identity. - token = "" - if not (node_root / "registered.json").exists(): - enrollment = request(core, admin, "POST", "enrollment-tokens", {}) - token = enrollment.get("token", "") - if not isinstance(token, str) or not token or any(c.isspace() for c in token): - raise LocalNodeError("Core returned invalid node enrollment data") - environment = dict(os.environ) - environment.pop("PARSAR_NODE_ENROLLMENT_TOKEN", None) - if token: - environment["PARSAR_NODE_ENROLLMENT_TOKEN"] = token - run([sys.executable, str(bundle / "node-install.pyz"), "--bundle", str(bundle), - "--core-url", state["public_url"], "--installation-id", state["installation_id"], - "--provider", state["provider"]], env=environment) diff --git a/deploy/install/sandbox_setup.py b/deploy/install/sandbox_setup.py new file mode 100644 index 000000000..9ccfc768a --- /dev/null +++ b/deploy/install/sandbox_setup.py @@ -0,0 +1,111 @@ +"""Choose a new installation's sandbox backend through Core's administrator API. + +The installer applies what Web's setup would: Docker and microsandbox get the +Standard size from the bundled copy of Web's standard-sizes.json and the bundle's +Runtime release; E2B gets the account key and template build, and Core adopts the +build's size. Nodes are added afterwards from Web, this host included. +""" +import json +import http.client +import re +import urllib.error +import urllib.request +import uuid + +import node_spec + +CHOICES = ("docker", "microsandbox", "e2b", "none") +NAMES = {"docker": "Docker", "microsandbox": "microsandbox", "e2b": "E2B"} + + +class SandboxSetupError(Exception): + pass + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, message, headers, url): + raise SandboxSetupError("Core redirected the sandbox setup request") + + +def send(req): + """(status, body) of one request to Core: no redirects, no ambient proxy.""" + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + try: + # E2B validation reads the template build from E2B before Core answers. + with opener.open(req, timeout=60) as response: + return response.status, response.read(16385) + except urllib.error.HTTPError as error: + return error.code, error.read(16385) + + +def refusal(status, raw, secret=None): + """Core's own error message, printable, bounded and without the E2B key.""" + try: + message = json.loads(raw)["error"]["message"] + except (ValueError, KeyError, TypeError): + message = None + if not isinstance(message, str) or not message.strip(): + return f"Core refused the sandbox setup (HTTP {status})" + if secret: + message = message.replace(secret, "[E2B API key]") + return "Core refused the sandbox setup: " + "".join(c if c.isprintable() else " " for c in message.strip())[:500] + + +def request(core, token, method, path, value=None): + data = json.dumps(value).encode() if value is not None else None + headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"} + try: + status, raw = send(urllib.request.Request(core + "/core/v1/sandbox/" + path, data=data, + headers=headers, method=method)) + except (urllib.error.URLError, OSError, http.client.HTTPException, ValueError): + raise SandboxSetupError("Core did not confirm the sandbox setup") from None + if not 200 <= status < 300: + raise SandboxSetupError(refusal(status, raw, ((value or {}).get("e2b") or {}).get("api_key"))) + try: + if len(raw) > 16384: + raise ValueError() + result = json.loads(raw) + if not isinstance(result, dict): + raise ValueError() + return result + except ValueError: + raise SandboxSetupError("Core did not confirm the sandbox setup") from None + + +def e2b_template(value): + """`template-id:build-uuid`, as Core accepts it.""" + template, _, build = value.partition(":") + try: + exact = uuid.UUID(build) + except ValueError: + exact = None + return (re.fullmatch(r"[A-Za-z0-9_-]{1,128}", template) is not None and exact is not None + and exact.int != 0 and str(exact) == build) + + +def selection(bundle, manifest, choice, e2b=None): + """The request Web's setup sends for this backend.""" + if choice == "e2b": + return {"provider": "e2b", "e2b": e2b} + try: + resources = json.loads((bundle / "standard-sizes.json").read_text())[choice] + except (OSError, ValueError, KeyError, TypeError): + resources = None + if not isinstance(resources, dict) or not all(type(value) is int for value in resources.values()): + raise SandboxSetupError("The bundle's standard-sizes.json is invalid") + return {"provider": choice, "resources": resources, "runtime": node_spec.release(manifest)} + + +def initialize(root, config, state, request_body): + """Save the selection unless Core already has one; returns Core's deployment.""" + core = f'http://127.0.0.1:{config["ports"]["core"]}' + key = (root / "secrets/core.key").read_text().strip() + current = request(core, key, "GET", "deployment") + if current.get("installation_id") != state["installation_id"]: + raise SandboxSetupError("Core reports a different installation") + provider = current.get("provider") + if provider == request_body["provider"]: + return current + if provider: + raise SandboxSetupError(f"Core already uses {NAMES.get(provider, provider)} sandboxes") + return request(core, key, "POST", "deployment", request_body) diff --git a/deploy/install/test_convert.py b/deploy/install/test_convert.py index 29e3f081b..8828a4a64 100644 --- a/deploy/install/test_convert.py +++ b/deploy/install/test_convert.py @@ -158,6 +158,8 @@ def test_every_mode_of_both_earlier_layouts_converts(self): self.assertIn(["systemctl", "--user", "disable", "--now", "parsar-0123456789-core.service"], self.host.commands) self.assertConverted() + # The installation keeps its own sandbox deployment, or its lack of one. + self.assertEqual(self.host.deployment_posts, []) def test_a_local_only_138_install_keeps_no_public_url(self): # The #138 deployment reports Core's loopback fallback; it is derived, not a public URL. diff --git a/deploy/install/test_install.py b/deploy/install/test_install.py index 5cc32f718..11cc8ece7 100644 --- a/deploy/install/test_install.py +++ b/deploy/install/test_install.py @@ -17,7 +17,10 @@ import config_model import distribution import install -from installer_fakes import MANIFEST, FakeHost, make_bundle, run_installer +import node_spec +from installer_fakes import MANIFEST, STANDARD_SIZES, FakeHost, make_bundle, run_installer + +BUILD = "base:0f6c1e8e-7d3a-4b8e-9a51-2b7f7f0c9d11" class InstallerTests(unittest.TestCase): @@ -88,7 +91,9 @@ def test_output_labels_public_and_local_addresses(self): "API base URL: http://127.0.0.1:8091/v1 (local only)\n", f"Next: sign in to Web with the Core key in {key}, then create a Project and its API key " "on the Projects and keys page.", - "Choose a sandbox backend and add nodes on the Nodes page in Web."]), + # Sandboxes call Core at public_url, so a loopback install has no nodes yet. + "\nNodes need an HTTPS public URL that other machines and their sandboxes can reach: " + "set public_url in {root}/config.json and run {root}/parsar apply, then add nodes.\n"]), "core-only": (["--core-only"], ["API base URL: http://127.0.0.1:8091/v1 (local only)\n", "Next: create a Project and its API key through the Core management API at " f"http://127.0.0.1:8091/core/v1 (local only) with the Core key in {key}."]), @@ -128,6 +133,12 @@ def test_retired_flags_and_old_layouts_name_their_replacement(self): with self.subTest(flag=flag), contextlib.redirect_stderr(output), self.assertRaises(SystemExit): install.arguments(["--install-dir", str(self.root), flag]) self.assertIn(f"{flag} is retired; run {self.root / command}", output.getvalue()) + for flags in (["--sandbox-provider"], ["--sandbox-provider", "true", "--provider", "docker"], ["--provider", "docker"]): + output = io.StringIO() + with self.subTest(flags=flags), contextlib.redirect_stderr(output), self.assertRaises(SystemExit): + install.arguments(flags) + self.assertIn("are retired: use --sandbox docker|microsandbox|e2b|none. The installer no longer adds this " + "host as a node", output.getvalue()) output = io.StringIO() with contextlib.redirect_stderr(output), self.assertRaises(SystemExit): install.arguments(["--admin-token-file", str(self.key_file())]) @@ -194,27 +205,69 @@ def test_native_core_migrates_before_enabling_its_generated_unit(self): self.assertEqual(set(self.document("generated/compose.json")["services"]), {"database", "web"}) self.assertTrue(self.host.native["active"]) - def test_local_provider_flags_and_enrollment(self): - for flags, message in ((("--provider", "docker"), "--provider requires"), - (("--web-only", "--sandbox-provider", "true"), "cannot install a sandbox provider"), - (("--sandbox-provider", "true", "--public-url", "http://localhost:8080"), "requires public_url with HTTPS")): + def test_a_new_installation_selects_docker_at_web_standard_size(self): + self.install("--public-url", "https://core.example") + standard = json.loads(STANDARD_SIZES.read_text()) + self.assertEqual(self.host.deployment_posts, [ + {"provider": "docker", "resources": standard["docker"], "runtime": node_spec.release(self.manifest)}]) + self.assertIn(f'\nSandboxes: Docker, Standard ({install.size(standard["docker"])}).\nAdd nodes: in Web, open ' + "Nodes and choose Add node, then paste the command on each host, this one included.\n", + self.output.getvalue()) + self.assertNotIn("sandbox", json.dumps(self.document("config.json"))) + # A repair never selects again. + self.host.deployment = {"provider": ""} + self.install() + self.assertEqual(len(self.host.deployment_posts), 1) + self.root = self.work / "microsandbox" + self.install("--core-only", "--sandbox", "microsandbox") + self.assertEqual(self.host.deployment_posts[-1]["resources"], standard["microsandbox"]) + self.root, self.output = self.work / "none", io.StringIO() + self.install("--sandbox", "none") + self.assertEqual(len(self.host.deployment_posts), 2) + self.assertIn("Sandboxes: none chosen. Choose a sandbox backend on the Nodes page in Web.", self.output.getvalue()) + + def test_e2b_needs_a_public_address_a_private_key_file_and_an_exact_build(self): + secret = "synthetic-e2b-key-0123456789" + key = self.key_file(secret) + for flags, message in (((), "E2B needs an HTTPS public_url that is not loopback"), + (("--public-url", "http://localhost:8080"), "E2B needs an HTTPS public_url"), + (("--public-url", "https://core.example", "--e2b-template", "base"), "template-id:build-uuid")): + with self.subTest(flags=flags), self.assertRaisesRegex(install.InstallError, message): + self.install("--sandbox", "e2b", "--e2b-api-key-file", key, "--e2b-template", BUILD, *flags) + self.assertFalse(self.root.exists()) + key.chmod(0o644) + with self.assertRaisesRegex(install.InstallError, "E2B API key file must be .* private regular file"): + self.install("--sandbox", "e2b", "--e2b-api-key-file", key, "--e2b-template", BUILD, + "--public-url", "https://core.example") + self.assertFalse(self.root.exists()) + key.chmod(0o600) + self.install("--sandbox", "e2b", "--e2b-api-key-file", key, "--e2b-template", BUILD, "--public-url", "https://core.example") + self.assertEqual(self.host.deployment_posts, [{"provider": "e2b", "e2b": {"api_key": secret, "template": BUILD}}]) + self.assertIn(f"Sandboxes: E2B template {BUILD} (2 CPUs, 2 GiB). E2B runs them; no nodes are needed.", + self.output.getvalue()) + self.assertNotIn(secret, self.output.getvalue() + (self.root / "config.json").read_text() + + (self.root / "state.json").read_text()) + + def test_sandbox_flag_errors_create_nothing(self): + for flags, message in ((("--web-only", "--core-key-file", self.key_file(), "--sandbox", "docker"), + "--web-only has no Core; choose the sandbox backend on the Core host"), + (("--sandbox", "e2b"), "requires --e2b-api-key-file and --e2b-template"), + (("--e2b-template", BUILD), "require --sandbox e2b")): with self.subTest(flags=flags), self.assertRaisesRegex(install.InstallError, message): self.install(*flags) self.assertFalse(self.root.exists()) - # A first start that fails leaves the local node to the repair run, which takes no flags. - self.host.core["fails"] = True - with mock.patch.object(install.local_node, "install") as enroll, \ - self.assertRaisesRegex(install.parsar_cli.ParsarError, f"rerun ./install.sh --install-dir {self.root}$"): - self.install("--sandbox-provider", "true", "--provider", "docker", "--public-url", "https://core.example") - enroll.assert_not_called() - self.assertEqual(self.document("state.json")["local_node"], "docker") - self.host.core["fails"] = False - with mock.patch.object(install.local_node, "install") as enroll: - self.install() - state = enroll.call_args.args[1] - self.assertEqual((state["provider"], state["core_port"], state["public_url"]), ("docker", 8091, "https://core.example")) - self.assertNotIn("provider", self.document("config.json")) - self.assertIsNone(self.document("state.json")["local_node"]) + + def test_a_refused_selection_leaves_the_services_running(self): + secret = "synthetic-e2b-key-0123456789" + self.host.deployment_refusal = f"E2B rejected the API key {secret}." + with self.assertRaises(install.InstallError) as raised: + self.install("--sandbox", "e2b", "--e2b-api-key-file", self.key_file(secret), "--e2b-template", BUILD, + "--public-url", "https://core.example") + self.assertEqual(str(raised.exception), "Core refused the sandbox setup: E2B rejected the API key [E2B API key]. " + "Services are installed and running; choose the sandbox backend on the Nodes page in Web") + self.assertEqual(self.host.running(), {"database", "core", "web"}) + self.assertIn("Console: https://core.example\n", self.output.getvalue()) + self.assertNotIn("Sandboxes:", self.output.getvalue()) def test_node_payload_exports_only_matched_distribution_files(self): self.install() diff --git a/deploy/install/test_local_node.py b/deploy/install/test_local_node.py deleted file mode 100644 index 86ecfa3ab..000000000 --- a/deploy/install/test_local_node.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Verify opt-in installation uses one admin deployment and ordinary enrollment.""" -import json -from pathlib import Path -import tempfile -import unittest -from unittest import mock - -import local_node - - -class LocalNodeTests(unittest.TestCase): - def setUp(self): - base = Path.home() / ".parsar/tests/local-node" - base.mkdir(parents=True, exist_ok=True) - temporary = tempfile.TemporaryDirectory(dir=base) - self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) - (self.root / "secrets").mkdir() - (self.root / "secrets/core.key").write_text("fixture-admin") - self.state = {"core_port": 8091, "installation_id": "94be54a1-138c-4f30-bc87-b13686272dbe", - "provider": "microsandbox", "public_url": "https://core.example"} - self.manifest = {"source_commit": "a" * 40, "images": {"runtime": "sha256:" + "b" * 64}, - "image_manifest_digests": {"runtime": "sha256:" + "c" * 64}, - "runtime_ref": "parsar-core-runtime@sha256:" + "d" * 64, - "microsandbox": {"runtime_sha256": "e" * 64, "firmware_sha256": "f" * 64}} - self.current = {"installation_id": self.state["installation_id"], "provider": self.state["provider"], - "core_url": self.state["public_url"]} - self.run = mock.Mock() - - def test_first_install_saves_spec_and_launches_ordinary_node_without_secret_arguments(self): - # Web's sandbox setup proposes the same initial sizes. - for provider, resources in (("docker", {"cpus": 2, "memory_mib": 2048}), - ("microsandbox", {"cpus": 2, "memory_mib": 4096, "root_disk_mib": 8192, - "environment_disk_mib": 8192})): - with self.subTest(provider=provider): - self.run.reset_mock() - state, current = dict(self.state, provider=provider), dict(self.current, provider=provider) - empty = dict(current, provider="") - with mock.patch.object(local_node, "request", side_effect=[empty, current, {"token": "one-time"}]) as request, \ - mock.patch.object(local_node.Path, "home", return_value=self.root): - local_node.install(self.root, state, self.manifest, self.root / "bundle", self.run) - self.assertEqual([(call.args[2], call.args[3]) for call in request.call_args_list], - [("GET", "deployment"), ("POST", "deployment"), ("POST", "enrollment-tokens")]) - setup = request.call_args_list[1].args[4] - self.assertEqual(setup["resources"], resources) - self.assertEqual(setup["runtime"]["image_id"], self.manifest["images"]["runtime"]) - self.assertNotIn("core_url", setup) # Core derives it from its public URL. - self.assertNotIn("one-time", str(self.run.call_args.args)) - self.assertEqual(self.run.call_args.kwargs["env"]["PARSAR_NODE_ENROLLMENT_TOKEN"], "one-time") - self.assertIn("--bundle", self.run.call_args.args[0]) - - def test_repeat_never_overwrites_admin_configuration_or_reenrolls(self): - marker = self.root / ".parsar/nodes" / self.state["installation_id"] / "registered.json" - marker.parent.mkdir(parents=True) - marker.write_text("{}") - current = dict(self.current, specification={"resources": {"cpus": 8, "memory_mib": 16384}}) - with mock.patch.object(local_node, "request", return_value=current) as request, \ - mock.patch.object(local_node.Path, "home", return_value=self.root): - local_node.install(self.root, self.state, self.manifest, self.root / "bundle", self.run) - self.assertEqual(request.call_count, 1) - self.assertEqual(request.call_args.args[2], "GET") - self.assertNotIn("PARSAR_NODE_ENROLLMENT_TOKEN", self.run.call_args.kwargs["env"]) - - def test_changed_selection_does_not_initialize_enroll_or_start_service(self): - with mock.patch.object(local_node, "request", return_value=dict(self.current, provider="docker")) as request: - with self.assertRaisesRegex(local_node.LocalNodeError, "selection differs"): - local_node.install(self.root, self.state, self.manifest, self.root / "bundle", self.run) - self.assertEqual(request.call_count, 1) - self.run.assert_not_called() - - -if __name__ == "__main__": - unittest.main() diff --git a/deploy/install/test_sandbox_setup.py b/deploy/install/test_sandbox_setup.py new file mode 100644 index 000000000..5a8721dcc --- /dev/null +++ b/deploy/install/test_sandbox_setup.py @@ -0,0 +1,54 @@ +"""sandbox_setup: Core's existing selection wins, and E2B template builds follow Core's rule.""" +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +import sandbox_setup + +INSTALLATION = "94be54a1-138c-4f30-bc87-b13686272dbe" + + +class SandboxSetupTests(unittest.TestCase): + def setUp(self): + base = Path.home() / ".parsar/tests/sandbox-setup" + base.mkdir(parents=True, exist_ok=True) + temporary = tempfile.TemporaryDirectory(dir=base) + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + (self.root / "secrets").mkdir() + (self.root / "secrets/core.key").write_text("fixture-core-key\n") + self.config, self.state = {"ports": {"core": 8091}}, {"installation_id": INSTALLATION} + + def initialize(self, current, selection): + requests = [] + + def send(req): + requests.append((req.get_method(), req.full_url, req.get_header("Authorization"))) + return 200, json.dumps(current).encode() + with mock.patch.object(sandbox_setup, "send", side_effect=send): + result = sandbox_setup.initialize(self.root, self.config, self.state, selection) + return result, requests + + def test_an_existing_selection_is_kept_or_reported(self): + docker = {"provider": "docker", "resources": {"cpus": 2, "memory_mib": 2048}, "runtime": {}} + current = {"installation_id": INSTALLATION, "provider": "docker"} + self.assertEqual(self.initialize(current, docker), (current, [ + ("GET", "http://127.0.0.1:8091/core/v1/sandbox/deployment", "Bearer fixture-core-key")])) + with self.assertRaisesRegex(sandbox_setup.SandboxSetupError, "already uses microsandbox"): + self.initialize(dict(current, provider="microsandbox"), docker) + with self.assertRaisesRegex(sandbox_setup.SandboxSetupError, "different installation"): + self.initialize(dict(current, installation_id="other", provider=""), docker) + + def test_e2b_template_is_an_exact_build(self): + build = "0f6c1e8e-7d3a-4b8e-9a51-2b7f7f0c9d11" + for value in ("base:" + build, "my_template-2:" + build): + self.assertTrue(sandbox_setup.e2b_template(value), value) + for value in ("base", "base:", ":" + build, "base:" + build.upper(), "base:" + build.replace("-", ""), + "base:00000000-0000-0000-0000-000000000000", "ba.se:" + build, "x" * 129 + ":" + build): + self.assertFalse(sandbox_setup.e2b_template(value), value) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/configuration.md b/docs/configuration.md index d6af46200..da77a98c5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -177,21 +177,26 @@ See [model execution](../contracts/agents-api/model-execution.md#deployment-defa ## Database-owned deployment -Choose exactly one provider through Web or `/core/v1/sandbox/deployment`: -Docker, microsandbox or E2B. Core packaging is independent: `--native-core` does -not select microsandbox or install a local execution node. +Exactly one provider serves the deployment: Docker, microsandbox or E2B. A new +installation selects it once with `install.sh --sandbox` (Docker unless chosen +otherwise, none with `--web-only`); afterwards Web or `/core/v1/sandbox/deployment` +change it. Core packaging is independent: `--native-core` does not select +microsandbox or add an execution node. Setup requests supply `resources` and, for Docker/microsandbox, the matching `runtime` release. Core saves them as `specification` with a digest and generation. -Web proposes defaults from its distribution; PostgreSQL owns the submitted values. +Web's setup and the installer propose the Standard size from Web's +`apps/web/src/features/sandbox/standard-sizes.json` (bundled as +`standard-sizes.json`); Web derives Small and Large from it. PostgreSQL owns the +submitted values. See the [deployment contract](../contracts/agents-api/sandbox-deployment.md) for request shapes, immutable identities and provider validation. -| Setting | Initial Web proposal | Meaning | +| Setting | Standard | Meaning | | --- | --- | --- | -| `resources.cpus` | `2` | Integer vCPUs per sandbox, 1 through 255 | -| `resources.memory_mib` | Docker `2048`, microsandbox `4096` | MiB per sandbox, 512 through 1048576 | -| `resources.root_disk_mib`, `environment_disk_mib` | Microsandbox `8192` each | At least 1024 MiB; omitted for Docker/E2B | +| `resources.cpus` | Per provider in `standard-sizes.json` | Integer vCPUs per sandbox, 1 through 255 | +| `resources.memory_mib` | Per provider in `standard-sizes.json` | MiB per sandbox, 512 through 1048576 | +| `resources.root_disk_mib`, `environment_disk_mib` | Microsandbox only, in `standard-sizes.json` | At least 1024 MiB; omitted for Docker/E2B | | `runtime` | Matching distribution manifest | Source revision, exact image ID/manifest/ref and runtime/firmware hashes; no mutable tags | | Idle interval / snapshot retention | `300` / `86400` seconds | Database-owned microsandbox policy; no node-file override | @@ -256,7 +261,8 @@ an execution configuration source. Retain credentials, native history, private receipts and files independently of process configuration. The old `AGENTS_API_MANAGED_RUNTIMES_FILE`, embedded Core node and local-node Core -environment overrides are retired and rejected. Installation may connect an -explicitly requested local node through the same administrator API and registration -used for remote hosts. Provider flags are one-time setup inputs, never a persisted -override. No old file-managed deployment is migrated or adopted. +environment overrides are retired and rejected. Installation selects the sandbox +backend through the same administrator API as Web and never adds a node; every host, +the Core host included, joins through Web's Add node. `--sandbox` is a one-time +setup input, never a persisted override. No old file-managed deployment is migrated +or adopted. diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index 921a77469..6f4d5edbd 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -12,10 +12,10 @@ Runtime contract. The Parsar product is not required. - [Protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/main/contracts/agents-api/README.md) Core and Web ship together. The default installation runs Core, Web and PostgreSQL -with zero execution nodes. To prepare a local sandbox provider, install with -`--sandbox-provider true --provider microsandbox --public-url https://core.example` or -`--sandbox-provider true --provider docker --public-url https://core.example`, using -your HTTPS address. Core then creates the execution sandbox +and selects Docker sandboxes, with zero execution nodes; `--sandbox microsandbox`, +`--sandbox e2b` or `--sandbox none` choose otherwise. Add nodes, the Core host +included, with **Add node** on the Nodes page in Web; nodes need an HTTPS public URL +such as `--public-url https://core.example`. Core then creates the execution sandbox when a Session needs it. Installing the service does not require a model key or run a model request. diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 4152b970a..78eb21838 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -1,11 +1,12 @@ # Install Core and Web Install one matching Parsar Core distribution. By default it starts PostgreSQL, -Core and the existing Web console in containers, with zero execution nodes. +Core and the existing Web console in containers, then selects Docker sandboxes at +Web's Standard size through the administrator API, with zero execution nodes. It does not import Runtime images, mount the Docker socket or host devices into -Core, or generate a managed Provider configuration. Select the provider, per-sandbox -resources and immutable Runtime through Web or the administrator API after installation. A local node is an optional installation -choice and uses the same database-managed configuration. +Core, or add this host as a node. [`--sandbox`](#installation-choices) selects +microsandbox, E2B or no backend instead; afterwards, change the provider, +per-sandbox resources and immutable Runtime through Web or the administrator API. No model key, Environment wizard or sample task is required during installation. See [Configuration](../configuration.md) for setting ownership, paths, defaults, units, change restrictions and restart behavior. @@ -20,9 +21,8 @@ You can leave the deployment with zero nodes until you need execution. The first distribution targets Linux amd64 with Python 3.9+, Docker and Docker Compose 2.26.0 or newer. Run the installer as a non-root user who can use Docker. The default installation requires neither KVM nor systemd user services. -The optional local provider has additional requirements described under -[installation choices](#installation-choices). Node-host requirements are listed -in the [add-node steps](#add-nodes-after-a-default-installation). +Node-host requirements are listed in the +[add-node steps](#add-nodes-after-a-default-installation). ## Verify, extract and install @@ -41,7 +41,7 @@ your DNS/TLS reverse proxy as described in [Expose Core and Web](#expose-core-an and pass that address on the first install. The installer does not create DNS records or certificates. You can set or change the public URL later in `config.json` (see [Change settings](#change-settings-after-installation)). The command below -still installs zero execution nodes. +selects Docker sandboxes and still installs zero execution nodes. ```sh sha256sum -c parsar-core--linux-amd64.tar.gz.sha256 @@ -130,7 +130,12 @@ Choose English or Chinese through the System language selector. 1. Log in to the bundled Web console and open **Nodes**. The paired installation needs no second key or Core connection setup. -2. Choose **E2B cloud** or **Own machines**. Own machines use Docker or + The installer already saved the sandbox backend (Docker at the Standard size, + unless [`--sandbox`](#installation-choices) chose otherwise), so continue with + step 4. Nodes need an HTTPS public URL that other machines and their sandboxes + can reach; on a loopback installation, set `public_url` first. +2. After `--sandbox none`, or when the installer could not save the selection, + choose **E2B cloud** or **Own machines**. Own machines use Docker or microsandbox; select their per-sandbox resources and matched immutable Runtime release. E2B uses an account key and an exact ready template build, with no node installation; each sandbox gets the build's CPU and memory, so setup asks for no @@ -258,52 +263,58 @@ container. ## Installation choices ```sh -./install.sh --sandbox-provider true --provider microsandbox --public-url https://core.example -./install.sh --sandbox-provider true --provider docker --public-url https://core.example +./install.sh --sandbox microsandbox --public-url https://core.example +./install.sh --sandbox e2b --e2b-api-key-file "$HOME/e2b-api-key" \ + --e2b-template ':' --public-url https://core.example +./install.sh --sandbox none ./install.sh --core-only ./install.sh --native-core --public-url https://core.example -./install.sh --core-only --sandbox-provider true --provider docker --public-url https://core.example ``` -`--sandbox-provider true` installs a local node through the ordinary node installer, using the node assets in -the bundle: use the offline bundle, or place the release assets in its `artifacts/` directory. If `--provider` is -omitted, it selects microsandbox. Supplying `--provider` without enabling the -sandbox provider is an error. `--core-only` installs Core and PostgreSQL without -Web and has no sandbox provider unless explicitly enabled. - -Local opt-in requires a non-loopback HTTPS `--public-url` reachable from both the -node service and its guests. Set up the reverse proxy before installation; the -installer does not create DNS or certificates. It starts Core, initializes an empty -deployment through the administrator API and invokes the ordinary node installer. -An existing database specification is never replaced by installer defaults. +`--sandbox` selects the sandbox backend once the services are healthy, through the +same administrator API as Web's setup: `docker` (the default), `microsandbox`, `e2b` +or `none`, the only choice with `--web-only`. Docker and microsandbox get Web's +Standard size, read from the bundle's copy of +`apps/web/src/features/sandbox/standard-sizes.json`, and the bundle's Runtime +release. E2B takes its account key from a private file of at most 4 KiB +(`--e2b-api-key-file`) and a ready template build (`--e2b-template +template-id:build-uuid`); each sandbox gets the build's CPU and memory. E2B needs +an HTTPS `--public-url` that is not loopback; otherwise the installer refuses it +before installing anything. A loopback installation keeps a Docker or microsandbox +selection, but nodes can't serve it: each sandbox calls Core at the public URL, and +a loopback address reaches only the sandbox itself. Set `public_url` before adding +nodes. + +These flags are one-time install actions, not settings: `config.json` doesn't hold +them, and PostgreSQL owns the saved selection. Change it later in Web, through +maintenance. If Core refuses the selection, for example because E2B rejects the key, +the installer prints Core's message and exits with an error; the services keep +running, so choose the backend on the Nodes page. A repair run or a conversion +never selects a backend. + +The installer never adds this host as a node: add it like any other host, with +**Add node** in Web. `--sandbox-provider` and `--provider` are retired and fail. +`--core-only` installs Core and PostgreSQL without Web. The provider choice does not select a harness or alter the public `openai_hosted` -discriminator. Both providers use the same colocated Runtime. Core has no embedded +discriminator. Both node providers use the same colocated Runtime. Core has no embedded node or file-managed provider selection. Docker access and microsandbox KVM/native paths belong to the separate node service; the Core container receives no Docker socket or node state mount. Web receives neither provider authority nor node secrets. -Local and remote nodes keep configuration and identity under +Nodes keep configuration and identity under `~/.parsar/nodes//`. Microsandbox stores its private Runtime home under `~/.parsar/m//`. Preserve these directories and backend storage across restarts. A missing identity is a recovery incident, not permission to register over existing resources. -### Local provider requirements - -Both node providers require a systemd user session with lingering enabled. -Docker requires access to the host's Unix socket. Microsandbox additionally requires -glibc, the matched native libraries and user read/write access to `/dev/kvm`; -nested cloud hosts must expose hardware virtualization. The installer checks these -prerequisites without granting permissions or falling back to another provider. - -`--native-core` selects a native Core user service independently of the provider; +`--native-core` selects a native Core user service independently of the sandbox backend; PostgreSQL/Web stay in containers. Native Core itself requires no KVM or node Runtime files. Without this flag, Core stays in Compose. The standalone node service owns provider processes separately from Core. Stopping Core does not stop the node or prove its resources have been reclaimed. -See [Configuration](../configuration.md) for initial per-sandbox resources, +See [Configuration](../configuration.md) for the Standard per-sandbox resources, administrator-approved node capacity and the five-minute idle suspension policy. Change saved resources or Runtime only through the [drained deployment procedure](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-maintenance). @@ -322,8 +333,9 @@ a remote Core must use HTTPS. --core-key-file "$HOME/.parsar/core/secrets/core.key" ``` -Web-only mode cannot enable a sandbox provider. It starts no database or Core and -requires no KVM. Its key remains on the server, outside the static Web files. +Web-only mode starts no database or Core, so it selects no sandbox backend (choose +it on the Core host), and requires no KVM. Its key remains on the server, outside +the static Web files. The input file must be private (0600) and contain a Core key of at least 32 characters. The Web-only installation copies the key into its own `secrets/core.key`. After a @@ -354,7 +366,7 @@ Rerunning `./install.sh` on an existing installation reads `config.json` and rejects every flag except `--install-dir`. Without flags it repairs the installation: it reloads missing images, restores a missing `parsar` command, applies `config.json` and starts the services. It refuses a bundle from another -release. `--sandbox-provider` and `--provider` apply only to a new installation. +release. `--sandbox` and the E2B flags apply only to a new installation. ## Convert an earlier installation diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index e13a3ca15..d4b814718 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -1,10 +1,9 @@ # Operate your Core The installation operator owns the host, storage and service availability. -The default installation has zero execution nodes. After nodes are added in Web, -their operators maintain the node services, containers or microVMs on those hosts. -A local node requested during installation runs as the same separate node service -on the Core host. Service +The default installation selects Docker sandboxes and has zero execution nodes. +After nodes are added in Web, the Core host included, their operators maintain the +node services, containers or microVMs on those hosts. Service health and provider state are separate from a Session's public execution state. Use [Configuration](../configuration.md) for the authoritative setting locations, @@ -199,14 +198,12 @@ Retain together: directory on its host (`~/.parsar/nodes//` for the Web-generated installer), as described in the [node guide](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host); -- the same `~/.parsar/nodes//` identity for a local node, including - its credential, generation, specification digest and highest accepted owner epoch; - E2B's private `state/e2b` receipts and SDK connection materials when selected; - microsandbox's private state/cache/disks/snapshots, or Docker-owned Runtime volumes and histories; - the exact distribution and private deployment configuration needed to recover. -Do not replace a missing local node identity directory with a fresh registration; +Do not replace a missing node identity directory with a fresh registration; restore its original saved state alongside the database and provider storage. Restarting the same installation preserves the directory. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 1ccb98b6f..f8eb33f7a 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -46,10 +46,10 @@ environment. Installation has no mandatory sample task. ## Run a Session when you are ready This example requires a connected node whose provider is ready. The default -installation has zero execution nodes: sign in to Web and -[add a Docker or microsandbox node](install.md#add-nodes-after-a-default-installation). -A local provider enabled during installation also satisfies this requirement. -You do not need to reinstall Core or change installer flags to add nodes in Web. +installation selects Docker sandboxes and has zero execution nodes: sign in to Web +and [add a node](install.md#add-nodes-after-a-default-installation), the Core host +included. You do not need to reinstall Core or change installer flags to add nodes +in Web. Core creates the sandbox through its Provider using the prepared Runtime image, then initializes the daemon, native harness and workspace inside it. You do not diff --git a/scripts/build-core-distribution.sh b/scripts/build-core-distribution.sh index 6a2785f52..4f19d3388 100755 --- a/scripts/build-core-distribution.sh +++ b/scripts/build-core-distribution.sh @@ -99,10 +99,12 @@ if [[ "$(go env GOVERSION)" != "$required_go" ]]; then exit 1 fi for file in install.sh install.py configuration.py config_model.py config.schema.json parsar_cli.py convert.py \ - native_service.py node_install.py node_spec.py local_node.py distribution.py self_hosted_install.py \ + native_service.py node_install.py node_spec.py sandbox_setup.py distribution.py self_hosted_install.py \ model_provider_sessions.py; do cp "deploy/install/$file" "$bundle/$file" done +# Web owns the Standard sandbox sizes; the installer's first deployment uses this copy. +cp apps/web/src/features/sandbox/standard-sizes.json "$bundle/standard-sizes.json" python3 scripts/core-distribution-manifest.py bootstraps "$bundle" "$source_epoch" mkdir -p "$bundle/docs" cp -R docs/getting-started "$bundle/docs/" diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index ca22e383f..14f147824 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -215,7 +215,7 @@ def package_artifacts(bundle, stage, revision): # The installation's management command; it runs without the bundle directory. PARSAR_MODULES = ("parsar_cli.py", "config_model.py", "config.schema.json", "configuration.py", - "native_service.py", "distribution.py", "local_node.py", "node_spec.py") + "native_service.py", "distribution.py", "node_spec.py") def bootstraps(bundle, epoch): diff --git a/services/agents-api/HOSTED-SANDBOX-MANAGER.md b/services/agents-api/HOSTED-SANDBOX-MANAGER.md index cfa735a3e..3af790337 100644 --- a/services/agents-api/HOSTED-SANDBOX-MANAGER.md +++ b/services/agents-api/HOSTED-SANDBOX-MANAGER.md @@ -34,8 +34,10 @@ node installation payload. Project keys cannot register, edit or remove nodes. ## Start with zero nodes -Default installation starts Core, Web and PostgreSQL without local compute. -Setup on the **Nodes** page first asks for **E2B cloud** or **Own machines**. Own +Default installation starts Core, Web and PostgreSQL without local compute, and +selects Docker sandboxes at Web's Standard size through the route below; +`install.sh --sandbox` chooses microsandbox, E2B or none instead. Without a +selection, setup on the **Nodes** page first asks for **E2B cloud** or **Own machines**. Own machines then choose Docker or microsandbox. Supply the per-sandbox resources and matched Runtime release as part of initial setup. E2B takes an account API key and a qualified immutable Runtime template build (`template-id:build-uuid`) that must be @@ -123,13 +125,12 @@ E2B selects its immutable Runtime through the template build instead. See the validation and safe response shapes. The response's `specification.resources` contains limits; its top-level `resources` contains cleanup counts. -An explicitly requested local node uses the ordinary node installer and service, -with the same database selection and registration checks as a remote node. Local -means the Core host, not the browser. The installation option requires a -non-loopback HTTPS `--public-url` reachable from sandbox guests. Node identity and -configuration live under `~/.parsar/nodes//`; preserve that private -directory and its backend storage together. Core has no embedded local provider -configuration or node identity mount. +The Core installer never enrolls its own host. The Core host joins like any other +host, through **Add node**, with the same database selection and registration +checks. Node identity and configuration live under +`~/.parsar/nodes//`; preserve that private directory and its +backend storage together. Core has no embedded local provider configuration or node +identity mount. ## Older file-managed installations @@ -276,8 +277,7 @@ The node program then exits with status 78 when Core answers 401 to its credenti and the installed service does not restart it. Other failures, including an unreachable Core or a 403 from a proxy in front of it, restart the service every 5 seconds without a start limit. -Ordinary disconnects and host restarts reuse the original identity. A local node -uses the same removal and resource checks as any other enrolled node. +Ordinary disconnects and host restarts reuse the original identity. Provider, resource-limit and Runtime changes share one deployment-wide procedure: From 92cd1480c4c33e85538c69b99d6c41ef06330edb Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 26 Sep 2026 02:25:40 +0000 Subject: [PATCH 2/2] Say when a failed first start drops --sandbox, and harden the private key file read A new installation whose first start fails now ends its error with "The sandbox backend was not chosen; after the repair, choose it ...", because the repair run never selects a backend. Private key files (the E2B API key and the Core key) are opened with O_NOFOLLOW and O_NONBLOCK, checked with fstat and read up to the 4 KiB limit plus one byte. The bundled standard-sizes.json must have exactly each provider's fields. Docs: CONTRIBUTING and the microsandbox guide no longer mention local-node flags; the install guide states the E2B key file rules. Tests cover a symlinked or 5000-byte E2B key file and a checksum list without standard-sizes.json. --- CONTRIBUTING.md | 2 +- deploy/install/install.py | 50 +++++++++++++------ deploy/install/sandbox_setup.py | 6 ++- deploy/install/test_install.py | 32 +++++++++--- docs/getting-started/install.md | 5 +- .../agents-api/deploy/microsandbox/README.md | 13 ++--- 6 files changed, 75 insertions(+), 33 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 685e0d57e..2cdd53c4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1730,7 +1730,7 @@ environment and has no config loader; it serves the non-secret snapshot at (`config_model.py`), the generator and the generated reference table in `docs/configuration.md` (`scripts/config-reference.py`) in step. Do not add a second operator configuration file, loader precedence, hot reload, compatibility -reading of retired names, or an embedded Core node. Explicit local-node flags call +reading of retired names, or an embedded Core node. `install.sh --sandbox` calls the ordinary administrator API once; PostgreSQL owns the resulting selection. Administrator-issued enrollment approves capacity (default two active/eight retained); a node cannot supply or overwrite those limits. Downloaded specification diff --git a/deploy/install/install.py b/deploy/install/install.py index 5bfa23d14..adc60622d 100644 --- a/deploy/install/install.py +++ b/deploy/install/install.py @@ -233,17 +233,26 @@ def check_public_url(config, choice): "in the --config file)") -def read_private_file(source, name): - """The one token in an absolute, private regular file of at most 4 KiB.""" +def read_private_file(source, name, limit=4096): + """The one token in an absolute, private regular file of at most 4 KiB, never through a symlink.""" + refused = InstallError(f"{name} must be an absolute, private regular file of at most 4 KiB") + if not source.is_absolute(): + raise refused try: - info = source.lstat() + # O_NONBLOCK: a FIFO in its place must not hang the installer. + descriptor = os.open(source, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) except OSError: - raise InstallError(f"{name} must be an absolute, private regular file") from None - if (not source.is_absolute() or not stat.S_ISREG(info.st_mode) - or stat.S_IMODE(info.st_mode) & 0o077 or info.st_size > 4096): - raise InstallError(f"{name} must be an absolute, private regular file") - token = source.read_text().strip() - if not token or any(c.isspace() for c in token) or "\x00" in token: + raise refused from None + with os.fdopen(descriptor, "rb") as stream: + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) & 0o077 or info.st_size > limit: + raise refused + raw = stream.read(limit + 1) + try: + token = raw.decode().strip() + except UnicodeDecodeError: + token = "" + if len(raw) > limit or not token or any(c.isspace() for c in token) or "\x00" in token: raise InstallError("Invalid " + name) return token @@ -399,7 +408,14 @@ def finish(root, bundle, manifest, fresh=False, selection=None): native_service.prepare(root, state, bundle, converting) install_parsar(root, bundle) retry = f"rerun ./install.sh {'--convert ' if converting else ''}--install-dir {root}" - parsar_cli.apply(root, start=True, retry=retry) + try: + parsar_cli.apply(root, start=True, retry=retry) + except parsar_cli.ParsarError as error: + if not selection: + raise + # A repair never selects a backend, so the --sandbox choice would otherwise be lost silently. + raise parsar_cli.ParsarError(f"{str(error).rstrip('.')}. The sandbox backend was not chosen; after the " + f"repair, choose it {choose_where(state['mode'])}") from None config = parsar_cli.load_config(root) mode = config["mode"] # An earlier-release Core has no /core/v1/installation (404); apply noted it and Web still works. @@ -417,10 +433,14 @@ def finish(root, bundle, manifest, fresh=False, selection=None): parsar_cli.save_state(root, state) summary(root, config, fresh, selection, deployment) if failure: - where = ("on the Nodes page in Web" if mode == "all" else - "through the Core management API (POST /core/v1/sandbox/deployment)") raise InstallError(f"{str(failure).rstrip('.')}. Services are installed and running; " - f"choose the sandbox backend {where}") + f"choose the sandbox backend {choose_where(mode)}") + + +def choose_where(mode): + """Where an operator chooses the sandbox backend when the installer did not.""" + return ("on the Nodes page in Web" if mode == "all" else + "through the Core management API (POST /core/v1/sandbox/deployment)") def size(resources): @@ -432,9 +452,7 @@ def sandbox_lines(root, config, selection, deployment): """What a new installation's sandboxes are and how nodes are added.""" web = config["mode"] == "all" if selection is None: - return ["Sandboxes: none chosen. Choose a sandbox backend " + ( - "on the Nodes page in Web." if web else - "through the Core management API (POST /core/v1/sandbox/deployment).")] + return [f"Sandboxes: none chosen. Choose a sandbox backend {choose_where(config['mode'])}."] if deployment is None: return [] # The error that follows says what to do. if selection["provider"] == "e2b": diff --git a/deploy/install/sandbox_setup.py b/deploy/install/sandbox_setup.py index 9ccfc768a..936b0c20d 100644 --- a/deploy/install/sandbox_setup.py +++ b/deploy/install/sandbox_setup.py @@ -16,6 +16,9 @@ CHOICES = ("docker", "microsandbox", "e2b", "none") NAMES = {"docker": "Docker", "microsandbox": "microsandbox", "e2b": "E2B"} +# The fields of each provider's size in Web's standard-sizes.json (standard-sizes.md). +SIZE_FIELDS = {"docker": {"cpus", "memory_mib"}, + "microsandbox": {"cpus", "memory_mib", "root_disk_mib", "environment_disk_mib"}} class SandboxSetupError(Exception): @@ -91,7 +94,8 @@ def selection(bundle, manifest, choice, e2b=None): resources = json.loads((bundle / "standard-sizes.json").read_text())[choice] except (OSError, ValueError, KeyError, TypeError): resources = None - if not isinstance(resources, dict) or not all(type(value) is int for value in resources.values()): + if (not isinstance(resources, dict) or set(resources) != SIZE_FIELDS[choice] + or not all(type(value) is int for value in resources.values())): raise SandboxSetupError("The bundle's standard-sizes.json is invalid") return {"provider": choice, "resources": resources, "runtime": node_spec.release(manifest)} diff --git a/deploy/install/test_install.py b/deploy/install/test_install.py index 11cc8ece7..21bf4a36f 100644 --- a/deploy/install/test_install.py +++ b/deploy/install/test_install.py @@ -235,11 +235,18 @@ def test_e2b_needs_a_public_address_a_private_key_file_and_an_exact_build(self): with self.subTest(flags=flags), self.assertRaisesRegex(install.InstallError, message): self.install("--sandbox", "e2b", "--e2b-api-key-file", key, "--e2b-template", BUILD, *flags) self.assertFalse(self.root.exists()) + link = self.work / "linked-e2b-key" + link.symlink_to(key) + large = self.work / "large-e2b-key" + large.write_text("x" * 5000) + large.chmod(0o600) key.chmod(0o644) - with self.assertRaisesRegex(install.InstallError, "E2B API key file must be .* private regular file"): - self.install("--sandbox", "e2b", "--e2b-api-key-file", key, "--e2b-template", BUILD, - "--public-url", "https://core.example") - self.assertFalse(self.root.exists()) + for source in (key, link, large): + with self.subTest(source=source), \ + self.assertRaisesRegex(install.InstallError, "E2B API key file must be .* private regular file"): + self.install("--sandbox", "e2b", "--e2b-api-key-file", source, "--e2b-template", BUILD, + "--public-url", "https://core.example") + self.assertFalse(self.root.exists()) key.chmod(0o600) self.install("--sandbox", "e2b", "--e2b-api-key-file", key, "--e2b-template", BUILD, "--public-url", "https://core.example") self.assertEqual(self.host.deployment_posts, [{"provider": "e2b", "e2b": {"api_key": secret, "template": BUILD}}]) @@ -257,6 +264,16 @@ def test_sandbox_flag_errors_create_nothing(self): self.install(*flags) self.assertFalse(self.root.exists()) + def test_a_failed_first_start_says_the_sandbox_backend_was_not_chosen(self): + self.host.core["fails"] = True + with self.assertRaisesRegex(install.parsar_cli.ParsarError, + f"rerun ./install.sh --install-dir {self.root}. The sandbox backend was not chosen; " + "after the repair, choose it on the Nodes page in Web$"): + self.install("--sandbox", "microsandbox") + self.host.core["fails"] = False + self.install() + self.assertEqual(self.host.deployment_posts, []) + def test_a_refused_selection_leaves_the_services_running(self): secret = "synthetic-e2b-key-0123456789" self.host.deployment_refusal = f"E2B rejected the API key {secret}." @@ -291,9 +308,10 @@ def test_bundle_verifies_transferred_bytes_and_checksum_list(self): path.write_bytes(original) checksums = self.bundle / "SHA256SUMS" original = checksums.read_text() - checksums.write_text("".join(line + "\n" for line in original.splitlines() if not line.endswith(" parsar.pyz"))) - with self.assertRaisesRegex(install.InstallError, "incomplete"): - install.verify_bundle(self.bundle) + for name in ("parsar.pyz", "standard-sizes.json"): + checksums.write_text("".join(line + "\n" for line in original.splitlines() if not line.endswith(" " + name))) + with self.subTest(name=name), self.assertRaisesRegex(install.InstallError, "incomplete"): + install.verify_bundle(self.bundle) checksums.write_text(original + original.splitlines()[0] + "\n") with self.assertRaisesRegex(install.InstallError, "Duplicate"): install.verify_bundle(self.bundle) diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 78eb21838..d890f303c 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -276,8 +276,9 @@ same administrator API as Web's setup: `docker` (the default), `microsandbox`, ` or `none`, the only choice with `--web-only`. Docker and microsandbox get Web's Standard size, read from the bundle's copy of `apps/web/src/features/sandbox/standard-sizes.json`, and the bundle's Runtime -release. E2B takes its account key from a private file of at most 4 KiB -(`--e2b-api-key-file`) and a ready template build (`--e2b-template +release. E2B takes its account key from `--e2b-api-key-file`: the absolute path of a +regular file, not a symlink, of at most 4 KiB, with no group or other access (for +example mode 0600). It also takes a ready template build (`--e2b-template template-id:build-uuid`); each sandbox gets the build's CPU and memory. E2B needs an HTTPS `--public-url` that is not loopback; otherwise the installer refuses it before installing anything. A loopback installation keeps a Docker or microsandbox diff --git a/services/agents-api/deploy/microsandbox/README.md b/services/agents-api/deploy/microsandbox/README.md index eb560acad..d12cf58eb 100644 --- a/services/agents-api/deploy/microsandbox/README.md +++ b/services/agents-api/deploy/microsandbox/README.md @@ -104,12 +104,13 @@ commands or initial file writes. ## Deployment and node configuration -Initialize microsandbox through Web or `POST /core/v1/sandbox/deployment`, including -its required `resources` and immutable `runtime` fields. The standalone node -installer reads `GET /api/v1/sandbox-node/configuration` using an enrollment token, -or its retained node credential on a registered reinstall. It verifies the saved -release and resources before registration. Local opt-in uses this same path and -requires a guest-reachable, non-loopback HTTPS `--public-url`. +Initialize microsandbox through Web, `POST /core/v1/sandbox/deployment` (including +its required `resources` and immutable `runtime` fields) or `install.sh --sandbox +microsandbox`. The standalone node installer reads +`GET /api/v1/sandbox-node/configuration` using an enrollment token, or its retained +node credential on a registered reinstall. It verifies the saved release and +resources before registration. The Core host is added the same way, with Add node, +and needs a guest-reachable, non-loopback HTTPS public URL like any other node. Set VM CPU, memory and disk limits in the database-owned specification. `root_disk_mib` bounds the managed root disk; `environment_disk_mib` separately