Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -1756,7 +1756,9 @@ remains independent of Docker's local store identity.

The manifest is the shared download contract for Core, node and self-hosted
installers: flat versioned filenames, compressed Runtime size/hash and unpacked
size/hash, with HTTPS release URLs or the explicit offline payload. Download into
size/hash. Nodes and self-hosted executors download artifacts only from their
console's payload (or a local offline bundle), never from a release URL the build
recorded; release URLs serve people downloading bundles. Download into
private temporary files, verify before atomic promotion, and reuse only verified
cache entries or exact image identities. Core's default image must not acquire
execution-only payloads. Python zipapps bundle the shared resolver with each
Expand Down Expand Up @@ -1806,9 +1808,10 @@ applications, nodes, sandbox guests and self-hosted executors, and also the cons
origin. Core derives the daemon `wss` URL, the self-hosted `remote_url`, hosted
Runtime bootstrap and the deployment's read-only `core_url` from it; the deployment
API does not accept a Core address, and no deployment row stores one. Bootstrap
never uses request Host or caller-supplied placement fields. Each node records the
address it enrolled with; after the public URL changes, it receives no new
sandboxes until re-added. This does
never uses request Host or caller-supplied placement fields. Enrollment names the
Core address the node uses; Core refuses one that is not the public URL (409,
token unconsumed) and records it. After the public URL changes, a node receives no
new sandboxes until re-added. This does
not widen sandbox network policies or change credential admission.

The distribution build sets umask 022 for non-root-readable payloads; installation
Expand All @@ -1828,15 +1831,23 @@ same opt-in rule. Missing KVM fails when microsandbox is selected without changi
that choice.
The thin distribution supplies native Core binaries. Provider helpers, the node
agent, Runtime launcher and pinned msb runtime/firmware are separate, same-revision
assets resolved only when selected. Core packaging is independent of provider:
assets. Nodes obtain them only from the console's payload, so a console serves
nodes only with the offline bundle's assets or release assets placed in the bundle's
`artifacts/` directory before `install.sh` runs; `/console/config` reports which
providers it can serve. Core packaging is independent of provider:
`--native-core` runs Core as a systemd user service, with PostgreSQL/Web in Compose
and a private loopback database port. Native Core needs no KVM or node assets. Core receives no
Docker socket or node identity mount in either mode. The ordinary standalone node
service owns its provider processes outside the Core container. Its `KillMode=process`
preserves resident microVM/helper processes across a node-service restart. User KVM
access and the Linux runtime libraries are prerequisites for microsandbox; both node
providers require a systemd user session with linger. Do not add another launcher,
scheduler or recovery path for local installation.
access and the Linux runtime libraries are prerequisites for microsandbox. The node
runs as a systemd user service and needs linger. The only other launcher allowed
is the root-run node installation (sudo mode) planned for phase 2b: one root-owned
system service per installation that runs the same node program as a dedicated
unprivileged service user. Do not add any other launcher, scheduler or recovery
path. Node services restart after failures without a start limit, so a node
outlasts a Core outage, and stop restarting when the node program exits 78
because Core answered 401 to its credential (a removed or retired node).
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
Expand Down Expand Up @@ -1893,8 +1904,11 @@ dialog, using the deployment's `core_url` (the installation public URL).
Do not expose routine network wiring or manual runtime setup as the primary flow.
Generate a one-time command only on user intent, never retry enrollment writes
automatically, and discard credentials and late responses when the dialog closes
or the Core connection changes. Detect successful addition against the node IDs
present before enrollment; an existing node reconnecting is not a new enrollment.
or the Core connection changes. Web detects successful addition against the node
IDs present before enrollment; an existing node reconnecting is not a new
enrollment. Core also reports the command's `enrollment_id` on the node it
registered (null for nodes enrolled before Core recorded it); Web's exact match on
it lands with the Web follow-up.
The command verifies the installer checksum before execution, retains normal TLS
verification, and passes the enrollment credential only to the installer process.

Expand Down
11 changes: 11 additions & 0 deletions contracts/agents-api/core.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,9 @@ definitions:
type: object
api.SandboxEnrollmentToken:
properties:
enrollment_id:
description: Public, non-secret handle of this command; never a credential. The node it registers reports the same value as enrollment_id.
type: string
expires_at:
type: string
token:
Expand Down Expand Up @@ -939,6 +942,10 @@ definitions:
- microsandbox_artifacts_unavailable
- capacity_insufficient
type: string
enrollment_id:
description: The enrollment_id of the command that registered this node (POST /core/v1/sandbox/enrollment-tokens); null for nodes enrolled before Core recorded it.
type: string
x-nullable: true
id:
type: string
last_seen_at:
Expand Down Expand Up @@ -1021,6 +1028,10 @@ definitions:
- microsandbox_artifacts_unavailable
- capacity_insufficient
type: string
enrollment_id:
description: The enrollment_id of the command that registered this node (POST /core/v1/sandbox/enrollment-tokens); null for nodes enrolled before Core recorded it.
type: string
x-nullable: true
history:
$ref: '#/definitions/store.NodeHostHistory'
host:
Expand Down
5 changes: 4 additions & 1 deletion contracts/agents-api/runtime.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ definitions:
properties:
backend_fingerprint:
type: string
core_url:
description: The Core origin this node stores and connects to, such as https://core.example. It must equal the installation public URL; otherwise enrollment gets 409 sandbox_node_address_mismatch and the token stays unused.
type: string
credential:
type: string
deployment_generation:
Expand Down Expand Up @@ -183,7 +186,7 @@ paths:
post:
consumes:
- application/json
description: Node machine connection. Consumes a one-use enrollment token; grants no project or administrator access. Responses contain only explicit safe fields.
description: Node machine connection. Consumes a one-use enrollment token; grants no project or administrator access. Responses contain only explicit safe fields. core_url is required and must equal the installation public URL; a different address gets 409 sandbox_node_address_mismatch and leaves the token unused.
parameters:
- description: Request
in: body
Expand Down
16 changes: 11 additions & 5 deletions contracts/agents-api/sandbox-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,17 @@ Node capacity is separate from the deployment specification. The administrator's
`max_retained`, defaulting to 2 and 8. Microsandbox uses both limits. Docker never
suspends, so its `max_retained` always equals `max_active`; Core replaces any
submitted value, here and in `PATCH /core/v1/sandbox/nodes/{node_id}`. Core stores
that approval with the token and copies it to the registered node. Node enrollment
that approval with the token and copies it to the registered node. The response
is `{token, expires_at, enrollment_id}`: `enrollment_id` is a public, non-secret
handle of that command and never a credential. Node enrollment
cannot submit capacity overrides. The existing node configuration/identity reads
expose approved limits for that credential; node updates remain administrator
operations. Local host capacity checks may reject a deployment that cannot run
safely, but never raise its limits.

Each node in `GET /core/v1/sandbox/nodes` and its detail reports `core_url`: the
installation public URL when the node enrolled. A node whose `core_url` differs from
Each node in `GET /core/v1/sandbox/nodes` and its detail reports `enrollment_id`,
the handle of the command that registered it (null for nodes enrolled before Core
recorded it), and `core_url`: the installation public URL when the node enrolled. A node whose `core_url` differs from
the current public URL receives no new placements. Work already placed on it
finishes there: a hosted Environment that was placed but not yet allocated before
the change is still allocated on that node, and its retained sandboxes can still
Expand Down Expand Up @@ -252,8 +255,11 @@ or E2B credential. It is available only for node-backed providers.
The installer reads this configuration before preparing local assets. Its provider
file records `generation` and `specification`, alongside the host's socket, paths
and network policy. Enrollment at `POST /api/v1/sandbox-node/enroll` includes
`deployment_generation` and `specification_digest`. A mismatch rejects before
token consumption. Retained node authentication checks the same generation and
`deployment_generation`, `specification_digest` and `core_url`, the Core origin the
node stores. A specification mismatch rejects with 409
`sandbox_specification_mismatch` and a `core_url` other than the installation public
URL with 409 `sandbox_node_address_mismatch`, both before token consumption. A
missing `core_url` gets 400 `invalid_request_error` with `param: "core_url"`. Retained node authentication checks the same generation and
digest. A changed local resource setting, Runtime or generation must fail rather
than rewrite the retained identity or silently use a local default.

Expand Down
18 changes: 9 additions & 9 deletions deploy/install/distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,12 @@ def safe_url(value):
return value


class SecureRedirect(urllib.request.HTTPRedirectHandler):
class NoRedirect(urllib.request.HTTPRedirectHandler):
"""Downloads come only from the console, which never redirects; a redirect
could hand verified names to another origin, so every one is refused."""
def redirect_request(self, request, fp, code, message, headers, newurl):
safe_url(newurl)
if urlsplit(request.full_url).scheme == 'https' and urlsplit(newurl).scheme != 'https':
raise DistributionError('Artifact redirect cannot downgrade HTTPS')
return super().redirect_request(request, fp, code, message, headers, newurl)
raise DistributionError('Artifact and metadata downloads do not follow redirects; check the console URL '
'and the reverse proxy in front of it')


def checked_path(path):
Expand Down Expand Up @@ -156,7 +156,7 @@ def obtain_artifact(manifest, logical_path, destination, offline_root=None):
fd, temporary = tempfile.mkstemp(prefix='.artifact-', dir=target.parent)
try:
with os.fdopen(fd, 'wb') as output:
stream = source.open('rb') if source else urllib.request.build_opener(SecureRedirect()).open(url, timeout=30)
stream = source.open('rb') if source else urllib.request.build_opener(NoRedirect()).open(url, timeout=30)
with stream:
count = 0
while True:
Expand Down Expand Up @@ -232,7 +232,7 @@ def read(name):
url = safe_url(source_url.rstrip('/') + '/node-install/' + name)
for attempt in range(3):
try:
with urllib.request.build_opener(SecureRedirect()).open(url, timeout=30) as stream:
with urllib.request.build_opener(NoRedirect()).open(url, timeout=30) as stream:
data = stream.read(1024 * 1024 + 1)
break
except urllib.error.HTTPError as error:
Expand All @@ -259,8 +259,8 @@ def read(name):
if (manifest.get('platform') != 'linux/amd64'
or not re.fullmatch(r'[0-9a-f]{40}', manifest.get('source_commit', ''))):
raise DistributionError('Unsupported distribution platform or revision')
if not manifest.get('artifact_base_url') and source_url:
manifest['artifact_base_url'] = source_url.rstrip('/') + '/node-install/artifacts'
# Artifacts come only from the console, never from a release URL the build recorded.
manifest['artifact_base_url'] = source_url.rstrip('/') + '/node-install/artifacts' if source_url and offline_root is None else ''
return manifest
except (ValueError, TypeError, AttributeError):
raise DistributionError('Invalid distribution metadata') from None
62 changes: 53 additions & 9 deletions deploy/install/node_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from pathlib import Path
import platform
import re
import shutil
import socket
import stat
import subprocess
Expand Down Expand Up @@ -53,17 +54,53 @@ def origin(value):
return value.rstrip("/")


def checked(arguments, failure, **kwargs):
def checked(arguments, failure, explain=None, **kwargs):
# explain(stderr) may replace the failure with an InstallError carrying fixed text only.
try:
result = subprocess.run(arguments, check=False, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=kwargs.pop("timeout", 30), **kwargs)
if result.returncode:
raise InstallError(failure)
raise explain(result.stderr) if explain else InstallError(failure)
return result.stdout.decode().strip()
except (OSError, subprocess.SubprocessError):
raise InstallError(failure) from None


REGISTRATION_UNCONFIRMED = ("Node enrollment was not confirmed. Check the Core URL, enrollment expiry and local provider "
"prerequisites; keep its state and rerun the command to recover.")
# The node program's rejection line: Core's status and fixed error code, nothing else.
REJECTION = re.compile(rb"node enrollment rejected \(HTTP (\d{3})(?: ([a-z_]{1,64}))?\)")


class AddressChanged(InstallError):
"""Core refused the node's address before consuming the token, so it has no record of this node."""


def registration_failure(stderr):
"""A fixed message for Core's answer to registration; never the node program's own text."""
matches = list(REJECTION.finditer(stderr or b""))
if not matches:
return InstallError(REGISTRATION_UNCONFIRMED)
status, code = matches[-1].group(1).decode(), (matches[-1].group(2) or b"").decode()
if code == "sandbox_node_address_mismatch":
return AddressChanged(node_spec.PUBLIC_URL_CHANGED + " The token was not used; downloaded files are kept.")
if status == "401":
return InstallError("The enrollment command expired or was already used. Generate a new command on the Nodes "
"page and run it on this host; downloaded files are kept.")
return InstallError(REGISTRATION_UNCONFIRMED + " Core answered HTTP " + status + (" " + code if code else "") + ".")


def discard_unregistered(root):
"""Remove the files that name the old address, so a new command can register this host.

Only for a node Core never recorded: downloads, the image and the service file stay."""
for name in ("installation.json", "provider.json"):
if existing_file(root / name):
(root / name).unlink()
if (root / "state/node").is_dir() and not (root / "state/node").is_symlink():
shutil.rmtree(root / "state/node")


def preflight(provider):
if sys.version_info < (3, 9) or platform.system() != "Linux" or platform.machine() not in ("x86_64", "amd64") or os.getuid() == 0:
raise InstallError("Run with Python 3.9+ as a non-root user on Linux amd64")
Expand Down Expand Up @@ -136,8 +173,8 @@ def read(name):
distribution.image_identities(manifest, "runtime")
for name in (COMMON[0], "images/runtime.tar.gz") + MICRO:
distribution.artifact(manifest, name)
if not manifest.get("artifact_base_url") and source:
manifest["artifact_base_url"] = source + "/node-install/artifacts"
# Nodes download only from their console, never from a release URL the build recorded.
manifest["artifact_base_url"] = source + "/node-install/artifacts" if source else ""
return manifest, sums


Expand Down Expand Up @@ -272,10 +309,13 @@ def matches():
def service_unit(root):
def quote(value):
return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%") + '"'
return ("[Unit]\nDescription=Parsar sandbox node\n\n[Service]\nType=exec\nExecStart=:"
# The node keeps retrying while Core is unreachable (no start limit). It exits 78
# when Core rejects its credential, as after removal; that ends the restarts.
return ("[Unit]\nDescription=Parsar sandbox node\nStartLimitIntervalSec=0\n\n[Service]\nType=exec\nExecStart=:"
+ quote(root / COMMON[0]) + " run --config " + quote(root / "provider.json") + " --state-dir " + quote(root / "state/node")
+ "\nWorkingDirectory=" + str(root).replace("%", "%%")
+ "\nRestart=on-failure\nKillMode=process\nUMask=0077\n\n[Install]\nWantedBy=default.target\n")
+ "\nRestart=on-failure\nRestartSec=5s\nRestartPreventExitStatus=78\nKillMode=process\nUMask=0077"
+ "\n\n[Install]\nWantedBy=default.target\n")


def install(args, token):
Expand Down Expand Up @@ -347,9 +387,13 @@ def install(args, token):
with os.fdopen(descriptor, "w") as secret:
secret.write(token)
print("Registering this node with Core...", flush=True)
checked([str(root / COMMON[0]), "register", "--config", str(root / "provider.json"), "--state-dir", str(root / "state/node"),
"--core-url", args.core_url, "--name", socket.gethostname(),
"--enrollment-token-file", secret_path], "Node enrollment was not confirmed. Check the Core URL, enrollment expiry and local provider prerequisites; keep its state and rerun the command to recover.")
try:
checked([str(root / COMMON[0]), "register", "--config", str(root / "provider.json"), "--state-dir", str(root / "state/node"),
"--core-url", args.core_url, "--name", socket.gethostname(),
"--enrollment-token-file", secret_path], REGISTRATION_UNCONFIRMED, explain=registration_failure)
except AddressChanged:
discard_unregistered(root)
raise
write_once(marker, json_text(state))
finally:
if os.path.exists(secret_path):
Expand Down
6 changes: 6 additions & 0 deletions deploy/install/node_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ class SpecificationError(Exception):
pass


PUBLIC_URL_CHANGED = ("Core's public URL changed after this command was generated. Generate a new command on the "
"Nodes page and run it on this host.")


def release(manifest):
return {"source_commit": manifest["source_commit"],
"image_id": manifest["images"]["runtime"],
Expand All @@ -36,6 +40,8 @@ def digest(provider, specification):


def validate(data, args):
if isinstance(data, dict) and isinstance(data.get("core_url"), str) and data["core_url"] != args.core_url:
raise SpecificationError(PUBLIC_URL_CHANGED)
try:
provider, spec = data["provider"], data["specification"]
if (provider not in ("docker", "microsandbox") or data["installation_id"] != args.installation_id
Expand Down
Loading
Loading