diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..06e8362b7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,116 @@ +name: core-release + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + ref: + description: Full source commit SHA to build + required: true + type: string + offline: + description: Also create the full offline archive + default: false + type: boolean + draft_release: + description: Upload files to a new draft release; never publish + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: core-release-${{ inputs.ref || github.sha }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-22.04 + timeout-minutes: 120 + outputs: + revision: ${{ steps.source.outputs.revision }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.sha }} + persist-credentials: false + - name: Pin source identity + id: source + env: + REQUESTED_REF: ${{ inputs.ref }} + run: | + if [[ -n "$REQUESTED_REF" && ! "$REQUESTED_REF" =~ ^[0-9a-f]{40}$ ]]; then + echo 'Manual releases require a full commit SHA' >&2 + exit 1 + fi + revision="$(git rev-parse HEAD)" + if [[ -n "$REQUESTED_REF" && "$REQUESTED_REF" != "$revision" ]]; then exit 1; fi + echo "revision=$revision" >> "$GITHUB_OUTPUT" + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Install build prerequisites + run: | + npm install --global pnpm@10.30.3 + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libssl-dev + rustup toolchain install 1.95.0 --profile minimal + - uses: actions/cache@v6 + with: + path: | + ~/.parsar/cache/go-build + ~/.parsar/cache/go-mod + ~/.parsar/cache/executor-cargo + ~/.parsar/cache/executor-target + key: core-release-${{ runner.os }}-${{ hashFiles('go.sum', 'packages/codex-executor/Cargo.lock') }} + - name: Check release metadata and prepare pinned harnesses + run: | + PYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py + bash scripts/prepare-release-runtimes.sh + - name: Build matched artifacts + env: + RELEASE_REVISION: ${{ steps.source.outputs.revision }} + RELEASE_REPOSITORY: ${{ github.repository }} + CORE_DISTRIBUTION_OFFLINE: ${{ inputs.offline && '1' || '0' }} + run: | + inputs="$HOME/.parsar/build/release-inputs/inputs.json" + export AGENTS_RUNTIME_CODEX_PACKAGE="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["codex"])' "$inputs")" + export MCODE_HARNESS_BUILD_DIR="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["mcode"])' "$inputs")" + export CORE_DISTRIBUTION_RELEASE_BASE_URL="https://github.com/$RELEASE_REPOSITORY/releases/download/$RELEASE_REVISION" + bash scripts/build-core-distribution.sh + mkdir -p "$HOME/.parsar/build/release-upload" + for asset in "$HOME/.parsar/build/core-distribution/"*; do + if [[ -f "$asset" ]]; then ln "$asset" "$HOME/.parsar/build/release-upload/"; fi + done + - uses: actions/upload-artifact@v6 + with: + name: core-release-${{ steps.source.outputs.revision }} + path: ~/.parsar/build/release-upload/* + compression-level: 0 + if-no-files-found: error + + draft: + if: github.event_name == 'workflow_dispatch' && inputs.draft_release + needs: build + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v6 + with: + name: core-release-${{ needs.build.outputs.revision }} + path: release-upload + - name: Create an unpublished draft + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_REVISION: ${{ needs.build.outputs.revision }} + run: | + printf 'Matched Linux amd64 artifacts from commit %s. Build output is not live execution qualification.\n' "$RELEASE_REVISION" > release-notes.md + gh release create "$RELEASE_REVISION" --draft --target "$RELEASE_REVISION" \ + --title "Parsar Core $RELEASE_REVISION" --notes-file release-notes.md release-upload/* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e1620308..2c6479925 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1551,6 +1551,25 @@ microsandbox runtime/firmware hashes and executable native payloads. Release gen qualification. A release must be tested from fresh extraction with real models; no synthetic result may substitute for native execution acceptance. +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 +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 +remote bootstrap; the console publishes only fixed non-secret files and declared +artifact names. Release automation builds artifacts and may create an unpublished +draft, but cannot claim real execution qualification or public availability. + +Project-authenticated executor-credential extensions remain outside the upstream +API namespace and reuse the existing restricted issuer. They require the exact +creator of a live self-hosted Environment; deployment administrator authority and +shared Session read access do not grant credential issuance. The console preserves +explicit caller credentials on these routes and never substitutes its administrator +key. Self-hosted installation reuses Docker Runtime isolation, owns no sandbox +node or Core allocation, and retains user-owned native history after uncertain +launches. Report started, connected and real execution success separately. + The distribution build sets umask 022 for non-root-readable payloads; installation credentials and state retain their explicit private permissions. @@ -1565,8 +1584,9 @@ a provider selects microsandbox; `--provider` without enabling the option is an error. 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. -The distribution supplies native Core/helper binaries and pinned msb runtime and -firmware. For microsandbox, Core is a native systemd user service with direct +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. For microsandbox, Core is a native systemd user service with direct `ExecStart` and `KillMode=process`: its restart must preserve the Provider's resident microVM/helper processes. Never package those processes inside Core's container PID namespace, kill their process group on Core stop, or add recovery mechanisms to @@ -1580,7 +1600,7 @@ The basic distroless 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 distribution includes the sandbox-node binary. An enabled local node uses a +The node asset includes the sandbox-node binary. An enabled local node uses a persistent private state directory, explicitly separate from read-only configuration. Docker grants Core write access only to that node-state mount; native Core uses the same installation-owned directory. Zero-node installs create no node identity diff --git a/Makefile b/Makefile index 71e1496aa..8b3e1f449 100644 --- a/Makefile +++ b/Makefile @@ -120,7 +120,7 @@ check-distribution: go test ./services/core-console -count=1 PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/install -p 'test_*.py' PYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py - bash -n deploy/install/install.sh scripts/build-core-console.sh scripts/build-core-distribution.sh + bash -n deploy/install/install.sh scripts/build-core-console.sh scripts/build-core-distribution.sh scripts/prepare-release-runtimes.sh ./scripts/build-core-console.sh build-core-distribution: diff --git a/apps/web/e2e/sandbox-setup.spec.ts b/apps/web/e2e/sandbox-setup.spec.ts index 9fb9d8139..aae90e191 100644 --- a/apps/web/e2e/sandbox-setup.spec.ts +++ b/apps/web/e2e/sandbox-setup.spec.ts @@ -32,7 +32,7 @@ test("bundled console needs no extra admin key and provides one install command await page.getByRole("button", { name: "Add node", exact: true }).click(); const command = page.getByLabel("One-time enrollment command"); await expect(command).toHaveValue(/fixture-once-token/); - await expect(command).toHaveValue(/\/node-install\/node_install.py/); + await expect(command).toHaveValue(/\/node-install\/node-install.pyz/); await expect(page.getByRole("button", { name: "Copy node command" })).toBeVisible(); await expect(page.getByRole("dialog").getByRole("status")).toContainText("Waiting for your node"); diff --git a/apps/web/src/features/sandbox/enrollment-command.test.ts b/apps/web/src/features/sandbox/enrollment-command.test.ts index d5a1e2183..89022293b 100644 --- a/apps/web/src/features/sandbox/enrollment-command.test.ts +++ b/apps/web/src/features/sandbox/enrollment-command.test.ts @@ -10,7 +10,7 @@ describe("sandbox connection and enrollment", () => { it("creates a compact verified installer command with no credential argument or redirect following", () => { const command = nodeInstallCommand("secret'onetime", "https://core.example", "https://console.example", "docker", "installation", "a".repeat(64)); expect(command).toContain("PARSAR_NODE_ENROLLMENT_TOKEN='secret'\\''onetime' python3"); - expect(command).toContain("https://console.example/node-install/node_install.py"); + expect(command).toContain("https://console.example/node-install/node-install.pyz"); expect(command).toContain("sha256sum -c --status &&"); expect(command).toContain("--max-time 30 --max-filesize 1048576"); expect(command).not.toMatch(/--location| -L/); diff --git a/apps/web/src/features/sandbox/enrollment-command.ts b/apps/web/src/features/sandbox/enrollment-command.ts index 15e656d86..3637cd117 100644 --- a/apps/web/src/features/sandbox/enrollment-command.ts +++ b/apps/web/src/features/sandbox/enrollment-command.ts @@ -2,7 +2,7 @@ const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; export function nodeInstallCommand(token: string, coreUrl: string, sourceUrl: string, provider: string, installationId: string, scriptDigest: string): string { return `(umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT -curl -fsS --max-time 30 --max-filesize 1048576 ${quote(sourceUrl + "/node-install/node_install.py")} -o "$d/install.py" && +curl -fsS --max-time 30 --max-filesize 1048576 ${quote(sourceUrl + "/node-install/node-install.pyz")} -o "$d/install.py" && printf '%s %s\\n' ${quote(scriptDigest)} "$d/install.py" | sha256sum -c --status && PARSAR_NODE_ENROLLMENT_TOKEN=${quote(token)} python3 "$d/install.py" --source-url ${quote(sourceUrl)} --core-url ${quote(coreUrl)} --provider ${quote(provider)} --installation-id ${quote(installationId)})`; } diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md new file mode 100644 index 000000000..e27d7438f --- /dev/null +++ b/contracts/agents-api/environment-executor-credentials.md @@ -0,0 +1,58 @@ +# Environment executor credentials + +These Core extensions are outside the pinned upstream Agents API. They use the +same project caller authentication as Session creation, without `OpenAI-Beta`. +They require the exact authenticated creator of a live `self_hosted` Session; +project-shared Session read access does not grant issuance authority. + +`POST /core/v1/environments/{environment_id}/executor-credentials` accepts: + +```json +{"key_id":"CLIENT_GENERATED_UUID","rotate":false} +``` + +`key_id` is a canonical nonzero UUID chosen and retained before the request. +`rotate` is optional and defaults to false. The 201 response is the existing +daemon credential-file format: + +```json +{"key_id":"UUID","environment_id":"ENVIRONMENT_UUID","executor_token":"ONE_TIME_SECRET"} +``` + +Responses use `Cache-Control: no-store`. Save the response directly to an owned +mode-0600 file; never place it in shell arguments, logs, a workspace or source. +Only its digest is persisted in Core. The secret authorizes daemon enrollment +and connection for this exact Environment, never public Session API calls, +sandbox-node enrollment or project resource operations. The application caller +key stays on the application machine and is never given to the Runtime. + +Ordinary issuance with an existing `key_id` returns 409, even after revocation. +After an uncertain issuance response, explicitly send the same `key_id` with +`rotate:true` to replace the secret; do not automatically retry a rotation or +choose another key. Rotation preserves principal and Environment restriction, +invalidates the previous secret, and can restore an explicitly revoked key. +Credentials created without an exact Environment restriction by the operator +CLI cannot be managed through these routes. + +`DELETE /core/v1/environments/{environment_id}/executor-credentials/{key_id}` +returns 204 and revokes the exact caller-owned restricted key. Repeated +revocation is safe. Other principals, targets and deleted Sessions return 404. +Revocation denies further authorized connection and dispatch; it does not stop +user-owned compute or prove that an existing process has stopped. + +The distribution's `self-hosted-install.pyz` downloads the matching +`parsar-runtime` launcher, Runtime image and seccomp profile. The local launcher +uses the same Docker isolation and workspace layout as Core-managed V1, then +invokes the existing daemon `connect` with the unchanged returned Environment +ID and `remote_url`. Docker onboarding requires an externally reachable `wss` +URL; host loopback addresses are not rewritten inside the container. It creates +no sandbox node or Core-managed allocation. Workspace, credential and native +history volumes belong to the operator. Failed or uncertain launches retain +their volumes and installation receipt for inspection instead of replacing +history or retrying enrollment. Session deletion does not reclaim these volumes. +Rerunning the installer inspects a previously started container only after its +installation and Environment labels match. It reports running separately from +Session connection, or gives a command to start the same stopped container. +An uncertain launch without a success receipt gives label-filtered container +and volume inspection commands and never creates a replacement. A cached image +with the exact distribution digest and platform skips image download and import. diff --git a/contracts/agents-api/sandbox-manager.openapi.yaml b/contracts/agents-api/sandbox-manager.openapi.yaml index dfe112cda..7e79241e1 100644 --- a/contracts/agents-api/sandbox-manager.openapi.yaml +++ b/contracts/agents-api/sandbox-manager.openapi.yaml @@ -1,5 +1,12 @@ basePath: / definitions: + api.EnvironmentExecutorCredentialRequest: + properties: + key_id: + type: string + rotate: + type: boolean + type: object api.SandboxAllocationList: properties: data: @@ -32,6 +39,15 @@ definitions: $ref: '#/definitions/store.RuntimeNode' type: array type: object + store.IssuedExecutorCredential: + properties: + environment_id: + type: string + executor_token: + type: string + key_id: + type: string + type: object store.RuntimeDeploymentView: properties: core_url: @@ -135,6 +151,19 @@ definitions: provider: type: string type: object + store.RuntimeNodeStatus: + properties: + connected: + type: boolean + installation_id: + type: string + node_id: + type: string + provider: + type: string + provider_ready: + type: boolean + type: object store.RuntimeNodeUpdate: properties: max_active: @@ -176,13 +205,100 @@ definitions: type: object info: contact: {} - description: Deployment administration and node enrollment. Uses separate administrator and node credentials; project API keys do not authorize these operations. + description: Core extensions outside the upstream Agents API. Environment executor credential operations use project caller authentication. Sandbox administration and node enrollment use separate administrator and node credentials. See each operation's security requirements. license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html - title: Core Sandbox Manager + title: Core Extensions version: "1" paths: + /core/v1/environments/{environment_id}/executor-credentials: + post: + consumes: + - application/json + description: Core extension, not an upstream Agents API operation. Uses the project caller key and exact live self_hosted Session creator. Returns a connect-only secret once, restricted to this Environment. Repeating an issuance key_id returns 409; after an uncertain response explicitly rotate that same key_id. Rotation retains immutable ownership and invalidates the old secret. No Session API authority is granted. + parameters: + - description: Environment UUID + in: path + name: environment_id + required: true + type: string + - description: Request + in: body + name: body + required: true + schema: + $ref: '#/definitions/api.EnvironmentExecutorCredentialRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/store.IssuedExecutorCredential' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Issue or explicitly rotate an Environment executor credential + tags: + - Environment Executor + /core/v1/environments/{environment_id}/executor-credentials/{key_id}: + delete: + description: Core extension authenticated by the project caller key. Revokes only the caller's exact Environment-restricted credential. Repeated revocation is safe. Revocation denies future enrollment and dispatch but does not stop caller-owned compute or prove process quiescence. + parameters: + - description: Environment UUID + in: path + name: environment_id + required: true + type: string + - description: Executor key UUID + in: path + name: key_id + required: true + type: string + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Revoke an Environment executor credential + tags: + - Environment Executor /core/v1/sandbox/deployment: get: description: Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. @@ -376,7 +492,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/store.RuntimeNodeIdentity' + $ref: '#/definitions/store.RuntimeNodeStatus' "400": description: Bad Request schema: @@ -403,7 +519,7 @@ paths: $ref: '#/definitions/v1.ErrorResponse' security: - NodeAuth: [] - summary: Recover an enrolled sandbox node identity + summary: Recover an enrolled sandbox node identity and observe its readiness tags: - Sandbox Manager /core/v1/sandbox/nodes: diff --git a/deploy/distribution/Dockerfile b/deploy/distribution/Dockerfile index 89cd7f8bd..9da812463 100644 --- a/deploy/distribution/Dockerfile +++ b/deploy/distribution/Dockerfile @@ -1,13 +1,11 @@ -# Context contains matched Core binaries and the verified microsandbox v0.7.2 release. -# The helper embeds its pinned SDK FFI; unlike the basic static image, this image -# supplies the glibc runtime needed to load that library. -FROM debian:bookworm-slim@sha256:a0982977ea1cf754c15281f48a7d5957cd14039a2a4f2aca9f23d74d226003d0 +# Core is independent of execution payloads. Optional native Provider artifacts +# are downloaded separately; the Docker Provider is compiled into the API. +FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libgcc-s1 \ && rm -rf /var/lib/apt/lists/* -COPY --chmod=0555 bin/agents-api bin/agents-api-migrate bin/agents-api-device bin/agents-api-environment-key bin/agents-api-microsandbox-provider bin/parsar-sandbox-node /usr/local/bin/ -COPY --chmod=0555 microsandbox/msb microsandbox/libkrunfw.so.5.6.1 /opt/microsandbox/ +COPY --chmod=0555 bin/agents-api bin/agents-api-migrate bin/agents-api-device bin/agents-api-environment-key /usr/local/bin/ ENV AGENTS_API_ADDR=:8091 EXPOSE 8091 diff --git a/deploy/install/distribution.py b/deploy/install/distribution.py new file mode 100644 index 000000000..5dd9655d6 --- /dev/null +++ b/deploy/install/distribution.py @@ -0,0 +1,216 @@ +"""Fetch verified, version-matched installation artifacts without build tools.""" +import gzip +import hashlib +import http.client +import ipaddress +import json +import os +from pathlib import Path +import re +import socket +import stat +import tempfile +import time +import urllib.error +import urllib.request +from urllib.parse import urlsplit + + +class DistributionError(Exception): + pass + + +def digest(path): + value = hashlib.sha256() + with Path(path).open('rb') as stream: + for block in iter(lambda: stream.read(1024 * 1024), b''): + value.update(block) + return value.hexdigest() + + +def artifact(manifest, name): + entry = manifest.get('artifacts', {}).get(name) + revision = manifest.get('source_commit', '') + if not isinstance(entry, dict) or not re.fullmatch(r'[0-9a-f]{40}', revision): + raise DistributionError('Missing versioned artifact: ' + name) + filename = entry.get('filename', '') + if (not isinstance(filename, str) or not re.fullmatch(r'[A-Za-z0-9._-]+', filename) + or revision not in filename or filename in ('.', '..') + or not re.fullmatch(r'[0-9a-f]{64}', str(entry.get('sha256', ''))) + or type(entry.get('size')) is not int or entry['size'] <= 0): + raise DistributionError('Invalid artifact metadata: ' + name) + return entry + + +def safe_url(value): + try: + parsed = urlsplit(value) + parsed.port + loopback = parsed.hostname == 'localhost' + if parsed.hostname and not loopback: + try: + loopback = ipaddress.ip_address(parsed.hostname).is_loopback + except ValueError: + pass + if (not parsed.hostname or parsed.username is not None or parsed.password is not None + or parsed.fragment or any(c.isspace() for c in value) + or '\\' in value or parsed.scheme != 'https' and not (parsed.scheme == 'http' and loopback)): + raise ValueError() + except ValueError: + raise DistributionError('Artifact downloads require HTTPS; loopback HTTP is only for local testing') from None + return value + + +class SecureRedirect(urllib.request.HTTPRedirectHandler): + 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) + + +def checked_path(path): + path = Path(path) + if not path.is_absolute() or path.resolve() != path: + raise DistributionError('Artifact destination must be an absolute path without symlinks') + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if path.exists() and (not stat.S_ISREG(path.stat().st_mode) or path.stat().st_uid != os.getuid()): + raise DistributionError('Artifact destination must be an owned regular file') + return path + + +def matches(path, entry): + return path.stat().st_size == entry['size'] and digest(path) == entry['sha256'] + + +def obtain_artifact(manifest, logical_path, destination, offline_root=None): + entry = artifact(manifest, logical_path) + target = checked_path(destination) + if target.exists(): + if not matches(target, entry): + raise DistributionError('Cached artifact differs; preserve the installation and inspect: ' + logical_path) + return target + source = None + if offline_root is not None: + candidate = Path(offline_root) / 'artifacts' / entry['filename'] + if candidate.exists(): + if candidate.is_symlink() or not candidate.is_file(): + raise DistributionError('Offline artifact must be a regular file: ' + logical_path) + source = candidate + base = manifest.get('artifact_base_url', '') + if source is None: + if not isinstance(base, str) or not base or urlsplit(base).query: + raise DistributionError('No downloadable artifact source; use the matching offline bundle') + url = safe_url(base.rstrip('/') + '/' + entry['filename']) + for attempt in range(3): + 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) + with stream: + count = 0 + while True: + block = stream.read(1024 * 1024) + if not block: + break + count += len(block) + if count > entry['size']: + raise DistributionError('Artifact exceeds published size: ' + logical_path) + output.write(block) + if count != entry['size']: + raise http.client.IncompleteRead(b'', entry['size'] - count) + if digest(temporary) != entry['sha256']: + raise DistributionError('Artifact checksum mismatch: ' + logical_path) + os.chmod(temporary, 0o700 if logical_path.startswith('native/') else 0o600) + os.replace(temporary, target) + return target + except urllib.error.HTTPError as error: + if error.code not in (408, 429, 500, 502, 503, 504) or attempt == 2: + raise DistributionError(f'Artifact download failed (HTTP {error.code}): {logical_path}. Check release access and retry.') from None + except (urllib.error.URLError, socket.timeout, ConnectionError, http.client.HTTPException): + if attempt == 2 or source: + raise DistributionError('Artifact transfer interrupted: ' + logical_path + '. Check network access and rerun; installed state is retained.') from None + finally: + if os.path.exists(temporary): + os.unlink(temporary) + time.sleep(attempt + 1) + raise DistributionError('Artifact download did not complete') + + +def runtime_archive(manifest, cache_root, offline_root=None): + entry = artifact(manifest, 'images/runtime.tar.gz') + expanded = {'sha256': entry.get('unpacked_sha256'), 'size': entry.get('unpacked_size')} + if (not re.fullmatch(r'[0-9a-f]{64}', str(expanded['sha256'])) + or type(expanded['size']) is not int or expanded['size'] <= 0): + raise DistributionError('Runtime archive is missing unpacked verification metadata') + root = Path(cache_root) + target = checked_path(root / 'images/runtime.tar') + if target.exists(): + if not matches(target, expanded): + raise DistributionError('Cached Runtime archive differs; preserve state and inspect it') + return target + archive = obtain_artifact(manifest, 'images/runtime.tar.gz', root / 'images/runtime.tar.gz', offline_root) + fd, temporary = tempfile.mkstemp(prefix='.runtime-', dir=target.parent) + try: + with os.fdopen(fd, 'wb') as output, gzip.open(archive, 'rb') as stream: + count = 0 + for block in iter(lambda: stream.read(1024 * 1024), b''): + count += len(block) + if count > expanded['size']: + raise DistributionError('Runtime archive exceeds published unpacked size') + output.write(block) + if not matches(Path(temporary), expanded): + raise DistributionError('Unpacked Runtime checksum mismatch') + os.replace(temporary, target) + except (gzip.BadGzipFile, EOFError): + raise DistributionError('Invalid compressed Runtime archive') from None + finally: + if os.path.exists(temporary): + os.unlink(temporary) + return target + + +def load_manifest(source_url=None, offline_root=None): + """Read the matched public manifest without transmitting installation credentials.""" + def read(name): + if offline_root is not None: + with (Path(offline_root) / name).open('rb') as stream: + data = stream.read(1024 * 1024 + 1) + else: + if not source_url: + raise DistributionError('A Core source URL or offline bundle is required') + 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: + data = stream.read(1024 * 1024 + 1) + break + except urllib.error.HTTPError as error: + if error.code not in (408, 429, 500, 502, 503, 504) or attempt == 2: + raise DistributionError(f'Distribution metadata unavailable (HTTP {error.code}); check the Core source URL') from None + except (urllib.error.URLError, socket.timeout, ConnectionError, http.client.HTTPException): + if attempt == 2: + raise DistributionError('Cannot reach distribution metadata; check network and TLS trust, then retry') from None + time.sleep(attempt + 1) + if len(data) > 1024 * 1024: + raise DistributionError('Distribution metadata exceeds size limit') + return data + sums = {} + try: + for line in read('SHA256SUMS').decode().splitlines(): + checksum, name = line.split(' ', 1) + if name in sums or not re.fullmatch(r'[0-9a-f]{64}', checksum): + raise ValueError() + sums[name] = checksum + raw = read('manifest.json') + if hashlib.sha256(raw).hexdigest() != sums.get('manifest.json'): + raise DistributionError('Distribution manifest checksum mismatch') + manifest = json.loads(raw) + 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' + return manifest + except (ValueError, TypeError, AttributeError): + raise DistributionError('Invalid distribution metadata') from None diff --git a/deploy/install/install.py b/deploy/install/install.py index e3ad45db2..1a23df5e3 100644 --- a/deploy/install/install.py +++ b/deploy/install/install.py @@ -22,6 +22,7 @@ from configuration import compose_config, core_environment, managed_config import native_service +from distribution import DistributionError, artifact, obtain_artifact, runtime_archive class InstallError(Exception): @@ -63,16 +64,19 @@ def verify_bundle(bundle): raise InstallError("Invalid distribution path") if digest(path) != expected: raise InstallError("Distribution checksum mismatch: " + name) - required = {"manifest.json", "install.sh", "install.py", "configuration.py", "native_service.py", "node_install.py", "runtime/seccomp.json"} - required.update(f"images/{name}.tar" for name in ("core", "web", "runtime", "database")) - required.update("native/bin/" + name for name in ("agents-api", "agents-api-migrate", "agents-api-microsandbox-provider", "parsar-sandbox-node")) - required.update("native/microsandbox/" + name for name in ("msb", "libkrunfw.so.5.6.1")) + required = {"manifest.json", "install.sh", "install.py", "configuration.py", "native_service.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")) if not required.issubset(covered): raise InstallError("Distribution checksum list is incomplete") manifest = json.loads((bundle / "manifest.json").read_text()) for image in manifest["images"].values(): if not image.startswith("sha256:") or len(image) != 71: raise InstallError("Distribution must select immutable images") + for name in ("images/runtime.tar.gz", "native/bin/parsar-sandbox-node", + "native/bin/agents-api-microsandbox-provider", "native/microsandbox/msb", + "native/microsandbox/libkrunfw.so.5.6.1"): + artifact(manifest, name) return manifest @@ -259,10 +263,18 @@ def prepare_node_payload(root, state, bundle): return destination = root / "node-payload" # Public distribution files only. Never copy the private installation config. - names = ("node_install.py", "manifest.json", "SHA256SUMS", "images/runtime.tar", - "runtime/seccomp.json", "native/bin/parsar-sandbox-node", - "native/bin/agents-api-microsandbox-provider", "native/microsandbox/msb", - "native/microsandbox/libkrunfw.so.5.6.1") + names = ["node-install.pyz", "self-hosted-install.pyz", "manifest.json", "SHA256SUMS", "runtime/seccomp.json"] + manifest = json.loads((bundle / "manifest.json").read_text()) + for logical in manifest.get("artifacts", {}): + entry = artifact(manifest, logical) + name = "artifacts/" + entry["filename"] + source = bundle / name + if source.exists(): + if (source.is_symlink() or not source.is_file() + or not source.resolve().is_relative_to(bundle.resolve()) + or source.stat().st_size != entry["size"] or digest(source) != entry["sha256"]): + raise InstallError("Offline artifact verification failed: " + logical) + names.append(name) for name in names: source, target = bundle / name, destination / name target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) @@ -313,8 +325,14 @@ def main(argv=None): raise InstallError("microsandbox requires host KVM; enable virtualization or explicitly choose --provider docker") bundle = Path(__file__).resolve().parent manifest = verify_bundle(bundle) - if args.provider == "microsandbox" and not args.web_only: - native_service.preflight(bundle) + if args.provider and not args.web_only: + print("Preparing the selected local sandbox provider...", flush=True) + runtime_archive(manifest, bundle, bundle) + if args.provider == "microsandbox": + for name in ("native/bin/agents-api-microsandbox-provider", "native/microsandbox/msb", + "native/microsandbox/libkrunfw.so.5.6.1"): + obtain_artifact(manifest, name, bundle / name, bundle) + native_service.preflight(bundle) state = initialize(root, args, manifest) prepare_node_payload(root, state, bundle) if state["provider"] == "docker": @@ -365,7 +383,7 @@ def main(argv=None): if __name__ == "__main__": try: main() - except (InstallError, RuntimeError, OSError, ValueError, KeyError, subprocess.CalledProcessError) as error: + except (InstallError, DistributionError, RuntimeError, OSError, ValueError, KeyError, subprocess.CalledProcessError) as error: # Errors never include generated configuration or external process output. - print(str(error) if isinstance(error, (InstallError, RuntimeError)) else "Installation failed; inspect prerequisites and private deployment files", file=sys.stderr) + print(str(error) if isinstance(error, (InstallError, DistributionError, RuntimeError)) else "Installation failed; inspect prerequisites and private deployment files", file=sys.stderr) sys.exit(1) diff --git a/deploy/install/native_service.py b/deploy/install/native_service.py index 78c9ffbf3..dd6801e7e 100644 --- a/deploy/install/native_service.py +++ b/deploy/install/native_service.py @@ -11,8 +11,7 @@ REQUIRED = ("bin/agents-api", "bin/agents-api-microsandbox-provider", - "microsandbox/msb", "microsandbox/libkrunfw.so.5.6.1", - "bin/parsar-sandbox-node") + "microsandbox/msb", "microsandbox/libkrunfw.so.5.6.1") def is_native(state): diff --git a/deploy/install/node_install.py b/deploy/install/node_install.py index 679fad86d..909f9b35d 100644 --- a/deploy/install/node_install.py +++ b/deploy/install/node_install.py @@ -3,6 +3,8 @@ import argparse import fcntl import hashlib +import http.client +import io import ipaddress import json import os @@ -14,16 +16,20 @@ import subprocess import sys import tempfile +import time +import urllib.error import urllib.request -from urllib.parse import urlsplit +from urllib.parse import urlencode, urlsplit import uuid +import distribution + class InstallError(Exception): pass -COMMON = ("native/bin/parsar-sandbox-node", "images/runtime.tar", "runtime/seccomp.json") +COMMON = ("native/bin/parsar-sandbox-node", "runtime/seccomp.json") MICRO = ("native/bin/agents-api-microsandbox-provider", "native/microsandbox/msb", "native/microsandbox/libkrunfw.so.5.6.1") @@ -71,11 +77,32 @@ def preflight(provider): class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, request, fp, code, message, headers, url): - raise InstallError("Node payload redirects are not supported") + raise InstallError("Node bootstrap redirects are not supported") + + +def open_request(request, timeout=15): + return urllib.request.build_opener(NoRedirect()).open(request, timeout=timeout) + + +def transient(error): + if isinstance(error, urllib.error.HTTPError): + return error.code in (408, 429, 500, 502, 503, 504) + return isinstance(error, (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead)) def fetch(source, name): - return urllib.request.build_opener(NoRedirect()).open(source + "/node-install/" + name, timeout=30) + for attempt in range(3): + try: + with open_request(source + "/node-install/" + name) as response: + raw = response.read(1024 * 1024 + 1) + if len(raw) > 1024 * 1024: + raise InstallError("Node bootstrap metadata is too large: " + name) + return io.BytesIO(raw) + except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead) as error: + if not transient(error) or attempt == 2: + status = " (HTTP " + str(error.code) + ")" if isinstance(error, urllib.error.HTTPError) else "" + raise InstallError("Cannot download node metadata " + name + status + "; check the console URL, TLS and network, then rerun") from None + time.sleep(attempt + 1) def metadata(source): @@ -97,6 +124,10 @@ def metadata(source): if (manifest.get("platform") != "linux/amd64" or not re.fullmatch(r"[0-9a-f]{40}", manifest.get("source_commit", "")) or not re.fullmatch(r"sha256:[0-9a-f]{64}", manifest.get("images", {}).get("runtime", ""))): raise InstallError("Unsupported node distribution") + for name in (COMMON[0], "images/runtime.tar.gz") + MICRO: + distribution.artifact(manifest, name) + if not manifest.get("artifact_base_url"): + manifest["artifact_base_url"] = source + "/node-install/artifacts" return manifest, sums @@ -197,8 +228,15 @@ def provider_config(root, args, manifest): def prepare_runtime(root, args, manifest): if args.provider == "docker": docker = ["docker", "--host", "unix:///var/run/docker.sock"] - checked(docker + ["load", "--input", str(root / "images/runtime.tar")], "Cannot import the Docker runtime image", timeout=1800) - image = checked(docker + ["image", "inspect", "--format", "{{.Id}}", manifest["images"]["runtime"]], "Cannot verify the runtime image") + inspect = docker + ["image", "inspect", "--format", "{{.Id}}", manifest["images"]["runtime"]] + try: + image = checked(inspect, "Runtime image is not installed") + except InstallError: + image = None + if image != manifest["images"]["runtime"]: + archive = distribution.runtime_archive(manifest, root) + checked(docker + ["load", "--input", str(archive)], "Cannot import the Docker runtime image; check Docker access and free disk space", timeout=1800) + image = checked(inspect, "Cannot verify the imported runtime image") if image != manifest["images"]["runtime"]: raise InstallError("Imported runtime image identity differs") network = "parsar-node-" + args.installation_id @@ -211,9 +249,22 @@ def prepare_runtime(root, args, manifest): output = result.stdout.decode() if "not found" in output or (result.returncode and "statically linked" not in output and "not a dynamic executable" not in output): raise InstallError("Install the microsandbox host shared-library prerequisites") - checked([str(root / MICRO[1]), "image", "load", "--input", str(root / "images/runtime.tar"), "--tag", manifest["runtime_ref"], "--quiet"], - "Cannot import the microsandbox runtime image", timeout=1800, - env=dict(os.environ, MSB_BACKEND="local", MSB_HOME=str(micro_home(args.installation_id)), MSB_PATH=str(root / MICRO[1]), MSB_LIBKRUNFW_PATH=str(root / MICRO[2]))) + env = dict(os.environ, MSB_BACKEND="local", MSB_HOME=str(micro_home(args.installation_id)), + MSB_PATH=str(root / MICRO[1]), MSB_LIBKRUNFW_PATH=str(root / MICRO[2])) + inspect = [str(root / MICRO[1]), "image", "inspect", manifest["runtime_ref"], "--format", "json"] + def matches(): + try: + value = json.loads(checked(inspect, "Runtime image is not installed", env=env)) + return (value.get("digest") == manifest["runtime_ref"].split("@", 1)[1] + and value.get("architecture") == "amd64" and value.get("os") == "linux") + except (InstallError, ValueError, AttributeError): + return False + if not matches(): + archive = distribution.runtime_archive(manifest, root) + checked([str(root / MICRO[1]), "image", "load", "--input", str(archive), "--tag", manifest["runtime_ref"], "--quiet"], + "Cannot import the microsandbox runtime image; check free disk space and host libraries", timeout=1800, env=env) + if not matches(): + raise InstallError("Imported microsandbox runtime image identity or platform differs") def service_unit(root): @@ -246,13 +297,20 @@ def install(args, token): print("Downloading and verifying node files...", flush=True) manifest, sums = metadata(args.source_url) names = COMMON + (MICRO if args.provider == "microsandbox" else ()) - if any(name not in sums for name in names): + if "runtime/seccomp.json" not in sums: raise InstallError("The distribution is missing required node checksums") state = {"installation_id": args.installation_id, "provider": args.provider, "core_url": args.core_url, "source_commit": manifest["source_commit"]} write_once(root / "installation.json", json_text(state)) for name in names: - download(args.source_url, name, root, sums[name]) + if name == "runtime/seccomp.json": + download(args.source_url, name, root, sums[name]) + else: + target = root / name + safe_directory(target.parent) + existing_file(target) + distribution.obtain_artifact(manifest, name, target) + os.chmod(target, 0o700) safe_directory(root / "state/node") # Retain the original network policy when recovering a partial installation. if not existing_file(root / "provider.json"): @@ -260,9 +318,9 @@ def install(args, token): unit = root / ("parsar-node-" + args.installation_id + ".service") write_once(unit, service_unit(root)) marker = root / "registered.json" + print("Checking the sandbox runtime...", flush=True) + prepare_runtime(root, args, manifest) if not existing_file(marker): - print("Preparing the sandbox runtime...", flush=True) - prepare_runtime(root, args, manifest) # The one-time credential is never passed through process arguments or service environments. descriptor, secret_path = tempfile.mkstemp(prefix=".enrollment-", dir=root) try: @@ -271,7 +329,7 @@ def install(args, 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(), "--max-active", "4", "--max-retained", "16", - "--enrollment-token-file", secret_path], "Node enrollment was not confirmed. Keep its state and rerun the command to recover.") + "--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.") write_once(marker, json_text(state)) finally: if os.path.exists(secret_path): @@ -282,7 +340,55 @@ def install(args, token): checked(["systemctl", "--user", "daemon-reload"], "Cannot reload the systemd user manager") checked(["systemctl", "--user", "enable", "--now", str(unit)], "Cannot start the node service; retained identity is unchanged") checked(["systemctl", "--user", "is-active", "--quiet", unit.name], "Node service is unavailable; inspect its systemd user journal") - print("Node service started. The Web console will show connection and provider readiness. State: " + str(root)) + print("Waiting for Core connection and provider readiness...", flush=True) + wait_ready(root, args) + print("Node connected to Core and provider ready. State: " + str(root)) + + +def wait_ready(root, args, timeout=60): + identity_file = root / "state/node/identity.json" + if not existing_file(identity_file) or stat.S_IMODE(identity_file.stat().st_mode) != 0o600: + raise InstallError("Retained node identity is missing or is not private (0600); preserve state and inspect enrollment") + with identity_file.open() as stream: + raw = stream.read(16385) + try: + stored = json.loads(raw) + identity = stored["identity"] + credential = stored["credential"] + if (len(raw) > 16384 or stored["core_url"] != args.core_url + or identity["installation_id"] != args.installation_id or identity["provider"] != args.provider + or str(uuid.UUID(identity["node_id"])) != identity["node_id"] + or not re.fullmatch(r"[0-9a-f]{64}", credential)): + raise ValueError() + except (ValueError, KeyError, TypeError, AttributeError): + raise InstallError("Retained node identity differs or is invalid; preserve state and inspect enrollment") from None + request = urllib.request.Request(args.core_url + "/core/v1/sandbox/node/identity?" + urlencode({"node_id": identity["node_id"]}), + headers={"Authorization": "Bearer " + credential}) + deadline = time.monotonic() + timeout + detail = "Core has not confirmed the node connection" + while time.monotonic() < deadline: + try: + with open_request(request, timeout=min(10, max(0.1, deadline - time.monotonic()))) as response: + raw = response.read(16385) + if len(raw) > 16384: + raise ValueError() + data = json.loads(raw) + if any(data.get(key) != identity[key] for key in ("node_id", "installation_id", "provider")): + raise InstallError("Core returned a different node identity; preserve state and inspect the Core URL") + if data.get("connected") is True and data.get("provider_ready") is True: + return + detail = "Node is connected but its provider is not ready" if data.get("connected") is True else "Core has not confirmed the node connection" + except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead) as error: + if not transient(error): + raise InstallError("Core rejected the node readiness request (HTTP " + str(error.code) + "); verify its retained credential and Core URL") from None + detail = "Core readiness endpoint is temporarily unreachable; check TLS and network access" + except (ValueError, AttributeError): + raise InstallError("Core returned invalid node readiness data; check the matched Core release") from None + remaining = deadline - time.monotonic() + if remaining > 0: + time.sleep(min(2, remaining)) + raise InstallError(detail + "; state and service are retained. Inspect journalctl --user -u parsar-node-" + + args.installation_id + ".service, then rerun the installation command") def main(argv=None): @@ -303,6 +409,6 @@ def main(argv=None): if __name__ == "__main__": try: main() - except (InstallError, OSError, ValueError, KeyError, subprocess.SubprocessError) as error: - print(str(error) if isinstance(error, InstallError) else "Node installation failed; check host prerequisites and retained private files", file=sys.stderr) + except (InstallError, distribution.DistributionError, OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + print(str(error) if isinstance(error, (InstallError, distribution.DistributionError)) else "Node installation failed; check host prerequisites and retained private files", file=sys.stderr) sys.exit(1) diff --git a/deploy/install/self_hosted_install.py b/deploy/install/self_hosted_install.py new file mode 100644 index 000000000..cdb36d4de --- /dev/null +++ b/deploy/install/self_hosted_install.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Start one user-owned V1 Runtime from the connected Core's distribution.""" +import argparse +import fcntl +import getpass +import json +import os +from pathlib import Path +import platform +import re +import stat +import subprocess +import sys +from urllib.parse import urlsplit +import uuid + +from distribution import DistributionError, load_manifest, obtain_artifact, runtime_archive + + +class InstallError(Exception): + pass + + +def canonical_uuid(value): + try: + return isinstance(value, str) and str(uuid.UUID(value)) == value and uuid.UUID(value).int != 0 + except ValueError: + return False + + +def identity(environment, remote): + try: + address = urlsplit(remote) + address.port + if (not canonical_uuid(environment) or address.scheme != 'wss' or not address.hostname + or address.username is not None or address.password is not None + or address.path != '/api/v1/agent-daemon/ws' or any(c in remote for c in '?#\\') + or any(c.isspace() for c in remote)): + raise ValueError() + except (ValueError, TypeError): + raise InstallError('Use the returned Environment UUID and unchanged reachable wss remote_url') from None + return {'environment_id': environment, 'remote_url': remote} + + +def private_read(path): + path = Path(path) + if not path.is_absolute() or path.resolve() != path: + raise InstallError('Credential and state files must have absolute paths without symlinks') + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, 'rb') as stream: + info = os.fstat(stream.fileno()) + if (not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600 + or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_size > 16384): + raise InstallError('Credential and state files must be private mode-0600 owned regular files') + return stream.read(16385) + except OSError: + raise InstallError('Cannot read private credential or state file') from None + + +def credential(raw, environment): + try: + if len(raw) > 16384: + raise ValueError() + value = json.loads(raw) + if (not isinstance(value, dict) or set(value) != {'key_id', 'environment_id', 'executor_token'} + or not canonical_uuid(value['key_id']) or value['environment_id'] != environment + or not isinstance(value['executor_token'], str) or not value['executor_token'] + or any(c.isspace() or c == '\x00' for c in value['executor_token'])): + raise ValueError() + except (ValueError, TypeError, KeyError): + raise InstallError('Invalid restricted executor credential JSON') from None + return value + + +def write_private(path, value): + data = json.dumps(value).encode() + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + except OSError: + raise InstallError('Cannot write private Runtime installation state; inspect retained files') from None + + +def checked(command, message, timeout=30): + try: + result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=timeout, check=False) + if result.returncode: + raise InstallError(message) + return result.stdout.decode() + except (OSError, subprocess.SubprocessError, UnicodeError): + raise InstallError(message) from None + + +def preflight(): + 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 Python 3.9+ as a non-root Linux amd64 user with Docker access') + checked(['docker', '--host', 'unix:///var/run/docker.sock', 'info', '--format', '{{.ServerVersion}}'], + 'Docker access through /var/run/docker.sock is required') + + +def inspect_prior_launch(root, state): + docker = 'docker --host unix:///var/run/docker.sock' + filters = (' --filter label=io.parsar.agents-api.installation=' + state['installation_id'] + + ' --filter label=io.parsar.agents-api.environment=' + state['environment_id']) + guidance = (' Inspect retained resources with: ' + docker + ' ps -a' + filters + + '; ' + docker + ' volume ls' + filters + + '. Do not delete the receipt or create replacement history.') + receipt = root / 'started.json' + if not receipt.exists(): + raise InstallError('A Runtime launch was already attempted without confirmed startup.' + guidance) + prior = json.loads(private_read(receipt)) + name = prior.get('container', '') if isinstance(prior, dict) else '' + if not re.fullmatch(r'parsar-selfhost-[0-9a-f]{32}', name) or prior.get('status') != 'started': + raise InstallError('Invalid retained Runtime startup receipt.' + guidance) + try: + raw = checked(['docker', '--host', 'unix:///var/run/docker.sock', 'container', 'inspect', name, + '--format', '{{json .Config.Labels}} {{.State.Status}}'], 'Cannot inspect retained Runtime') + labels, status = raw.strip().rsplit(' ', 1) + labels = json.loads(labels) + except (InstallError, ValueError): + raise InstallError('The prior Runtime cannot be confirmed.' + guidance) from None + expected = {'io.parsar.agents-api.installation': state['installation_id'], + 'io.parsar.agents-api.environment': state['environment_id'], + 'io.parsar.agents-api.user-owned': 'true'} + if not isinstance(labels, dict) or any(labels.get(key) != value for key, value in expected.items()): + raise InstallError('The retained container does not match this installation and Environment.' + guidance) + if status == 'running': + print('Runtime already running: ' + name) + print('Read the Session to confirm connection; a running container does not establish it.') + return + if status == 'exited': + raise InstallError('The existing Runtime is stopped. Preserve its history and resume that same container with: ' + + docker + ' start ' + name + '. Then read the Session to confirm connection.') + raise InstallError('The retained Runtime requires inspection before continuing.' + guidance) + + +def install(args, root): + target = identity(args.environment_id, args.remote) + manifest = load_manifest(source_url=args.source_url, offline_root=args.offline_root) + revision = manifest.get('source_commit', '') + runtime_image = manifest.get('images', {}).get('runtime', '') + if (manifest.get('platform') != 'linux/amd64' or not re.fullmatch(r'[0-9a-f]{40}', revision) + or not re.fullmatch(r'sha256:[0-9a-f]{64}', runtime_image)): + raise InstallError('The distribution does not contain a matched Linux amd64 Runtime') + target.update(source_commit=revision, runtime_image=runtime_image) + state_file = root / 'installation.json' + if state_file.exists(): + state = json.loads(private_read(state_file)) + if (not isinstance(state, dict) or set(state) != set(target) | {'installation_id'} + or any(state.get(key) != value for key, value in target.items()) + or not canonical_uuid(state['installation_id'])): + raise InstallError('This installation belongs to another Environment or distribution') + else: + state = dict(target, installation_id=str(uuid.uuid4())) + write_private(state_file, state) + if (root / 'launch.json').exists() or (root / 'launch.json').is_symlink(): + inspect_prior_launch(root, state) + return + key_file = root / 'executor-key.json' + if key_file.exists(): + key = credential(private_read(key_file), args.environment_id) + if args.credential_file and credential(private_read(Path(args.credential_file)), args.environment_id) != key: + raise InstallError('Stored executor credential differs; inspect the existing installation') + else: + if not args.credential_file and not sys.stdin.isatty(): + raise InstallError('Use --credential-file in noninteractive sessions; credentials are never echoed') + raw = private_read(Path(args.credential_file)) if args.credential_file else getpass.getpass('Restricted executor credential JSON (hidden): ') + key = credential(raw, args.environment_id) + write_private(key_file, key) + launcher = obtain_artifact(manifest, 'native/bin/parsar-runtime', root / 'native/bin/parsar-runtime', args.offline_root) + seccomp = obtain_artifact(manifest, 'runtime/seccomp.json', root / 'runtime/seccomp.json', args.offline_root) + inspect = ['docker', '--host', 'unix:///var/run/docker.sock', 'image', 'inspect', runtime_image, + '--format', '{{.Id}} {{.Os}}/{{.Architecture}}'] + try: + image = checked(inspect, 'Runtime image is not installed').strip() + except InstallError: + image = None + if image != runtime_image + ' linux/amd64': + archive = runtime_archive(manifest, root, args.offline_root) + checked(['docker', '--host', 'unix:///var/run/docker.sock', 'image', 'load', '--input', str(archive)], + 'Cannot load the matched Runtime image; retry after checking Docker', timeout=600) + image = checked(inspect, 'Cannot verify the loaded Runtime image').strip() + if image != runtime_image + ' linux/amd64': + raise InstallError('Loaded Runtime image does not match the distribution') + command = [str(launcher), '--installation-id', state['installation_id'], + '--environment-id', args.environment_id, '--remote', args.remote, + '--image', runtime_image, '--seccomp-file', str(seccomp), '--credential-file', str(key_file)] + # Persist the attempt before Docker mutation. Uncertain outcomes require + # inspection, never another bootstrap or replacement native history. + write_private(root / 'launch.json', {'installation_id': state['installation_id'], 'environment_id': args.environment_id}) + result = json.loads(checked(command, 'Runtime launch failed or is uncertain. Inspect retained installation state and Docker containers', timeout=150)) + if (not isinstance(result, dict) or result.get('status') != 'started' + or not re.fullmatch(r'parsar-selfhost-[0-9a-f]{32}', result.get('container', ''))): + raise InstallError('Runtime launcher returned an invalid result; inspect the retained container') + write_private(root / 'started.json', result) + print('Runtime started: ' + result['container']) + print('Read the Session to confirm connection. Stop this user-owned Runtime with: docker stop ' + result['container']) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument('--source-url', help='Core console HTTPS origin') + source.add_argument('--offline-root', type=Path, help='matching extracted offline distribution') + parser.add_argument('--environment-id', required=True) + parser.add_argument('--remote', required=True, help='unchanged Session environment remote_url') + parser.add_argument('--credential-file', help='absolute private restricted executor credential JSON') + parser.add_argument('--install-dir', type=Path) + args = parser.parse_args() + preflight() + identity(args.environment_id, args.remote) + root = args.install_dir or Path.home() / '.parsar/self-hosted' / args.environment_id + if not root.is_absolute() or root.resolve() != root: + raise InstallError('Installation directory must be absolute and have no symlinks') + root.mkdir(mode=0o700, parents=True, exist_ok=True) + info = root.stat() + if not root.is_dir() or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700: + raise InstallError('Installation directory must be owned by this user and mode 0700') + lock = os.open(root / '.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + with os.fdopen(lock, 'rb') as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1: + raise InstallError('Installation lock must be private and owned') + try: + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise InstallError('Another installation is already running') from None + install(args, root) + + +if __name__ == '__main__': + try: + main() + except (InstallError, DistributionError) as error: + raise SystemExit(str(error)) from None + except (OSError, ValueError, KeyError, TypeError): + raise SystemExit('Runtime installation failed; inspect the retained installation state') from None diff --git a/deploy/install/test_distribution.py b/deploy/install/test_distribution.py new file mode 100644 index 000000000..6d785d476 --- /dev/null +++ b/deploy/install/test_distribution.py @@ -0,0 +1,123 @@ +"""Exercise artifact transport, integrity and safe retry using real local HTTP.""" +import gzip +import hashlib +import http.server +import json +from pathlib import Path +import tempfile +import threading +import unittest +from unittest.mock import patch + +import distribution + + +class ArtifactTests(unittest.TestCase): + def setUp(self): + root = Path.home() / '.parsar/tests/distribution' + root.mkdir(parents=True, exist_ok=True) + self.temp = tempfile.TemporaryDirectory(dir=root) + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + self.data = b'prebuilt artifact' * 1000 + self.requests = [] + self.status = 200 + test = self + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + test.requests.append(self.path) + self.send_response(test.status) + self.end_headers() + if test.status == 200: + self.wfile.write(test.data) + def log_message(self, *args): + pass + self.server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.addCleanup(self.stop) + self.manifest = {'source_commit': 'a' * 40, 'artifact_base_url': f'http://127.0.0.1:{self.server.server_port}', 'artifacts': {}} + self.entry('native/bin/node', self.data) + + def stop(self): + self.server.shutdown() + self.server.server_close() + self.thread.join() + + def entry(self, name, data): + value = {'filename': 'parsar-core-' + 'a' * 40 + '-linux-amd64-' + name.replace('/', '-'), + 'sha256': hashlib.sha256(data).hexdigest(), 'size': len(data)} + self.manifest['artifacts'][name] = value + return value + + def test_download_and_verified_warm_cache(self): + target = self.root / 'node' + distribution.obtain_artifact(self.manifest, 'native/bin/node', target) + self.assertEqual(target.read_bytes(), self.data) + self.assertEqual(target.stat().st_mode & 0o777, 0o700) + distribution.obtain_artifact(self.manifest, 'native/bin/node', target) + self.assertEqual(len(self.requests), 1) + target.write_bytes(b'changed') + with self.assertRaisesRegex(distribution.DistributionError, 'differs'): + distribution.obtain_artifact(self.manifest, 'native/bin/node', target) + self.assertEqual(len(self.requests), 1) + + def test_failed_digest_does_not_install_or_retry(self): + self.data = b'x' * len(self.data) + with self.assertRaisesRegex(distribution.DistributionError, 'checksum'): + distribution.obtain_artifact(self.manifest, 'native/bin/node', self.root / 'node') + self.assertEqual(len(self.requests), 1) + self.assertEqual(list(self.root.iterdir()), []) + + def test_transient_http_retries_are_bounded(self): + self.status = 503 + with patch.object(distribution.time, 'sleep'), self.assertRaisesRegex(distribution.DistributionError, 'HTTP 503'): + distribution.obtain_artifact(self.manifest, 'native/bin/node', self.root / 'node') + self.assertEqual(len(self.requests), 3) + self.assertFalse((self.root / 'node').exists()) + + def test_auth_rejection_is_not_retried(self): + self.status = 403 + with self.assertRaisesRegex(distribution.DistributionError, 'HTTP 403'): + distribution.obtain_artifact(self.manifest, 'native/bin/node', self.root / 'node') + self.assertEqual(len(self.requests), 1) + + def test_truncated_transfer_can_be_rerun(self): + self.data = self.data[:20] + with patch.object(distribution.time, 'sleep'), self.assertRaisesRegex(distribution.DistributionError, 'interrupted'): + distribution.obtain_artifact(self.manifest, 'native/bin/node', self.root / 'node') + self.assertEqual(list(self.root.iterdir()), []) + self.data = b'prebuilt artifact' * 1000 + distribution.obtain_artifact(self.manifest, 'native/bin/node', self.root / 'node') + + def test_offline_and_runtime_expansion_are_verified(self): + raw = b'synthetic tar contents' * 1000 + self.data = gzip.compress(raw) + entry = self.entry('images/runtime.tar.gz', self.data) + entry.update(unpacked_size=len(raw), unpacked_sha256=hashlib.sha256(raw).hexdigest()) + offline = self.root / 'offline' + (offline / 'artifacts').mkdir(parents=True) + (offline / 'artifacts' / entry['filename']).write_bytes(self.data) + output = distribution.runtime_archive(self.manifest, self.root / 'cache', offline) + self.assertEqual(output.read_bytes(), raw) + self.assertEqual(self.requests, []) + self.assertEqual(distribution.runtime_archive(self.manifest, self.root / 'cache'), output) + output.write_bytes(b'bad cache') + with self.assertRaisesRegex(distribution.DistributionError, 'differs'): + distribution.runtime_archive(self.manifest, self.root / 'cache') + + def test_url_and_path_boundaries(self): + for value in ('http://example.com/a', 'https://user:secret@example.com/a', 'file:///tmp/a'): + with self.assertRaises(distribution.DistributionError): + distribution.safe_url(value) + self.manifest['artifacts']['native/bin/node']['filename'] = '../escape' + with self.assertRaises(distribution.DistributionError): + distribution.obtain_artifact(self.manifest, 'native/bin/node', self.root / 'node') + self.entry('native/bin/node', self.data) + (self.root / 'link').symlink_to(self.root / 'target') + with self.assertRaises(distribution.DistributionError): + distribution.obtain_artifact(self.manifest, 'native/bin/node', self.root / 'link') + + +if __name__ == '__main__': + unittest.main() diff --git a/deploy/install/test_install.py b/deploy/install/test_install.py index e195aceb2..9370b3a89 100644 --- a/deploy/install/test_install.py +++ b/deploy/install/test_install.py @@ -4,6 +4,7 @@ import base64 import contextlib import hashlib +import gzip import io import json import os @@ -83,8 +84,17 @@ def bundle(self): (bundle / "runtime").mkdir() (bundle / "manifest.json").write_text(json.dumps(self.manifest)) (bundle / "runtime/seccomp.json").write_text('{"defaultAction":"SCMP_ACT_ERRNO"}') - for name in ("install.py", "configuration.py", "native_service.py", "node_install.py", "install.sh"): + for name in ("install.py", "configuration.py", "native_service.py", "distribution.py", "install.sh"): shutil.copyfile(Path(__file__).with_name(name), bundle / name) + for name in ("node-install.pyz", "self-hosted-install.pyz"): + (bundle / name).write_bytes(b"synthetic verified Python bootstrap") + self.manifest["artifacts"] = {} + for name in ("images/runtime.tar.gz", "native/bin/parsar-sandbox-node", + "native/bin/agents-api-microsandbox-provider", "native/microsandbox/msb", + "native/microsandbox/libkrunfw.so.5.6.1"): + self.manifest["artifacts"][name] = {"filename": "parsar-" + "a" * 40 + "-" + name.replace("/", "-"), + "sha256": "a" * 64, "size": 1} + (bundle / "manifest.json").write_text(json.dumps(self.manifest)) for name in self.manifest["images"]: (bundle / "images" / (name + ".tar")).write_bytes(("synthetic " + name).encode()) for name in ("bin/agents-api", "bin/agents-api-migrate", "bin/agents-api-microsandbox-provider", @@ -102,19 +112,32 @@ def write_checksums(bundle): hashlib.sha256(path.read_bytes()).hexdigest() + " " + str(path.relative_to(bundle)) + "\n" for path in files)) + def test_thin_bundle_verifies_without_downloading_runtime(self): + bundle = self.bundle() + for name in ("images/runtime.tar", "native/bin/parsar-sandbox-node", + "native/bin/agents-api-microsandbox-provider", "native/microsandbox/msb", + "native/microsandbox/libkrunfw.so.5.6.1"): + (bundle / name).unlink() + self.write_checksums(bundle) + with mock.patch.object(install, "obtain_artifact", side_effect=AssertionError("unneeded download")): + manifest = install.verify_bundle(bundle) + install.prepare_node_payload(self.root, self.initialize(), bundle) + self.assertEqual(manifest["source_commit"], "a" * 40) + self.assertFalse((self.root / "node-payload/images").exists()) + def test_node_payload_exports_only_matched_distribution_files(self): state = self.initialize() bundle = self.bundle() install.prepare_node_payload(self.root, state, bundle) payload = self.root / "node-payload" exported = {str(path.relative_to(payload)) for path in payload.rglob("*") if path.is_file()} - self.assertEqual(len(exported), 9) + self.assertEqual(len(exported), 5) self.assertNotIn("config/caller.key", exported) self.assertNotIn("admin/sandbox-admin.key", exported) for name in exported: self.assertEqual((payload / name).read_bytes(), (bundle / name).read_bytes()) install.prepare_node_payload(self.root, state, bundle) - (payload / "node_install.py").write_text("changed") + (payload / "node-install.pyz").write_text("changed") with self.assertRaises(install.InstallError): install.prepare_node_payload(self.root, state, bundle) @@ -127,10 +150,10 @@ def interrupted(source, target): with mock.patch.object(install.shutil, "copyfile", side_effect=interrupted): with self.assertRaises(OSError): install.prepare_node_payload(self.root, state, bundle) - self.assertFalse((self.root / "node-payload/node_install.py").exists()) + self.assertFalse((self.root / "node-payload/node-install.pyz").exists()) install.prepare_node_payload(self.root, state, bundle) - self.assertEqual((self.root / "node-payload/node_install.py").read_bytes(), - (bundle / "node_install.py").read_bytes()) + self.assertEqual((self.root / "node-payload/node-install.pyz").read_bytes(), + (bundle / "node-install.pyz").read_bytes()) def test_repeat_installation_preserves_execution_identity_and_all_secrets(self): first = self.initialize() @@ -350,7 +373,7 @@ def test_bundle_rejects_omitted_or_duplicate_checksum_entries(self): bundle = self.bundle() checksums = bundle / "SHA256SUMS" original = checksums.read_text() - for name in ("images/runtime.tar", "native/bin/parsar-sandbox-node"): + for name in ("images/core.tar", "node-install.pyz"): checksums.write_text("".join(line + "\n" for line in original.splitlines() if not line.endswith(" " + name))) with self.subTest(name=name), self.assertRaises(install.InstallError): @@ -404,6 +427,51 @@ def test_provider_requires_explicit_enablement_and_cannot_belong_to_web_only(sel with self.subTest(flags=flags), contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): self.args(*flags) + def test_local_microsandbox_accepts_actual_thin_and_offline_native_layouts(self): + for offline in (False, True): + with self.subTest(offline=offline): + bundle = self.bundle() + payloads = {} + for name, entry in self.manifest["artifacts"].items(): + payload = gzip.compress(b"runtime fixture") if name == "images/runtime.tar.gz" else b"\x7fELFfixture" + payloads[entry["filename"]] = payload + entry.update(sha256=hashlib.sha256(payload).hexdigest(), size=len(payload)) + if name == "images/runtime.tar.gz": + entry.update(unpacked_sha256=hashlib.sha256(b"runtime fixture").hexdigest(), unpacked_size=len(b"runtime fixture")) + if offline: + target = bundle / "artifacts" / entry["filename"] + target.parent.mkdir(exist_ok=True) + target.write_bytes(payload) + else: + self.manifest["artifact_base_url"] = "https://release.example/immutable" + (bundle / name.removesuffix(".gz")).unlink() + (bundle / "manifest.json").write_text(json.dumps(self.manifest)) + self.write_checksums(bundle) + requested = [] + def response(url, **kwargs): + requested.append(url.rsplit("/", 1)[-1]) + return io.BytesIO(payloads[requested[-1]]) + def host_command(arguments, failure): + return SimpleNamespace(returncode=0, stdout="yes" if arguments[0] == "loginctl" else "", stderr="") + with mock.patch.object(install, "__file__", str(bundle / "install.py")), \ + mock.patch.object(install.platform, "system", return_value="Linux"), \ + mock.patch.object(install.platform, "machine", return_value="x86_64"), \ + mock.patch.object(install.os, "access", return_value=True), \ + mock.patch.object(install, "run", return_value=SimpleNamespace(stdout="", returncode=0)), \ + mock.patch.object(install, "wait_http", return_value=True), \ + mock.patch.object(install, "import_runtime"), \ + mock.patch.object(install.native_service, "_run", side_effect=host_command), \ + mock.patch("distribution.urllib.request.build_opener", return_value=SimpleNamespace(open=response)), \ + contextlib.redirect_stdout(io.StringIO()): + install.main(["--install-dir", str(self.root), "--sandbox-provider", "true", "--provider", "microsandbox"]) + self.assertTrue((self.root / "native/bin/agents-api").is_file()) + self.assertTrue((self.root / "native/bin/agents-api-microsandbox-provider").is_file()) + self.assertFalse((self.root / "native/bin/parsar-sandbox-node").exists()) + self.assertNotIn(self.manifest["artifacts"]["native/bin/parsar-sandbox-node"]["filename"], requested) + self.assertEqual(len(requested), 0 if offline else 4) + shutil.rmtree(bundle) + shutil.rmtree(self.root) + def test_default_main_skips_kvm_native_service_and_runtime_import(self): bundle = self.bundle() calls = [] diff --git a/deploy/install/test_native_service.py b/deploy/install/test_native_service.py index d7e438503..5ee445155 100644 --- a/deploy/install/test_native_service.py +++ b/deploy/install/test_native_service.py @@ -89,7 +89,7 @@ def test_private_files_and_environment_literal_values(self): for path in (env, self.unit_path(), self.root / "native/microsandbox/libkrunfw.so.5.6.1"): self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) for path in (self.root / "config", self.root / "native/bin/agents-api", - self.root / "native/bin/agents-api-microsandbox-provider", self.root / "native/bin/parsar-sandbox-node", + self.root / "native/bin/agents-api-microsandbox-provider", self.root / "native/microsandbox/msb"): self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o700) diff --git a/deploy/install/test_node_install.py b/deploy/install/test_node_install.py index 9ec759c74..bb8aaee87 100644 --- a/deploy/install/test_node_install.py +++ b/deploy/install/test_node_install.py @@ -1,6 +1,7 @@ """Exercise node installation without running providers or changing user services.""" import argparse import hashlib +import gzip import io import json import os @@ -9,6 +10,7 @@ import subprocess import tempfile import unittest +import urllib.error from unittest import mock import node_install as installer @@ -28,12 +30,16 @@ def setUp(self): "runtime_ref": "parsar-core-runtime@sha256:" + "c" * 64, "microsandbox": {"runtime_sha256": "d" * 64, "firmware_sha256": "e" * 64}} self.payloads = {name: b"fixture-payload-" + name.encode() for name in installer.COMMON + installer.MICRO} + self.payloads["images/runtime.tar.gz"] = gzip.compress(b"runtime archive") self.refresh_manifest() + self.image_present = False self.calls = [] self.fail_service = False self.fail_registration = False for patch in (mock.patch.object(installer.Path, "home", return_value=self.home), mock.patch.object(installer, "preflight"), + mock.patch.object(installer, "wait_ready"), + mock.patch.object(installer.distribution.urllib.request, "build_opener", return_value=mock.Mock(open=self.artifact_response)), mock.patch.object(installer, "micro_home", return_value=self.home / "m"), mock.patch.object(installer, "fetch", side_effect=lambda source, name: io.BytesIO(self.payloads[name])), mock.patch.object(installer, "checked", side_effect=self.checked), @@ -41,7 +47,18 @@ def setUp(self): patch.start() self.addCleanup(patch.stop) + def artifact_response(self, url, **kwargs): + for name, item in self.manifest["artifacts"].items(): + if url.endswith("/" + item["filename"]): + return io.BytesIO(self.payloads[name]) + raise AssertionError("Unexpected artifact URL: " + url) + def refresh_manifest(self): + self.manifest["artifact_base_url"] = "https://release.example/immutable" + self.manifest["artifacts"] = {name: {"filename": name.replace("/", "-") + "-" + self.manifest["source_commit"], + "size": len(raw), "sha256": hashlib.sha256(raw).hexdigest()} + for name, raw in self.payloads.items() if name.startswith("native/") or name == "images/runtime.tar.gz"} + self.manifest["artifacts"]["images/runtime.tar.gz"].update(unpacked_sha256=hashlib.sha256(b"runtime archive").hexdigest(), unpacked_size=len(b"runtime archive")) self.payloads["manifest.json"] = json.dumps(self.manifest).encode() self.payloads["SHA256SUMS"] = "".join(hashlib.sha256(raw).hexdigest() + " " + name + "\n" for name, raw in self.payloads.items() if name != "SHA256SUMS").encode() @@ -58,7 +75,15 @@ def checked(self, arguments, failure, **kwargs): raise installer.InstallError(failure) if "enable" in arguments and self.fail_service: raise installer.InstallError(failure) - return self.manifest["images"]["runtime"] if "inspect" in arguments else "" + if "load" in arguments: + self.image_present = True + if "inspect" in arguments: + if not self.image_present: + raise installer.InstallError(failure) + if arguments[0] == "docker": + return self.manifest["images"]["runtime"] + return json.dumps({"digest": self.manifest["runtime_ref"].split("@", 1)[1], "os": "linux", "architecture": "amd64"}) + return "" def install(self): installer.install(self.args, "synthetic-once-token") @@ -144,7 +169,7 @@ def test_corrupt_manifest_and_payload_fail_before_provider_use(self): self.install() self.refresh_manifest() self.payloads[installer.COMMON[0]] += b"corrupt" - with self.assertRaisesRegex(installer.InstallError, "payload checksum"): + with self.assertRaisesRegex(installer.distribution.DistributionError, "published size|checksum"): self.install() self.assertFalse(self.calls) self.assertFalse((self.root / installer.COMMON[0]).exists()) @@ -153,10 +178,38 @@ def test_installed_payload_and_config_are_not_overwritten(self): self.install() target = self.root / installer.COMMON[0] target.write_bytes(b"existing-different-payload") - with self.assertRaisesRegex(installer.InstallError, "refusing to overwrite"): + with self.assertRaisesRegex(installer.distribution.DistributionError, "Cached artifact differs"): self.install() self.assertEqual(target.read_bytes(), b"existing-different-payload") + def test_warm_image_skips_archive_download_and_import_before_registration(self): + for provider in ("docker", "microsandbox"): + with self.subTest(provider=provider): + self.args.provider = provider + self.image_present = True + with mock.patch.object(installer.distribution, "runtime_archive") as archive: + installer.prepare_runtime(self.root, self.args, self.manifest) + archive.assert_not_called() + self.assertFalse(any("load" in call for call, _ in self.calls)) + + def test_retry_after_unconfirmed_enrollment_preserves_imported_image(self): + self.args.provider = "microsandbox" + self.fail_registration = True + with self.assertRaises(installer.InstallError): + self.install() + self.calls.clear() + self.fail_registration = False + self.install() + self.assertFalse(any("load" in call for call, _ in self.calls)) + + def test_registered_node_reimports_deleted_image_without_reenrollment(self): + self.install() + self.image_present = False + self.calls.clear() + self.install() + self.assertTrue(any("load" in call for call, _ in self.calls)) + self.assertFalse(any("register" in call for call, _ in self.calls)) + def test_symlink_installation_is_rejected(self): self.root.parent.mkdir(parents=True) elsewhere = self.home / "elsewhere" diff --git a/deploy/install/test_node_readiness.py b/deploy/install/test_node_readiness.py new file mode 100644 index 000000000..0e92b229a --- /dev/null +++ b/deploy/install/test_node_readiness.py @@ -0,0 +1,117 @@ +"""Verify bounded bootstrap reads and authenticated provider readiness.""" +import argparse +import io +import http.client +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock +import urllib.error + +import node_install as installer + + +class ReadinessTests(unittest.TestCase): + def setUp(self): + base = Path.home() / ".parsar/tests/node-readiness" + base.mkdir(parents=True, exist_ok=True) + temporary = tempfile.TemporaryDirectory(dir=base) + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.args = argparse.Namespace(core_url="https://core.example", provider="docker", + installation_id="94be54a1-138c-4f30-bc87-b13686272dbe") + self.identity = {"node_id": "634d97be-e54d-40f0-9468-ae6b62be85bf", "installation_id": self.args.installation_id, + "provider": self.args.provider} + self.path = self.root / "state/node/identity.json" + self.path.parent.mkdir(parents=True) + self.path.write_text(json.dumps({"identity": self.identity, "credential": "a" * 64, "core_url": self.args.core_url})) + self.path.chmod(0o600) + + def response(self, **fields): + return io.BytesIO(json.dumps(dict(self.identity, **fields)).encode()) + + def test_waits_for_both_connection_and_provider_without_restarting_service(self): + replies = [self.response(connected=False, provider_ready=True), self.response(connected=True, provider_ready=False), + self.response(connected=True, provider_ready=True)] + with mock.patch.object(installer, "open_request", side_effect=replies) as request, \ + mock.patch.object(installer.time, "sleep") as sleep: + installer.wait_ready(self.root, self.args) + self.assertEqual(request.call_count, 3) + self.assertEqual(sleep.call_count, 2) + self.assertEqual(request.call_args.args[0].get_header("Authorization"), "Bearer " + "a" * 64) + self.assertNotIn("a" * 64, request.call_args.args[0].full_url) + + def test_transient_readiness_failure_recovers_with_same_identity(self): + original = self.path.read_bytes() + failure = urllib.error.HTTPError("https://core.example", 503, "unavailable", {}, None) + with mock.patch.object(installer, "open_request", side_effect=[failure, self.response(connected=True, provider_ready=True)]), \ + mock.patch.object(installer.time, "sleep"): + installer.wait_ready(self.root, self.args) + self.assertEqual(self.path.read_bytes(), original) + + def test_permanent_authorization_failure_is_not_retried(self): + failure = urllib.error.HTTPError("https://core.example", 401, "private failure text", {}, None) + with mock.patch.object(installer, "open_request", side_effect=failure) as request, \ + mock.patch.object(installer.time, "sleep") as sleep: + with self.assertRaisesRegex(installer.InstallError, "HTTP 401"): + installer.wait_ready(self.root, self.args) + self.assertEqual(request.call_count, 1) + sleep.assert_not_called() + + def test_deadline_reports_provider_and_retains_identity(self): + original = self.path.read_bytes() + with mock.patch.object(installer, "open_request", return_value=self.response(connected=True, provider_ready=False)), \ + mock.patch.object(installer.time, "monotonic", side_effect=[0, 0, 0, 60, 60]): + with self.assertRaisesRegex(installer.InstallError, "provider is not ready.*journalctl"): + installer.wait_ready(self.root, self.args) + self.assertEqual(self.path.read_bytes(), original) + + def test_private_or_mismatched_identity_is_not_sent(self): + self.path.chmod(0o644) + with mock.patch.object(installer, "open_request") as request: + with self.assertRaises(installer.InstallError): + installer.wait_ready(self.root, self.args) + self.path.chmod(0o600) + self.args.core_url = "https://other.example" + with self.assertRaisesRegex(installer.InstallError, "identity differs"): + installer.wait_ready(self.root, self.args) + request.assert_not_called() + + def test_response_cannot_substitute_another_node(self): + with mock.patch.object(installer, "open_request", return_value=self.response(node_id="other", connected=True, provider_ready=True)): + with self.assertRaisesRegex(installer.InstallError, "different node identity"): + installer.wait_ready(self.root, self.args) + + +class MetadataRetryTests(unittest.TestCase): + def test_fetch_retries_transient_failure_with_bounded_delays(self): + failure = urllib.error.HTTPError("https://console.example", 503, "unavailable", {}, None) + with mock.patch.object(installer, "open_request", side_effect=[failure, failure, io.BytesIO(b"ok")]) as request, \ + mock.patch.object(installer.time, "sleep") as sleep: + with installer.fetch("https://console.example", "manifest.json") as response: + self.assertEqual(response.read(), b"ok") + self.assertEqual(request.call_count, 3) + self.assertEqual(sleep.call_args_list, [mock.call(1), mock.call(2)]) + + def test_fetch_retries_a_truncated_response_before_using_metadata(self): + truncated = mock.MagicMock() + truncated.__enter__.return_value.read.side_effect = http.client.IncompleteRead(b"partial", 100) + with mock.patch.object(installer, "open_request", side_effect=[truncated, io.BytesIO(b"complete")]) as request, \ + mock.patch.object(installer.time, "sleep"): + with installer.fetch("https://console.example", "manifest.json") as response: + self.assertEqual(response.read(), b"complete") + self.assertEqual(request.call_count, 2) + + def test_fetch_rejects_permanent_status_without_retry_or_response_body(self): + failure = urllib.error.HTTPError("https://console.example", 404, "private response body", {}, None) + with mock.patch.object(installer, "open_request", side_effect=failure) as request: + with self.assertRaisesRegex(installer.InstallError, "manifest.json.*HTTP 404") as caught: + installer.fetch("https://console.example", "manifest.json") + self.assertEqual(request.call_count, 1) + self.assertNotIn("private response body", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy/install/test_self_hosted_install.py b/deploy/install/test_self_hosted_install.py new file mode 100644 index 000000000..7c233fc59 --- /dev/null +++ b/deploy/install/test_self_hosted_install.py @@ -0,0 +1,148 @@ +import argparse +import contextlib +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import uuid + +import self_hosted_install as installer + + +class SelfHostedInstallTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.environment = str(uuid.uuid4()) + self.remote = 'wss://core.example/api/v1/agent-daemon/ws' + self.key = {'key_id': str(uuid.uuid4()), 'environment_id': self.environment, + 'executor_token': 'synthetic-private-token'} + self.source_key = self.root / 'source-key.json' + installer.write_private(self.source_key, self.key) + self.args = argparse.Namespace(source_url='https://core.example', offline_root=None, + environment_id=self.environment, remote=self.remote, credential_file=str(self.source_key)) + self.manifest = {'source_commit': 'a' * 40, 'platform': 'linux/amd64', + 'images': {'runtime': 'sha256:' + 'b' * 64}} + + def test_exact_restricted_credential_and_tls_identity(self): + self.assertEqual(installer.credential(json.dumps(self.key), self.environment), self.key) + for change in ({'environment_id': ''}, {'executor_token': ''}, {'key_id': 'bad'}, {'extra': True}): + with self.assertRaises(installer.InstallError): + installer.credential(json.dumps(dict(self.key, **change)), self.environment) + for remote in ('ws://localhost/api/v1/agent-daemon/ws', self.remote+'?token=secret', + 'wss://user:secret@core.example/api/v1/agent-daemon/ws', self.remote+'#fragment'): + with self.assertRaises(installer.InstallError): + installer.identity(self.environment, remote) + + def test_private_file_rejects_symlink_permissions_and_hardlinks(self): + self.assertEqual(json.loads(installer.private_read(self.source_key)), self.key) + self.source_key.chmod(0o644) + with self.assertRaises(installer.InstallError): installer.private_read(self.source_key) + self.source_key.chmod(0o600) + alias = self.root / 'alias' + alias.symlink_to(self.source_key) + with self.assertRaises(installer.InstallError): installer.private_read(alias) + alias.unlink() + os.link(self.source_key, alias) + with self.assertRaises(installer.InstallError): installer.private_read(self.source_key) + + def test_one_command_keeps_secrets_out_of_arguments_and_retains_attempt(self): + commands = [] + def checked(command, message, timeout=30): + commands.append(command) + self.assertNotIn(self.key['executor_token'], json.dumps(command)) + if 'inspect' in command: return self.manifest['images']['runtime'] + ' linux/amd64' + if command[0].endswith('parsar-runtime'): + self.assertTrue((self.root / 'launch.json').exists()) + return json.dumps({'container': 'parsar-selfhost-'+'c'*32, 'status': 'started'}) + return '' + with patch.object(installer, 'load_manifest', return_value=self.manifest), \ + patch.object(installer, 'obtain_artifact', side_effect=lambda m, n, dest, o: dest), \ + patch.object(installer, 'runtime_archive', return_value=self.root/'runtime.tar'), \ + patch.object(installer, 'checked', side_effect=checked), contextlib.redirect_stdout(io.StringIO()): + installer.install(self.args, self.root) + self.assertEqual(len(commands), 2) + self.assertEqual(json.loads(installer.private_read(self.root/'executor-key.json')), self.key) + with self.assertRaises(installer.InstallError): installer.install(self.args, self.root) + self.assertEqual(len(commands), 3) + + def test_cached_runtime_avoids_archive_download_and_load(self): + def checked(command, message, timeout=30): + self.assertNotIn('load', command) + if 'inspect' in command: return self.manifest['images']['runtime'] + ' linux/amd64' + return json.dumps({'container': 'parsar-selfhost-'+'c'*32, 'status': 'started'}) + with patch.object(installer, 'load_manifest', return_value=self.manifest), \ + patch.object(installer, 'obtain_artifact', side_effect=lambda m, n, dest, o: dest), \ + patch.object(installer, 'runtime_archive') as archive, \ + patch.object(installer, 'checked', side_effect=checked), contextlib.redirect_stdout(io.StringIO()): + installer.install(self.args, self.root) + archive.assert_not_called() + + def test_missing_runtime_loads_and_verifies_before_launch(self): + commands = [] + def checked(command, message, timeout=30): + commands.append(command) + if len(commands) == 1: raise installer.InstallError('image missing') + if 'inspect' in command: return self.manifest['images']['runtime'] + ' linux/amd64' + if command[0].endswith('parsar-runtime'): + return json.dumps({'container': 'parsar-selfhost-'+'c'*32, 'status': 'started'}) + return '' + with patch.object(installer, 'load_manifest', return_value=self.manifest), \ + patch.object(installer, 'obtain_artifact', side_effect=lambda m, n, dest, o: dest), \ + patch.object(installer, 'runtime_archive', return_value=self.root/'runtime.tar') as archive, \ + patch.object(installer, 'checked', side_effect=checked), contextlib.redirect_stdout(io.StringIO()): + installer.install(self.args, self.root) + archive.assert_called_once() + self.assertIn('load', commands[1]) + self.assertIn('inspect', commands[2]) + self.assertTrue(commands[3][0].endswith('parsar-runtime')) + + def test_existing_launch_verifies_labels_and_reports_state(self): + name = 'parsar-selfhost-'+'c'*32 + state = {'installation_id': str(uuid.uuid4()), 'environment_id': self.environment} + labels = {'io.parsar.agents-api.installation': state['installation_id'], + 'io.parsar.agents-api.environment': self.environment, + 'io.parsar.agents-api.user-owned': 'true'} + installer.write_private(self.root/'started.json', {'container': name, 'status': 'started'}) + with patch.object(installer, 'checked', return_value=json.dumps(labels)+' running'), \ + contextlib.redirect_stdout(io.StringIO()) as output: + installer.inspect_prior_launch(self.root, state) + self.assertIn('already running', output.getvalue()) + self.assertIn('Read the Session to confirm connection', output.getvalue()) + with patch.object(installer, 'checked', return_value=json.dumps(labels)+' exited'): + with self.assertRaisesRegex(installer.InstallError, 'start '+name): + installer.inspect_prior_launch(self.root, state) + labels['io.parsar.agents-api.environment'] = str(uuid.uuid4()) + with patch.object(installer, 'checked', return_value=json.dumps(labels)+' running'): + with self.assertRaisesRegex(installer.InstallError, 'does not match'): + installer.inspect_prior_launch(self.root, state) + + def test_uncertain_launch_has_specific_safe_inspection_commands(self): + state = {'installation_id': str(uuid.uuid4()), 'environment_id': self.environment} + with self.assertRaises(installer.InstallError) as failure: + installer.inspect_prior_launch(self.root, state) + message = str(failure.exception) + self.assertIn('ps -a --filter label=io.parsar.agents-api.installation='+state['installation_id'], message) + self.assertIn('volume ls --filter', message) + self.assertIn('Do not delete the receipt', message) + + def test_failed_launch_is_not_replayed(self): + def checked(command, message, timeout=30): + if 'inspect' in command: return self.manifest['images']['runtime'] + ' linux/amd64' + if command[0].endswith('parsar-runtime'): raise installer.InstallError('uncertain launch') + return '' + with patch.object(installer, 'load_manifest', return_value=self.manifest), \ + patch.object(installer, 'obtain_artifact', side_effect=lambda m, n, dest, o: dest), \ + patch.object(installer, 'runtime_archive', return_value=self.root/'runtime.tar'), \ + patch.object(installer, 'checked', side_effect=checked): + with self.assertRaises(installer.InstallError): installer.install(self.args, self.root) + self.assertTrue((self.root/'launch.json').exists()) + with self.assertRaisesRegex(installer.InstallError, 'already attempted'): installer.install(self.args, self.root) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index b94161b21..e3d2ab160 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -44,14 +44,20 @@ cd "$HOME/.parsar/releases/parsar-core--linux-amd64" ./install.sh --public-url https://core.example ``` -The bundle contains the same-revision native Core binaries and service image, -unchanged Web build, production Web proxy and colocated Runtime. It includes microsandbox's pinned runtime and -firmware. It also contains image archives, source provenance and checksums; -installation does not need Go, Node, Rust or a product checkout. The default -installation loads only the Core, Web and PostgreSQL images; Runtime and -microsandbox payloads are used only when a sandbox provider is enabled. -The matched `parsar-sandbox-node` executable is included in the native payload -and Core service image; packaging it does not start or register a node. +The thin bundle contains same-revision Core and Web service images, PostgreSQL, +native Core binaries, installer bootstraps, documentation and checksums. Node, +Runtime and microsandbox binaries are separate prebuilt assets. Installing the +default zero-node deployment downloads none of those execution assets and needs +no Go, Node, Rust or source checkout. + +The manifest identifies every asset by immutable revision, SHA-256 and byte size. +Adding a node downloads only its selected provider's assets. Runtime images use +compressed archives; verified local files and already imported images are reused. +Downloads use temporary files and bounded retries, so a truncated response is +never promoted into the cache. An optional `-offline.tar.gz` bundle contains the +same assets locally. For console-based distribution without a release host, build +that offline bundle with no release URL. A bundle that records a release URL +retains that URL for remote node downloads; it does not silently change mirrors. ## Sign in to Web @@ -102,9 +108,11 @@ management. Choose English or Chinese through the System language selector. this page does not switch providers. Microsandbox uses a five-minute idle timeout and one-day snapshot retention. 4. Click **Add node**, copy the command, and run it as a non-root user on the - target Linux amd64 host. It downloads the matched files from your console, - verifies checksums, imports the Runtime image, writes the provider configuration, - registers the node and starts a systemd user service. Web refreshes node health + target Linux amd64 host. It downloads the matched bootstrap from your console and execution assets from + the manifest's release location (or the offline console payload), verifies + checksums, imports the Runtime image only when missing, writes the provider configuration, + registers the node and starts a systemd user service. The installer waits for Core to confirm + connection and provider readiness. Web refreshes node health automatically. Wait for the node to be online and its provider to be ready; registration alone does not mean it can accept work. @@ -132,6 +140,48 @@ the colocated daemon, native harness and workspace. Once a node is ready, you can [make an API request](quickstart.md). The model credentials are supplied with execution requests, not during node installation. +## Connect a user-managed Runtime + +A `self_hosted` Session uses your own execution machine; it does not enroll that +machine as a shared sandbox node. Create the Session through the public API, then +use your project caller credential to obtain a restricted credential for its +Environment. The [credential contract](../../contracts/agents-api/environment-executor-credentials.md) +describes issuance, rotation and revocation. Save the response to an owned, +mode-0600 file. Keep the project caller key on your application machine. + +After saving the restricted credential on your execution machine, run one command +with your Core origin, Environment ID and returned `remote_url`. This downloads +and verifies the matching bootstrap before starting it: + +```sh +( + set -eu + core=https://core.example + work=$(mktemp -d) + trap 'rm -rf "$work"' EXIT + curl -fsS "$core/node-install/self-hosted-install.pyz" -o "$work/self-hosted-install.pyz" + curl -fsS "$core/node-install/SHA256SUMS" -o "$work/SHA256SUMS" + (cd "$work"; awk '$2 == "self-hosted-install.pyz"' SHA256SUMS | sha256sum -c -) + python3 "$work/self-hosted-install.pyz" --source-url "$core" \ + --environment-id ENVIRONMENT_UUID \ + --remote wss://core.example/api/v1/agent-daemon/ws \ + --credential-file /absolute/private/executor-key.json +) +``` + +Use the exact Environment ID and reachable `remote_url` returned by your Session. +The Linux amd64 host needs Python 3.9+ and Docker access as a non-root user. +The installer prepares the matched daemon, native harnesses and local workspace +inside the same isolated Runtime used for hosted execution. No shared node, +model credential or source build is needed. Model access remains execution input. + +A started Runtime is not proof of a connected Environment or a successful model +request. Read the Session to confirm its connection, then submit your task. +Installation state stays under `~/.parsar/self-hosted/ENVIRONMENT_UUID`; retain its +credentials, volumes and native history. An uncertain launch gives inspection +instructions instead of creating replacement history. Session deletion does not +reclaim user-owned Docker resources. + ## Installation choices ```sh @@ -243,6 +293,7 @@ MiniMax companion prepared through the existing Runtime build instructions: ```sh export AGENTS_RUNTIME_CODEX_PACKAGE=/absolute/path/to/codex-linux-package export MCODE_HARNESS_BUILD_DIR=/absolute/path/to/mcode-harness-artifact +export CORE_DISTRIBUTION_RELEASE_BASE_URL=https://downloads.example/releases/COMMIT make build-core-distribution ``` @@ -251,3 +302,14 @@ the commit, immutable image identities, microsandbox binary hashes and the actua Runtime OCI manifest digest. Build output lives under `~/.parsar/build/`; it is not automatically published to GitHub, an image registry or a website. Qualify the exact bundle before distribution. See the [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/main/CONTRIBUTING.md). + +The release base must host the generated flat asset filenames over HTTPS. Use +`CORE_DISTRIBUTION_OFFLINE=1` to also emit a full offline archive; a build without +any release URL must select offline mode. A fully disconnected console deployment +uses that empty-URL offline build; remote node installers then obtain assets from +the console. The release workflow prepares pinned +harness dependencies, builds versioned assets, and uploads an Actions artifact. +An explicit manual option can create an unpublished draft release. Neither a +successful build nor a draft makes a private repository anonymously downloadable; +publish qualified assets through your chosen distribution channel before sharing +installation instructions with external users. diff --git a/scripts/build-core-distribution.sh b/scripts/build-core-distribution.sh index fb9587e12..8d4fc7466 100755 --- a/scripts/build-core-distribution.sh +++ b/scripts/build-core-distribution.sh @@ -7,6 +7,17 @@ umask 022 repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" runtime_root="${PARSAR_HOME:-$HOME/.parsar}" output_dir="${CORE_DISTRIBUTION_BUILD_DIR:-$runtime_root/build/core-distribution}" +release_base_url="${CORE_DISTRIBUTION_RELEASE_BASE_URL:-}" +offline="${CORE_DISTRIBUTION_OFFLINE:-0}" +if [[ "$offline" != 0 && "$offline" != 1 ]]; then + printf 'CORE_DISTRIBUTION_OFFLINE must be 0 or 1\n' >&2 + exit 1 +fi +if [[ -z "$release_base_url" && "$offline" != 1 ]]; then + printf 'Set CORE_DISTRIBUTION_RELEASE_BASE_URL, or explicitly select CORE_DISTRIBUTION_OFFLINE=1\n' >&2 + exit 1 +fi +python3 "$repo_root/scripts/core-distribution-manifest.py" release-base "$release_base_url" export GOCACHE="${GOCACHE:-$runtime_root/cache/go-build}" export GOMODCACHE="${GOMODCACHE:-$runtime_root/cache/go-mod}" export GOOS=linux GOARCH=amd64 GOAMD64=v1 GOTOOLCHAIN=local @@ -74,14 +85,17 @@ if [[ "$(go env GOVERSION)" != "$required_go" ]]; then printf 'Distribution build requires %s\n' "$required_go" >&2 exit 1 fi -for file in install.sh install.py configuration.py native_service.py node_install.py; do +for file in install.sh install.py configuration.py native_service.py node_install.py distribution.py self_hosted_install.py; do cp "deploy/install/$file" "$bundle/$file" done +python3 scripts/core-distribution-manifest.py bootstraps "$bundle" "$source_epoch" mkdir -p "$bundle/docs" cp -R docs/getting-started "$bundle/docs/" cp README.md "$bundle/" mkdir -p "$bundle/services/agents-api" cp services/agents-api/HOSTED-SANDBOX-MANAGER.md "$bundle/services/agents-api/" +mkdir -p "$bundle/contracts/agents-api" +cp contracts/agents-api/environment-executor-credentials.md "$bundle/contracts/agents-api/" mkdir -p "$bundle/runtime" cp services/agents-api/deploy/codex/seccomp.json "$bundle/runtime/" cp LICENSE "$bundle/" @@ -111,8 +125,11 @@ build_image --platform linux/amd64 --iidfile "$stage/core.id" \ --label "org.opencontainers.image.revision=$revision" "$stage/core" core_image="$(cat "$stage/core.id")" # Fail at packaging time if the helper or runtime requires unavailable host libraries. -docker run --rm --network none --entrypoint /bin/sh "$core_image" -ec \ - 'for p in /usr/local/bin/agents-api-microsandbox-provider /opt/microsandbox/msb /opt/microsandbox/libkrunfw.so.5.6.1; do ! ldd "$p" | grep "not found"; done; /opt/microsandbox/msb --version' +docker run --rm --network none --entrypoint /bin/sh \ + --mount "type=bind,src=$stage/core/microsandbox,dst=/opt/microsandbox,readonly" \ + --mount "type=bind,src=$stage/core/bin/agents-api-microsandbox-provider,dst=/opt/provider,readonly" \ + "$core_image" -ec \ + 'for p in /opt/provider /opt/microsandbox/msb /opt/microsandbox/libkrunfw.so.5.6.1; do ! ldd "$p" | grep "not found"; done; /opt/microsandbox/msb --version' CORE_CONSOLE_BUILD_DIR="$stage/web" scripts/build-core-console.sh pnpm install --frozen-lockfile @@ -125,6 +142,8 @@ build_image --platform linux/amd64 --iidfile "$stage/web.id" \ export AGENTS_EXECUTOR_BUILD_DIR="$stage/helpers" scripts/build-agents-executor.sh CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$stage/parsar-daemon" ./apps/parsar-daemon/cmd/parsar-daemon +cp "$stage/parsar-daemon" "$bundle/native/bin/parsar-daemon" +CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$bundle/native/bin/parsar-runtime" ./services/agents-api/cmd/runtime codex_image="${CORE_DISTRIBUTION_CODEX_IMAGE:-}" claude_image="${CORE_DISTRIBUTION_CLAUDE_IMAGE:-}" mcode_image="${CORE_DISTRIBUTION_MCODE_IMAGE:-}" @@ -184,23 +203,37 @@ done mkdir -m 0700 "$stage/msb-cache" msb=(docker run --rm --network none --user "$(id -u):$(id -g)" \ --mount "type=bind,src=$stage/msb-cache,dst=/cache" \ + --mount "type=bind,src=$stage/core/microsandbox,dst=/opt/microsandbox,readonly" \ --mount "type=bind,src=$bundle/images/runtime.tar,dst=/runtime.tar,readonly" \ --env MSB_HOME=/cache --env MSB_BACKEND=local --env MSB_PATH=/opt/microsandbox/msb \ --env MSB_LIBKRUNFW_PATH=/opt/microsandbox/libkrunfw.so.5.6.1 \ --entrypoint /opt/microsandbox/msb "$core_image") "${msb[@]}" image load --input /runtime.tar --tag parsar-core-runtime:distribution --quiet "${msb[@]}" image inspect parsar-core-runtime:distribution --format json > "$stage/runtime-inspect.json" -python3 scripts/core-distribution-manifest.py manifest "$bundle" "$stage" "$revision" "$source_tree" +python3 scripts/core-distribution-manifest.py manifest "$bundle" "$stage" "$revision" "$source_tree" "$release_base_url" "$offline" require_clean_source if [[ "$(git -C "$repo_root" rev-parse HEAD)" != "$revision" ]]; then printf 'Source changed during distribution build\n' >&2 exit 1 fi -python3 scripts/core-distribution-manifest.py archive "$bundle" "$source_epoch" archive_name="$(basename "$bundle").tar.gz" -if [[ -e "$output_dir/$(basename "$bundle")" || -e "$output_dir/$archive_name" ]]; then +if [[ -e "$output_dir/$(basename "$bundle")" || -e "$output_dir/$archive_name" || -e "$output_dir/${archive_name%.tar.gz}-offline.tar.gz" ]]; then printf 'A distribution already exists for this revision; choose a fresh output directory\n' >&2 exit 1 fi -mv "$stage/$archive_name" "$stage/$archive_name.sha256" "$bundle" "$output_dir/" -printf 'Core distribution: %s/%s\n' "$output_dir" "$archive_name" +if [[ -n "$release_base_url" ]]; then + python3 scripts/core-distribution-manifest.py archive "$bundle" "$source_epoch" + mv "$stage/$archive_name" "$stage/$archive_name.sha256" "$output_dir/" + printf 'Core distribution: %s/%s\n' "$output_dir" "$archive_name" +fi +if [[ "$offline" == 1 ]]; then + mkdir "$bundle/artifacts" + # Hard links keep the optional archive from requiring another Runtime-sized copy. + for asset in "$stage/artifacts/"*; do ln "$asset" "$bundle/artifacts/"; done + python3 scripts/core-distribution-manifest.py archive "$bundle" "$source_epoch" offline + offline_name="${archive_name%.tar.gz}-offline.tar.gz" + mv "$stage/$offline_name" "$stage/$offline_name.sha256" "$output_dir/" + printf 'Offline Core distribution: %s/%s\n' "$output_dir" "$offline_name" +fi +mv "$stage/artifacts/"* "$output_dir/" +mv "$bundle" "$output_dir/" diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index eaaa2eb39..7488db298 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -1,18 +1,33 @@ #!/usr/bin/env python3 -"""Verify distribution inputs and write the offline bundle's public metadata.""" +"""Verify matched distribution inputs and package independently fetched artifacts.""" import gzip import hashlib import json +import os import pathlib import re +import shutil import subprocess import sys import tarfile +import tempfile +from urllib.parse import urlsplit +import zipapp RUNTIME_ARCHIVE_SHA256 = "47c223e3ef5298abf05f47ed9f87981106e400d99bb3f1d042d4d6881346b18b" DIGEST = re.compile(r"sha256:[0-9a-f]{64}\Z") +ARTIFACTS = { + "images/runtime.tar.gz": "runtime.tar.gz", + "native/bin/parsar-sandbox-node": "sandbox-node", + "native/bin/parsar-daemon": "daemon", + "native/bin/parsar-runtime": "runtime-launcher", + "native/bin/agents-api-microsandbox-provider": "microsandbox-provider", + "native/microsandbox/msb": "msb", + "native/microsandbox/libkrunfw.so.5.6.1": "libkrunfw.so.5.6.1", + "runtime/seccomp.json": "seccomp.json", +} def sha256(path): @@ -74,8 +89,63 @@ def extract_runtime(archive, destination): (destination / name).chmod(0o555) -def manifest(bundle, stage, revision, source_tree): +def release_base(value): + if not value: + return "" + parsed = urlsplit(value) + if (parsed.scheme != "https" or not parsed.hostname or parsed.username is not None + or parsed.password is not None or parsed.query or parsed.fragment + or any(c.isspace() or ord(c) < 32 for c in value) or "\\" in value + or not parsed.path.strip("/") or "latest" in parsed.path.lower().split("/")): + raise ValueError("Release base must be a versioned HTTPS directory, never latest") + parsed.port + return value.rstrip("/") + + +def package_artifacts(bundle, stage, revision): + """Move optional payload out of Core; the manifest owns every asset digest.""" + assets = stage / "artifacts" + assets.mkdir() + runtime = bundle / "images/runtime.tar" + compressed = bundle / "images/runtime.tar.gz" + unpacked = {"unpacked_sha256": sha256(runtime), "unpacked_size": runtime.stat().st_size} + with runtime.open("rb") as source, compressed.open("wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0, compresslevel=6) as output: + shutil.copyfileobj(source, output, 1024 * 1024) + runtime.unlink() + result = {} + for logical, suffix in ARTIFACTS.items(): + filename = f"parsar-core-{revision}-linux-amd64-{suffix}" + target = assets / filename + if logical == "runtime/seccomp.json": + shutil.copyfile(bundle / logical, target) + else: + (bundle / logical).replace(target) + result[logical] = {"filename": filename, "sha256": sha256(target), "size": target.stat().st_size} + if logical == "images/runtime.tar.gz": + result[logical].update(unpacked) + return result + + +def bootstraps(bundle, epoch): + bundle = pathlib.Path(bundle) + for source, output in (("node_install.py", "node-install.pyz"), + ("self_hosted_install.py", "self-hosted-install.pyz")): + with tempfile.TemporaryDirectory(dir=bundle.parent) as directory: + for original, packaged in ((source, "__main__.py"), ("distribution.py", "distribution.py")): + target = pathlib.Path(directory) / packaged + shutil.copyfile(bundle / original, target) + os.utime(target, (int(epoch), int(epoch))) + zipapp.create_archive(directory, bundle / output, compressed=True) + + +def manifest(bundle, stage, revision, source_tree, artifact_base_url="", offline="0"): bundle, stage = pathlib.Path(bundle), pathlib.Path(stage) + artifact_base_url = release_base(artifact_base_url) + if not artifact_base_url and offline != "1": + raise ValueError("Online distributions require a release base; select explicit offline output otherwise") + if not re.fullmatch(r"[0-9a-f]{40}", revision) or not re.fullmatch(r"[0-9a-f]{40}", source_tree): + raise ValueError("Distribution source commit and tree must be full Git identities") inspected = json.loads((stage / "runtime-inspect.json").read_text()) digest = inspected.get("digest", "") if not isinstance(digest, str) or not DIGEST.fullmatch(digest): @@ -89,6 +159,8 @@ def manifest(bundle, stage, revision, source_tree): "source_commit": revision, "source_tree": source_tree, "platform": "linux/amd64", + "artifact_base_url": artifact_base_url, + "artifacts": package_artifacts(bundle, stage, revision), "images": images, "runtime_ref": "parsar-core-runtime@" + digest, "microsandbox": { @@ -98,13 +170,23 @@ def manifest(bundle, stage, revision, source_tree): }, } (bundle / "manifest.json").write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n") - members = sorted(path for path in bundle.rglob("*") if path.is_file()) + checksums(bundle) + + +def checksums(bundle): + bundle = pathlib.Path(bundle) + # Optional payload hashes are already authenticated by manifest.json. + members = sorted(path for path in bundle.rglob("*") if path.is_file() + and path.relative_to(bundle).parts[0] != "artifacts" + and path.name != "SHA256SUMS") (bundle / "SHA256SUMS").write_text("".join(sha256(path) + " " + path.relative_to(bundle).as_posix() + "\n" for path in members)) -def archive(bundle, epoch): +def archive(bundle, epoch, variant=""): bundle = pathlib.Path(bundle) - output = bundle.with_name(bundle.name + ".tar.gz") + if variant not in ("", "offline"): + raise ValueError("Unknown distribution archive variant") + output = bundle.with_name(bundle.name + ("-" + variant if variant else "") + ".tar.gz") with output.open("wb") as raw, gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as tar: for path in sorted(bundle.rglob("*")): @@ -124,10 +206,11 @@ def archive(bundle, epoch): if __name__ == "__main__": - commands = {"extract-runtime": extract_runtime, "verify-runtime": verify_runtime, "verify-image": verify_image, "manifest": manifest, "archive": archive} + commands = {"extract-runtime": extract_runtime, "verify-runtime": verify_runtime, "verify-image": verify_image, + "manifest": manifest, "archive": archive, "bootstraps": bootstraps, "release-base": release_base} try: commands[sys.argv[1]](*sys.argv[2:]) except (KeyError, TypeError): - sys.exit("Usage: core-distribution-manifest.py extract-runtime|verify-runtime|verify-image|manifest|archive ARGS...") + sys.exit("Usage: core-distribution-manifest.py extract-runtime|verify-runtime|verify-image|manifest|archive|bootstraps|release-base ARGS...") except (OSError, ValueError, subprocess.CalledProcessError) as error: sys.exit(str(error)) diff --git a/scripts/core-distribution-manifest.test.py b/scripts/core-distribution-manifest.test.py index cfcf950e3..e8dd709cb 100644 --- a/scripts/core-distribution-manifest.test.py +++ b/scripts/core-distribution-manifest.test.py @@ -1,5 +1,6 @@ """Regression checks for offline distribution identity and archive integrity.""" +import gzip import hashlib import importlib.util import json @@ -7,6 +8,7 @@ import tarfile import tempfile import unittest +import zipfile spec = importlib.util.spec_from_file_location("distribution", pathlib.Path(__file__).with_name("core-distribution-manifest.py")) @@ -14,6 +16,11 @@ spec.loader.exec_module(distribution) +REVISION = "a" * 40 +TREE = "b" * 40 +RELEASE_BASE = "https://example.com/releases/" + REVISION + + class DistributionTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory() @@ -31,12 +38,21 @@ def setUp(self): (self.stage / (name + ".id")).write_text("sha256:" + str(number) * 64 + "\n") self.inspection = {"digest": "sha256:" + "a" * 64, "architecture": "amd64", "os": "linux"} self.write_inspection() + for logical in distribution.ARTIFACTS: + path = self.bundle / logical.replace("images/runtime.tar.gz", "images/runtime.tar") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"payload:" + logical.encode()) + if logical.startswith("native/"): + path.chmod(0o555) + + def manifest(self, base=RELEASE_BASE, offline="0"): + distribution.manifest(self.bundle, self.stage, REVISION, TREE, base, offline) def write_inspection(self): (self.stage / "runtime-inspect.json").write_text(json.dumps(self.inspection)) def test_oci_manifest_identity_is_distinct_from_docker_config_identity(self): - distribution.manifest(self.bundle, self.stage, "commit", "tree") + self.manifest() metadata = json.loads((self.bundle / "manifest.json").read_text()) self.assertEqual(metadata["runtime_ref"], "parsar-core-runtime@sha256:" + "a" * 64) self.assertEqual(metadata["images"]["runtime"], "sha256:" + "3" * 64) @@ -50,20 +66,20 @@ def test_missing_manifest_digest_does_not_fall_back_to_config_id(self): self.inspection["config"] = {"digest": "sha256:" + "3" * 64} self.write_inspection() with self.assertRaisesRegex(ValueError, "manifest digest"): - distribution.manifest(self.bundle, self.stage, "commit", "tree") + self.manifest() def test_wrong_guest_platform_rejected(self): self.inspection["architecture"] = "arm64" self.write_inspection() with self.assertRaisesRegex(ValueError, "platform"): - distribution.manifest(self.bundle, self.stage, "commit", "tree") + self.manifest() def test_archive_reproducible_and_installer_executable(self): native = self.bundle / "native/bin/agents-api" - native.parent.mkdir(parents=True) + native.parent.mkdir(parents=True, exist_ok=True) native.write_bytes(b"native executable") native.chmod(0o555) - distribution.manifest(self.bundle, self.stage, "commit", "tree") + self.manifest() distribution.archive(self.bundle, "1700000000") archive = self.bundle.with_name(self.bundle.name + ".tar.gz") first = archive.read_bytes() @@ -83,6 +99,61 @@ def test_bad_upstream_checksum_does_not_extract(self): distribution.extract_runtime(archive, destination) self.assertFalse(destination.exists()) + def test_thin_archive_and_detached_payload_share_one_manifest(self): + self.manifest() + raw_manifest = (self.bundle / "manifest.json").read_bytes() + metadata = json.loads(raw_manifest) + self.assertEqual(metadata["artifact_base_url"], RELEASE_BASE) + self.assertEqual(set(metadata["artifacts"]), set(distribution.ARTIFACTS)) + for logical, artifact in metadata["artifacts"].items(): + path = self.stage / "artifacts" / artifact["filename"] + self.assertEqual((self.bundle / logical).exists(), logical == "runtime/seccomp.json") + self.assertTrue(artifact["filename"].startswith("parsar-core-" + REVISION + "-linux-amd64-")) + self.assertEqual(artifact["size"], path.stat().st_size) + self.assertEqual(artifact["sha256"], distribution.sha256(path)) + runtime = self.stage / "artifacts" / metadata["artifacts"]["images/runtime.tar.gz"]["filename"] + self.assertEqual(gzip.decompress(runtime.read_bytes()), b"payload:images/runtime.tar.gz") + runtime_entry = metadata["artifacts"]["images/runtime.tar.gz"] + self.assertEqual(runtime_entry["unpacked_size"], len(b"payload:images/runtime.tar.gz")) + self.assertEqual(runtime_entry["unpacked_sha256"], hashlib.sha256(b"payload:images/runtime.tar.gz").hexdigest()) + self.assertFalse((self.bundle / "images/runtime.tar").exists()) + distribution.archive(self.bundle, "1700000000") + thin = self.bundle.with_name(self.bundle.name + ".tar.gz") + with tarfile.open(thin) as contents: + self.assertFalse(any("/artifacts/" in entry.name or "/native/" in entry.name for entry in contents)) + (self.stage / "artifacts").rename(self.bundle / "artifacts") + distribution.archive(self.bundle, "1700000000", "offline") + offline = self.bundle.with_name(self.bundle.name + "-offline.tar.gz") + with tarfile.open(offline) as contents: + self.assertEqual(contents.extractfile(self.bundle.name + "/manifest.json").read(), raw_manifest) + for artifact in metadata["artifacts"].values(): + self.assertIsNotNone(contents.getmember(self.bundle.name + "/artifacts/" + artifact["filename"])) + distribution.checksums(self.bundle) + self.assertNotIn("artifacts/", (self.bundle / "SHA256SUMS").read_text()) + + def test_release_location_and_explicit_offline_requirements(self): + for invalid in ("http://example.com/release/v1", "https://example.com/", "https://example.com/latest", + "https://user:secret@example.com/v1", "https://example.com/v1?token=secret"): + with self.subTest(invalid=invalid), self.assertRaises(ValueError): + distribution.release_base(invalid) + with self.assertRaisesRegex(ValueError, "explicit offline"): + self.manifest(base="") + self.manifest(base="", offline="1") + self.assertEqual(json.loads((self.bundle / "manifest.json").read_text())["artifact_base_url"], "") + + def test_bootstraps_include_shared_downloader_and_are_reproducible(self): + for name in ("node_install.py", "self_hosted_install.py", "distribution.py"): + (self.bundle / name).write_text("# " + name + "\n") + distribution.bootstraps(self.bundle, "1700000000") + first = (self.bundle / "node-install.pyz").read_bytes() + distribution.bootstraps(self.bundle, "1700000000") + self.assertEqual(first, (self.bundle / "node-install.pyz").read_bytes()) + for script, filename in (("node_install.py", "node-install.pyz"), + ("self_hosted_install.py", "self-hosted-install.pyz")): + with zipfile.ZipFile(self.bundle / filename) as contents: + self.assertEqual(set(contents.namelist()), {"__main__.py", "distribution.py"}) + self.assertEqual(contents.read("__main__.py"), (self.bundle / script).read_bytes()) + if __name__ == "__main__": unittest.main() diff --git a/scripts/openapi-split/main.go b/scripts/openapi-split/main.go index 1bc30ad53..cdcb5db03 100644 --- a/scripts/openapi-split/main.go +++ b/scripts/openapi-split/main.go @@ -36,8 +36,8 @@ func run(input, projectOutput, managerOutput string) error { filterPaths(field(p, "paths"), false) filterPaths(field(m, "paths"), true) field(m, "basePath").Value = "/" - field(field(m, "info"), "title").Value = "Core Sandbox Manager" - field(field(m, "info"), "description").Value = "Deployment administration and node enrollment. Uses separate administrator and node credentials; project API keys do not authorize these operations." + field(field(m, "info"), "title").Value = "Core Extensions" + field(field(m, "info"), "description").Value = "Core extensions outside the upstream Agents API. Environment executor credential operations use project caller authentication. Sandbox administration and node enrollment use separate administrator and node credentials. See each operation's security requirements." // Retain exactly the definitions referenced by each surface, including shared // error DTOs. Follow nested references instead of duplicating the project schema. pruneDefinitions(p) diff --git a/scripts/prepare-release-runtimes.sh b/scripts/prepare-release-runtimes.sh new file mode 100644 index 000000000..a2e6f1365 --- /dev/null +++ b/scripts/prepare-release-runtimes.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Prepare pinned upstream inputs once, then reuse the existing Runtime builders. +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +release_root="$HOME/.parsar/build/release-inputs" +if [[ -e "$release_root" ]]; then + printf 'Release input directory already exists; use a fresh build host\n' >&2 + exit 1 +fi +mkdir -p "$release_root/codex" "$release_root/mcode-native" +cd "$release_root/codex" +npm pack --ignore-scripts --silent @openai/codex@0.153.4-linux-x64 > package-name.txt +tar -xzf "$(cat package-name.txt)" + +pin="$repo_root/packages/mcode-harness/source.json" +source_repository="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["repository"])' "$pin")" +source_revision="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["revision"])' "$pin")" +source_version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$pin")" +git init --quiet "$release_root/mcode-source" +git -C "$release_root/mcode-source" remote add origin "$source_repository" +git -C "$release_root/mcode-source" fetch --depth 1 origin "$source_revision" +git -C "$release_root/mcode-source" checkout --detach FETCH_HEAD +npm install --prefix "$release_root/mcode-native" --no-audit --no-fund --include=optional \ + --install-strategy=nested "@minimax-ai/code@$source_version" +MCODE_NATIVE_SOURCE="$release_root/mcode-source" \ +MCODE_CLI_DIR="$release_root/mcode-native/node_modules/@minimax-ai/code" \ + bash "$repo_root/scripts/build-mcode-harness.sh" | tee "$release_root/mcode-build.log" +companion="$(sed -n 's/^MiniMax Code Runtime artifact: //p' "$release_root/mcode-build.log")" +test -f "$companion/provenance.json" +python3 - "$release_root" "$companion" <<'PY' +import json, pathlib, sys +root = pathlib.Path(sys.argv[1]) +(root / "inputs.json").write_text(json.dumps({ + "codex": str(root / "codex/package"), + "mcode": sys.argv[2], +}) + "\n") +PY +printf 'Pinned Runtime build inputs: %s/inputs.json\n' "$release_root" diff --git a/services/agents-api/HOSTED-SANDBOX-MANAGER.md b/services/agents-api/HOSTED-SANDBOX-MANAGER.md index d397df394..a28e85d4e 100644 --- a/services/agents-api/HOSTED-SANDBOX-MANAGER.md +++ b/services/agents-api/HOSTED-SANDBOX-MANAGER.md @@ -145,7 +145,10 @@ do not extend snapshot retention. ## Register a host The paired console provides a complete installation command. It downloads only -fixed public distribution artifacts from `/node-install/`; the enrollment token +a matched bootstrap from `/node-install/`, then checksum-verified prebuilt assets +from the manifest release URL or offline console payload. It reuses verified cache +entries and exact imported images, then waits for Core to confirm connection and +provider readiness. The enrollment token is transient and never a console/project credential. Python 3.9+, a systemd user session with lingering, and Docker access or KVM/native-library prerequisites must already exist on the target host. Rerunning the same command preserves the diff --git a/services/agents-api/cmd/runtime/main.go b/services/agents-api/cmd/runtime/main.go new file mode 100644 index 000000000..3faae9ca0 --- /dev/null +++ b/services/agents-api/cmd/runtime/main.go @@ -0,0 +1,103 @@ +// Command runtime launches one user-owned packaged V1 Runtime, without a Core database. +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "syscall" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" + "github.com/moby/moby/client" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string) error { + var input docker.SelfHostedLaunch + var credential, seccomp string + flags := flag.NewFlagSet("parsar-runtime", flag.ContinueOnError) + flags.SetOutput(io.Discard) + flags.StringVar(&input.InstallationID, "installation-id", "", "persisted local installation UUID") + flags.StringVar(&input.EnvironmentID, "environment-id", "", "Session Environment UUID") + flags.StringVar(&input.RemoteURL, "remote", "", "unchanged Session remote_url") + flags.StringVar(&input.Image, "image", "", "matched Runtime image digest") + flags.StringVar(&credential, "credential-file", "", "private restricted executor credential JSON") + flags.StringVar(&seccomp, "seccomp-file", "", "matched Runtime seccomp file") + if flags.Parse(args) != nil || flags.NArg() != 0 { + return errors.New("invalid Runtime launch arguments") + } + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" || os.Getuid() == 0 { + return errors.New("run as a non-root user on Linux amd64 with Docker access") + } + raw, err := readPrivateCredential(credential) + if err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input.Credential) != nil || decoder.Decode(new(any)) != io.EOF { + return errors.New("invalid restricted executor credential file") + } + raw, err = os.ReadFile(seccomp) + if err != nil || len(raw) > 1024*1024 { + return errors.New("cannot read Runtime seccomp profile") + } + input.Seccomp = string(raw) + if err = input.Validate(); err != nil { + return err + } + c, err := client.New(client.WithHost("unix:///var/run/docker.sock")) + if err != nil { + return errors.New("cannot open local Docker") + } + defer c.Close() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + name, err := docker.LaunchSelfHosted(ctx, c, input) + if err != nil { + // A name contains only a digest, never credentials or request diagnostics. + if name != "" { + fmt.Fprintln(os.Stderr, "Retained Runtime name:", name) + } + return err + } + return json.NewEncoder(os.Stdout).Encode(map[string]string{"container": name, "status": "started"}) +} + +func readPrivateCredential(path string) ([]byte, error) { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return nil, errors.New("absolute private credential file required") + } + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, errors.New("cannot open private credential file") + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return nil, errors.New("cannot inspect private credential file") + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || st.Uid != uint32(os.Getuid()) || st.Nlink != 1 { + return nil, errors.New("credential must be a mode-0600 owned regular file") + } + raw, err := io.ReadAll(io.LimitReader(f, 16*1024+1)) + if err != nil || len(raw) > 16*1024 { + return nil, errors.New("cannot read bounded credential file") + } + return raw, nil +} diff --git a/services/agents-api/cmd/runtime/main_test.go b/services/agents-api/cmd/runtime/main_test.go new file mode 100644 index 000000000..079988cd6 --- /dev/null +++ b/services/agents-api/cmd/runtime/main_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRuntimeCredentialRequiresPrivateOwnedRegularFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "credential.json") + if err := os.WriteFile(path, []byte(`{"executor_token":"synthetic"}`), 0600); err != nil { + t.Fatal(err) + } + if _, err := readPrivateCredential(path); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0644); err != nil { + t.Fatal(err) + } + if _, err := readPrivateCredential(path); err == nil { + t.Fatal("public credential accepted") + } + if err := os.Chmod(path, 0600); err != nil { + t.Fatal(err) + } + alias := filepath.Join(dir, "alias") + if err := os.Symlink(path, alias); err != nil { + t.Fatal(err) + } + if _, err := readPrivateCredential(alias); err == nil { + t.Fatal("symlink accepted") + } + if err := os.Remove(alias); err != nil { + t.Fatal(err) + } + if err := os.Link(path, alias); err != nil { + t.Fatal(err) + } + if _, err := readPrivateCredential(path); err == nil { + t.Fatal("hardlink accepted") + } +} diff --git a/services/agents-api/internal/api/environment_executor_management.go b/services/agents-api/internal/api/environment_executor_management.go new file mode 100644 index 000000000..f520870ff --- /dev/null +++ b/services/agents-api/internal/api/environment_executor_management.go @@ -0,0 +1,93 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type EnvironmentExecutorStore interface { + IssueEnvironmentExecutorCredential(context.Context, identity.Principal, string, string, bool) (store.IssuedExecutorCredential, error) + RevokeEnvironmentExecutorCredential(context.Context, identity.Principal, string, string) error +} + +type EnvironmentExecutorCredentialRequest struct { + KeyID string `json:"key_id"` + Rotate bool `json:"rotate,omitempty"` +} + +func (h *Handler) registerEnvironmentExecutorRoutes(r chi.Router) { + s, ok := h.store.(EnvironmentExecutorStore) + if !ok { + return + } + r.Route("/core/v1/environments/{environment_id}/executor-credentials", func(r chi.Router) { + r.Use(h.authenticateProject) + r.Post("/", func(w http.ResponseWriter, r *http.Request) { h.issueEnvironmentExecutorCredential(w, r, s) }) + r.Delete("/{key_id}", func(w http.ResponseWriter, r *http.Request) { h.revokeEnvironmentExecutorCredential(w, r, s) }) + }) +} + +// @Summary Issue or explicitly rotate an Environment executor credential +// @Description Core extension, not an upstream Agents API operation. Uses the project caller key and exact live self_hosted Session creator. Returns a connect-only secret once, restricted to this Environment. Repeating an issuance key_id returns 409; after an uncertain response explicitly rotate that same key_id. Rotation retains immutable ownership and invalidates the old secret. No Session API authority is granted. +// @Tags Environment Executor +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param environment_id path string true "Environment UUID" +// @Param body body api.EnvironmentExecutorCredentialRequest true "Request" +// @Success 201 {object} store.IssuedExecutorCredential +// @Failure 400,401,404,409,500 {object} v1.ErrorResponse +// @Router /core/v1/environments/{environment_id}/executor-credentials [post] +func (h *Handler) issueEnvironmentExecutorCredential(w http.ResponseWriter, r *http.Request, s EnvironmentExecutorStore) { + raw, ok := readJSONBody(w, r) + if !ok { + return + } + var input EnvironmentExecutorCredentialRequest + var fields map[string]json.RawMessage + if decodeInputObject(raw, &input, "key_id", "rotate") != nil || json.Unmarshal(raw, &fields) != nil || bytes.Equal(bytes.TrimSpace(fields["rotate"]), []byte("null")) || !executorManagementID(input.KeyID) { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + credential, err := s.IssueEnvironmentExecutorCredential(r.Context(), r.Context().Value(principalContextKey{}).(identity.Principal), chi.URLParam(r, "environment_id"), input.KeyID, input.Rotate) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusCreated, credential) +} + +// @Summary Revoke an Environment executor credential +// @Description Core extension authenticated by the project caller key. Revokes only the caller's exact Environment-restricted credential. Repeated revocation is safe. Revocation denies future enrollment and dispatch but does not stop caller-owned compute or prove process quiescence. +// @Tags Environment Executor +// @Security BearerAuth +// @Param environment_id path string true "Environment UUID" +// @Param key_id path string true "Executor key UUID" +// @Success 204 +// @Failure 400,401,404,500 {object} v1.ErrorResponse +// @Router /core/v1/environments/{environment_id}/executor-credentials/{key_id} [delete] +func (h *Handler) revokeEnvironmentExecutorCredential(w http.ResponseWriter, r *http.Request, s EnvironmentExecutorStore) { + keyID := chi.URLParam(r, "key_id") + if !executorManagementID(keyID) { + writeStoreError(w, r, store.ErrNotFound) + return + } + if err := s.RevokeEnvironmentExecutorCredential(r.Context(), r.Context().Value(principalContextKey{}).(identity.Principal), chi.URLParam(r, "environment_id"), keyID); err != nil { + writeStoreError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) +} + +func executorManagementID(value string) bool { + id, err := uuid.Parse(value) + return err == nil && id != uuid.Nil && id.String() == value +} diff --git a/services/agents-api/internal/api/environment_executor_management_test.go b/services/agents-api/internal/api/environment_executor_management_test.go new file mode 100644 index 000000000..332de2c2a --- /dev/null +++ b/services/agents-api/internal/api/environment_executor_management_test.go @@ -0,0 +1,94 @@ +package api + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type executorManagementFixture struct { + ResourceStore + principal identity.Principal + environment, key string + rotate bool + calls int + err error +} + +func (f *executorManagementFixture) IssueEnvironmentExecutorCredential(_ context.Context, principal identity.Principal, environment, key string, rotate bool) (store.IssuedExecutorCredential, error) { + f.principal, f.environment, f.key, f.rotate = principal, environment, key, rotate + f.calls++ + return store.IssuedExecutorCredential{KeyID: key, EnvironmentID: environment, Token: "synthetic-connect-only"}, f.err +} +func (f *executorManagementFixture) RevokeEnvironmentExecutorCredential(_ context.Context, principal identity.Principal, environment, key string) error { + f.principal, f.environment, f.key = principal, environment, key + f.calls++ + return f.err +} + +func TestEnvironmentExecutorManagementHTTP(t *testing.T) { + f := &executorManagementFixture{} + tenant, environment, keyID := uuid.NewString(), uuid.NewString(), uuid.NewString() + auth, err := NewAuthenticator([]APIKey{{TokenSHA256: device.HashCredential("caller"), TenantID: tenant, OrganizationID: "org", ProjectID: "project", SubjectKind: "user", SubjectID: "creator"}}) + if err != nil { + t.Fatal(err) + } + h, err := NewHandler(f, auth, "codex") + if err != nil { + t.Fatal(err) + } + path := "/core/v1/environments/" + environment + "/executor-credentials" + request := func(method, path, body, token string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-Tenant-ID", "untrusted") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w + } + body := `{"key_id":"` + keyID + `"}` + for _, token := range []string{"", "synthetic-connect-only", "admin"} { + if w := request("POST", path, body, token); w.Code != 401 || f.calls != 0 { + t.Fatal("non-caller authority accepted", w.Code) + } + } + w := request("POST", path, body, "caller") + var got map[string]string + if w.Code != 201 || w.Header().Get("Cache-Control") != "no-store" || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got) != 3 || got["environment_id"] != environment || got["key_id"] != keyID || got["executor_token"] != "synthetic-connect-only" { + t.Fatal("credential response", w.Code) + } + if f.principal.TenantID != tenant || f.principal.SubjectID != "creator" || f.rotate { + t.Fatal("authority not derived from caller") + } + w = request("POST", path, `{"key_id":"`+keyID+`","rotate":true}`, "caller") + if w.Code != 201 || !f.rotate { + t.Fatal("explicit rotation", w.Code) + } + for _, body := range []string{`{}`, `{"key_id":"invalid"}`, `{"key_id":null}`, `{"key_id":"` + keyID + `","environment_id":"other"}`, `{"key_id":"` + keyID + `","executor_token":"import"}`, `{"key_id":"` + keyID + `","rotate":null}`} { + before := f.calls + if w := request("POST", path, body, "caller"); w.Code != 400 || before != f.calls { + t.Fatal("invalid request accepted", w.Code, body) + } + } + f.err = store.ErrExecutorCredentialExists + if w := request("POST", path, body, "caller"); w.Code != 409 || strings.Contains(w.Body.String(), "synthetic-connect-only") { + t.Fatal("uncertain retry", w.Code) + } + f.err = store.ErrNotFound + if w := request("DELETE", path+"/"+keyID, "", "caller"); w.Code != 404 { + t.Fatal("foreign revocation", w.Code) + } + f.err = nil + if w := request("DELETE", path+"/"+keyID, "", "caller"); w.Code != 204 || w.Body.Len() != 0 { + t.Fatal("revocation", w.Code) + } +} diff --git a/services/agents-api/internal/api/errors.go b/services/agents-api/internal/api/errors.go index b1470904d..15578602c 100644 --- a/services/agents-api/internal/api/errors.go +++ b/services/agents-api/internal/api/errors.go @@ -66,6 +66,8 @@ func writeFieldError(w http.ResponseWriter, err error) bool { func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFoundParam ...string) { switch { + case errors.Is(err, store.ErrExecutorCredentialExists): + writeError(w, http.StatusConflict, "executor_credential_exists", "This executor key ID already exists. Explicitly rotate it to replace the secret.") case errors.Is(err, store.ErrSandboxDeploymentConflict): writeError(w, http.StatusConflict, "sandbox_deployment_conflict", "The sandbox deployment is already configured or is managed by a deployment file.") case errors.Is(err, store.ErrRuntimeNodeCredential): diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 6877311e8..c6fc36618 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -89,6 +89,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... r.Delete("/v1/files/{file_id}", h.deleteSourceFile) }) h.registerSandboxManagerRoutes(router) + h.registerEnvironmentExecutorRoutes(router) router.Route("/v1", func(r chi.Router) { r.Use(h.authenticate) h.registerSandboxProjectRoutes(r) diff --git a/services/agents-api/internal/api/sandbox_manager.go b/services/agents-api/internal/api/sandbox_manager.go index 90e832054..de7771f54 100644 --- a/services/agents-api/internal/api/sandbox_manager.go +++ b/services/agents-api/internal/api/sandbox_manager.go @@ -209,13 +209,13 @@ func (h *Handler) enrollSandboxNode(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, value) } -// @Summary Recover an enrolled sandbox node identity +// @Summary Recover an enrolled sandbox node identity and observe its readiness // @Description Core deployment extension. Does not grant project resource access. Responses contain only explicit safe fields. // @Tags Sandbox Manager // @Produce json // @Security NodeAuth // @Param node_id query string true "Sandbox node UUID" -// @Success 200 {object} store.RuntimeNodeIdentity +// @Success 200 {object} store.RuntimeNodeStatus // @Failure 400,401,404,409,500,503 {object} v1.ErrorResponse // @Router /core/v1/sandbox/node/identity [get] func (h *Handler) sandboxNodeIdentity(w http.ResponseWriter, r *http.Request) { @@ -229,7 +229,7 @@ func (h *Handler) sandboxNodeIdentity(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, store.ErrInvalidInput) return } - value, err := h.sandboxStore.AuthenticateRuntimeNode(r.Context(), ids[0], token) + value, err := h.sandboxStore.RuntimeNodeStatus(r.Context(), ids[0], token) if err != nil { writeStoreError(w, r, err) return diff --git a/services/agents-api/internal/sandbox/docker/bootstrap.go b/services/agents-api/internal/sandbox/docker/bootstrap.go index 54dba1ae8..0fcb361d2 100644 --- a/services/agents-api/internal/sandbox/docker/bootstrap.go +++ b/services/agents-api/internal/sandbox/docker/bootstrap.go @@ -28,7 +28,7 @@ func (p *Provider) bootstrap(ctx context.Context, id string, b sandbox.Bootstrap if e != nil { return e } - if e = p.copy(ctx, id, "/home", []entry{ + if e = copyRuntimeFiles(ctx, p.client, id, "/home", []entry{ {name: "runtime", directory: true}, {name: "runtime/.parsar", directory: true}, {name: "runtime/.parsar/parsar-daemon", directory: true}, {name: "runtime/.parsar/parsar-daemon/default", directory: true}, @@ -36,9 +36,9 @@ func (p *Provider) bootstrap(ctx context.Context, id string, b sandbox.Bootstrap }); e != nil { return e } - return p.copy(ctx, id, "/environment", []entry{{name: "workspace", directory: true}, {name: "staging", directory: true}, {name: "initialization", directory: true}, {name: "packages", directory: true}}) + return copyRuntimeFiles(ctx, p.client, id, "/environment", []entry{{name: "workspace", directory: true}, {name: "staging", directory: true}, {name: "initialization", directory: true}, {name: "packages", directory: true}}) } -func (p *Provider) copy(ctx context.Context, id, path string, entries []entry) error { +func copyRuntimeFiles(ctx context.Context, c *client.Client, id, path string, entries []entry) error { var content bytes.Buffer writer := tar.NewWriter(&content) for _, file := range entries { @@ -57,6 +57,6 @@ func (p *Provider) copy(ctx context.Context, id, path string, entries []entry) e if e := writer.Close(); e != nil { return e } - _, e := p.client.CopyToContainer(ctx, id, client.CopyToContainerOptions{DestinationPath: path, Content: io.Reader(&content), CopyUIDGID: true}) + _, e := c.CopyToContainer(ctx, id, client.CopyToContainerOptions{DestinationPath: path, Content: io.Reader(&content), CopyUIDGID: true}) return e } diff --git a/services/agents-api/internal/sandbox/docker/container_options.go b/services/agents-api/internal/sandbox/docker/container_options.go new file mode 100644 index 000000000..7a02a50df --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/container_options.go @@ -0,0 +1,38 @@ +package docker + +import ( + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/client" +) + +// runtimeContainerOptions is shared by managed and user-owned V1 Runtime launch. +// Keep isolation and volume layout identical; only bootstrap authority differs. +func runtimeContainerOptions(config Config, name string, labels map[string]string, environment []string) client.ContainerCreateOptions { + limit := int64(128) + // A nested native sandbox must mount its own procfs. Keep sysfs secrets + // masked; only this qualified image profile opts out of Docker's proc masks. + var masked, readonly []string + var init *bool + if config.NestedSandbox { + masked = []string{"/sys/firmware", "/sys/devices/virtual/powercap"} + readonly = []string{} + enabled := true + init = &enabled + } + return client.ContainerCreateOptions{Name: name, Image: config.Image, + Config: &container.Config{User: "1000:1000", WorkingDir: "/environment/workspace", Labels: labels, Env: environment}, + HostConfig: &container.HostConfig{ReadonlyRootfs: true, CapDrop: []string{"ALL"}, SecurityOpt: []string{"no-new-privileges", "seccomp=" + config.Seccomp, "apparmor=unconfined"}, NetworkMode: container.NetworkMode(config.Network), ExtraHosts: config.ExtraHosts, + MaskedPaths: masked, ReadonlyPaths: readonly, Init: init, + Resources: container.Resources{PidsLimit: &limit, Memory: 2 * 1024 * 1024 * 1024, NanoCPUs: 2 * 1000000000}, Tmpfs: map[string]string{"/tmp": "rw,nosuid,nodev,size=128m"}, + Mounts: []mount.Mount{ + {Type: mount.TypeVolume, Source: name + "-home", Target: "/home"}, + {Type: mount.TypeVolume, Source: name + "-environment", Target: "/environment"}, + // The native sandbox mounts canonical roots, omitting symlink aliases. + // Expose the same workspace at its public path; trusted atomic staging + // remains entirely on the original /environment mount. + {Type: mount.TypeVolume, Source: name + "-environment", Target: "/workspace", VolumeOptions: &mount.VolumeOptions{Subpath: "workspace", NoCopy: true}}, + }, + }, + } +} diff --git a/services/agents-api/internal/sandbox/docker/provider.go b/services/agents-api/internal/sandbox/docker/provider.go index 73906c4f1..de067368b 100644 --- a/services/agents-api/internal/sandbox/docker/provider.go +++ b/services/agents-api/internal/sandbox/docker/provider.go @@ -15,8 +15,6 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/containerd/errdefs" "github.com/google/uuid" - "github.com/moby/moby/api/types/container" - "github.com/moby/moby/api/types/mount" "github.com/moby/moby/client" ) @@ -142,32 +140,7 @@ func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Inf return info, sandbox.ErrOwnership } } - limit := int64(128) - // A nested native sandbox must mount its own procfs. Keep sysfs secrets - // masked; only this qualified image profile opts out of Docker's proc masks. - var masked, readonly []string - var init *bool - if p.config.NestedSandbox { - masked = []string{"/sys/firmware", "/sys/devices/virtual/powercap"} - readonly = []string{} - enabled := true - init = &enabled - } - v, e := p.client.ContainerCreate(ctx, client.ContainerCreateOptions{Name: name, Image: p.config.Image, - Config: &container.Config{User: "1000:1000", WorkingDir: "/environment/workspace", Labels: p.labels(b.Reference), Env: []string{"PARSAR_RUNTIME_ENVIRONMENT_ID=" + b.EnvironmentID, "PARSAR_RUNTIME_SESSION_ID=" + b.SessionID, "PARSAR_RUNTIME_NETWORK_ACCESS=" + policy.Access, "PARSAR_RUNTIME_ALLOWED_DOMAINS=" + string(domains)}}, - HostConfig: &container.HostConfig{ReadonlyRootfs: true, CapDrop: []string{"ALL"}, SecurityOpt: []string{"no-new-privileges", "seccomp=" + p.config.Seccomp, "apparmor=unconfined"}, NetworkMode: container.NetworkMode(p.config.Network), ExtraHosts: p.config.ExtraHosts, - MaskedPaths: masked, ReadonlyPaths: readonly, Init: init, - Resources: container.Resources{PidsLimit: &limit, Memory: 2 * 1024 * 1024 * 1024, NanoCPUs: 2 * 1000000000}, Tmpfs: map[string]string{"/tmp": "rw,nosuid,nodev,size=128m"}, - Mounts: []mount.Mount{ - {Type: mount.TypeVolume, Source: name + "-home", Target: "/home"}, - {Type: mount.TypeVolume, Source: name + "-environment", Target: "/environment"}, - // The native sandbox mounts canonical roots, omitting symlink aliases. - // Expose the same workspace at its public path; trusted atomic staging - // remains entirely on the original /environment mount. - {Type: mount.TypeVolume, Source: name + "-environment", Target: "/workspace", VolumeOptions: &mount.VolumeOptions{Subpath: "workspace", NoCopy: true}}, - }, - }, - }) + v, e := p.client.ContainerCreate(ctx, runtimeContainerOptions(p.config, name, p.labels(b.Reference), []string{"PARSAR_RUNTIME_ENVIRONMENT_ID=" + b.EnvironmentID, "PARSAR_RUNTIME_SESSION_ID=" + b.SessionID, "PARSAR_RUNTIME_NETWORK_ACCESS=" + policy.Access, "PARSAR_RUNTIME_ALLOWED_DOMAINS=" + string(domains)})) if errdefs.IsConflict(e) { return info, sandbox.ErrExists } diff --git a/services/agents-api/internal/sandbox/docker/self_hosted.go b/services/agents-api/internal/sandbox/docker/self_hosted.go new file mode 100644 index 000000000..e3d3f1cf2 --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/self_hosted.go @@ -0,0 +1,113 @@ +package docker + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/url" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/containerd/errdefs" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" +) + +// SelfHostedLaunch is local installation input, never a public execution request. +// The caller retains the container and volumes; Core acquires no compute authority. +type SelfHostedLaunch struct { + InstallationID, EnvironmentID, RemoteURL, Image, Seccomp string + Credential ExecutorCredential +} + +type ExecutorCredential struct { + KeyID string `json:"key_id"` + EnvironmentID string `json:"environment_id"` + Token string `json:"executor_token"` +} + +func (v SelfHostedLaunch) Validate() error { + u, err := url.Parse(v.RemoteURL) + if err != nil || u.Scheme != "wss" || u.Hostname() == "" || u.User != nil || u.Path != "/api/v1/agent-daemon/ws" || u.RawPath != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.TrimSpace(v.RemoteURL) != v.RemoteURL { + return errors.New("Docker Runtime requires the unchanged HTTPS-reachable wss Environment remote_url") + } + if !validID(v.InstallationID) || !validID(v.EnvironmentID) || !validID(v.Credential.KeyID) || v.Credential.EnvironmentID != v.EnvironmentID || v.Credential.Token == "" || len(v.Credential.Token) > 16384 || strings.ContainsAny(v.Credential.Token, " \t\r\n\x00") { + return errors.New("invalid Environment identity or restricted executor credential") + } + if !strings.HasPrefix(v.Image, "sha256:") || len(v.Image) != 71 { + return errors.New("Runtime image must be the distribution image digest") + } + if raw, err := hex.DecodeString(strings.TrimPrefix(v.Image, "sha256:")); err != nil || len(raw) != 32 || !json.Valid([]byte(v.Seccomp)) { + return errors.New("invalid Runtime image digest or seccomp profile") + } + return nil +} + +func (v SelfHostedLaunch) Name() string { + hash := sha256.Sum256([]byte(v.InstallationID + ":" + v.EnvironmentID)) + return "parsar-selfhost-" + hex.EncodeToString(hash[:16]) +} + +// LaunchSelfHosted starts the packaged daemon's existing connect path using the +// same container isolation as managed V1. It never retries an uncertain launch, +// replaces compute, imports history or enrolls a sandbox node. +func LaunchSelfHosted(ctx context.Context, c *client.Client, v SelfHostedLaunch) (string, error) { + if err := v.Validate(); err != nil { + return "", err + } + if c == nil { + return "", sandbox.ErrInvalid + } + name := v.Name() + if _, err := c.ContainerInspect(ctx, name, client.ContainerInspectOptions{}); err == nil { + return name, sandbox.ErrExists + } else if !errdefs.IsNotFound(err) { + return name, errors.New("cannot inspect Docker Runtime") + } + labels := map[string]string{labelPrefix + "user-owned": "true", labelPrefix + "installation": v.InstallationID, labelPrefix + "environment": v.EnvironmentID} + for _, suffix := range []string{"-home", "-environment"} { + if _, err := c.VolumeInspect(ctx, name+suffix, client.VolumeInspectOptions{}); err == nil { + return name, sandbox.ErrExists + } else if !errdefs.IsNotFound(err) { + return name, errors.New("cannot inspect Runtime volumes") + } + } + for _, suffix := range []string{"-home", "-environment"} { + volume, err := c.VolumeCreate(ctx, client.VolumeCreateOptions{Name: name + suffix, Labels: labels}) + if err != nil { + return name, errors.New("cannot create Runtime volume; retain partial state") + } + for key, value := range labels { + if volume.Volume.Labels[key] != value { + return name, sandbox.ErrOwnership + } + } + } + options := runtimeContainerOptions(Config{Image: v.Image, Network: "bridge", Seccomp: v.Seccomp, NestedSandbox: true}, name, labels, nil) + options.Config.Cmd = []string{"connect", "--profile", "default", "--remote", v.RemoteURL, "--environment-id", v.EnvironmentID, "--credential-file", "/home/runtime/.parsar/parsar-daemon/executor-key.json"} + options.HostConfig.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyUnlessStopped} + created, err := c.ContainerCreate(ctx, options) + if err != nil { + return name, errors.New("cannot create Runtime container; retain partial state") + } + credential, _ := json.Marshal(v.Credential) + if err = copyRuntimeFiles(ctx, c, created.ID, "/home", []entry{ + {name: "runtime", directory: true}, {name: "runtime/.parsar", directory: true}, + {name: "runtime/.parsar/parsar-daemon", directory: true}, + {name: "runtime/.parsar/parsar-daemon/executor-key.json", content: credential}, + }); err != nil { + return name, errors.New("cannot initialize private Runtime credential; retain partial state") + } + if err = copyRuntimeFiles(ctx, c, created.ID, "/environment", []entry{ + {name: "workspace", directory: true}, {name: "staging", directory: true}, + {name: "initialization", directory: true}, {name: "packages", directory: true}, + }); err != nil { + return name, errors.New("cannot initialize Runtime workspace; retain partial state") + } + if _, err = c.ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil { + return name, errors.New("Runtime start outcome uncertain; inspect retained container") + } + return name, nil +} diff --git a/services/agents-api/internal/sandbox/docker/self_hosted_test.go b/services/agents-api/internal/sandbox/docker/self_hosted_test.go new file mode 100644 index 000000000..d843b159c --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/self_hosted_test.go @@ -0,0 +1,144 @@ +package docker + +import ( + "archive/tar" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/google/uuid" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" +) + +func selfHostedFixture() SelfHostedLaunch { + environment := uuid.NewString() + return SelfHostedLaunch{InstallationID: uuid.NewString(), EnvironmentID: environment, RemoteURL: "wss://core.example/api/v1/agent-daemon/ws", Image: "sha256:" + strings.Repeat("a", 64), Seccomp: `{}`, Credential: ExecutorCredential{KeyID: uuid.NewString(), EnvironmentID: environment, Token: "synthetic-private-token"}} +} + +func TestSelfHostedRejectsUnsafeLaunchBeforeDocker(t *testing.T) { + for _, change := range []func(*SelfHostedLaunch){ + func(v *SelfHostedLaunch) { v.RemoteURL = "ws://localhost/api/v1/agent-daemon/ws" }, + func(v *SelfHostedLaunch) { v.RemoteURL += "?secret=value" }, + func(v *SelfHostedLaunch) { v.RemoteURL = "wss://user:secret@core.example/api/v1/agent-daemon/ws" }, + func(v *SelfHostedLaunch) { v.Credential.EnvironmentID = "" }, + func(v *SelfHostedLaunch) { v.Credential.EnvironmentID = uuid.NewString() }, + func(v *SelfHostedLaunch) { v.Image = "runtime:latest" }, + } { + value := selfHostedFixture() + change(&value) + if _, err := LaunchSelfHosted(t.Context(), nil, value); err == nil { + t.Fatal("unsafe launch accepted") + } + } +} + +func TestSelfHostedLaunchUsesQualifiedIsolationAndPrivateBootstrap(t *testing.T) { + value := selfHostedFixture() + var configuration struct { + container.Config + HostConfig container.HostConfig + } + privateFiles := map[string]*tar.Header{} + contents := map[string]string{} + started, creates := false, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/v1.52") + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == "GET" && (strings.HasPrefix(path, "/volumes/") || strings.HasSuffix(path, "/json")): + w.WriteHeader(404) + _, _ = io.WriteString(w, `{"message":"not found"}`) + case path == "/volumes/create": + var request map[string]any + _ = json.NewDecoder(r.Body).Decode(&request) + _ = json.NewEncoder(w).Encode(request) + case path == "/containers/create": + creates++ + if err := json.NewDecoder(r.Body).Decode(&configuration); err != nil { + t.Error(err) + } + w.WriteHeader(201) + _, _ = io.WriteString(w, `{"Id":"runtime-id","Warnings":[]}`) + case path == "/containers/runtime-id/archive": + reader := tar.NewReader(r.Body) + for { + header, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + t.Error(err) + break + } + name := r.URL.Query().Get("path") + "/" + header.Name + privateFiles[name] = header + content, _ := io.ReadAll(reader) + contents[name] = string(content) + } + w.WriteHeader(200) + case path == "/containers/runtime-id/start": + started = true + w.WriteHeader(204) + default: + t.Errorf("unexpected Docker request %s %s", r.Method, path) + w.WriteHeader(500) + } + })) + defer server.Close() + c, err := client.New(client.WithHost(server.URL), client.WithAPIVersion("1.52")) + if err != nil { + t.Fatal(err) + } + defer c.Close() + name, err := LaunchSelfHosted(t.Context(), c, value) + if err != nil || name != value.Name() || creates != 1 || !started { + t.Fatal("launch", err) + } + qualified := runtimeContainerOptions(Config{Image: value.Image, Seccomp: value.Seccomp, Network: "bridge", NestedSandbox: true}, name, configuration.Labels, nil) + qualified.HostConfig.RestartPolicy = configuration.HostConfig.RestartPolicy + if !reflect.DeepEqual(*qualified.HostConfig, configuration.HostConfig) { + t.Fatal("user Runtime changed qualified isolation") + } + if configuration.User != "1000:1000" || configuration.WorkingDir != "/environment/workspace" || len(configuration.Env) != 0 { + t.Fatal("unsafe process configuration") + } + command, _ := json.Marshal(configuration.Cmd) + if strings.Contains(string(command), value.Credential.Token) || !strings.Contains(string(command), value.RemoteURL) { + t.Fatal("credential in argv or remote URL changed") + } + key := "/home/runtime/.parsar/parsar-daemon/executor-key.json" + for name, header := range privateFiles { + if header.Uid != 1000 || header.Gid != 1000 || header.Mode != 0700 && header.Mode != 0600 { + t.Fatal("unsafe archive ownership", name) + } + } + if privateFiles[key] == nil || privateFiles[key].Mode != 0600 || !strings.Contains(contents[key], value.Credential.Token) || privateFiles["/environment/workspace"] == nil { + t.Fatal("missing protected credential or workspace") + } +} + +func TestSelfHostedRetainsExistingRuntimeWithoutWrites(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || !strings.HasSuffix(r.URL.Path, "/json") { + t.Fatal("unexpected mutation") + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"Id":"existing"}`) + })) + defer server.Close() + c, err := client.New(client.WithHost(server.URL), client.WithAPIVersion("1.52")) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if _, err := LaunchSelfHosted(t.Context(), c, selfHostedFixture()); !errors.Is(err, sandbox.ErrExists) { + t.Fatal("existing history adopted", err) + } +} diff --git a/services/agents-api/internal/sandbox/node/enrollment_status_test.go b/services/agents-api/internal/sandbox/node/enrollment_status_test.go new file mode 100644 index 000000000..86091d0ef --- /dev/null +++ b/services/agents-api/internal/sandbox/node/enrollment_status_test.go @@ -0,0 +1,18 @@ +package node + +import ( + "io" + "net/http" + "strings" + "testing" +) + +func TestEnrollmentRecoveryAcceptsCoreReadiness(t *testing.T) { + for _, fields := range []string{"", `,"connected":true,"provider_ready":false`} { + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"node_id":"node","installation_id":"installation","provider":"docker"` + fields + `}`))} + value, err := readEnrollment(response) + if err != nil || value.NodeID != "node" || value.InstallationID != "installation" || value.Provider != "docker" { + t.Fatal(value, err) + } + } +} diff --git a/services/agents-api/internal/sandbox/node/wire.go b/services/agents-api/internal/sandbox/node/wire.go index 6b79c9b9a..0660014e9 100644 --- a/services/agents-api/internal/sandbox/node/wire.go +++ b/services/agents-api/internal/sandbox/node/wire.go @@ -54,6 +54,8 @@ type EnrollmentRequest struct { } type EnrollmentResponse struct { + Connected bool `json:"connected,omitempty"` + ProviderReady bool `json:"provider_ready,omitempty"` NodeID string `json:"node_id"` InstallationID string `json:"installation_id"` Provider string `json:"provider"` diff --git a/services/agents-api/internal/store/environment_executor_management.go b/services/agents-api/internal/store/environment_executor_management.go new file mode 100644 index 000000000..8ac764f45 --- /dev/null +++ b/services/agents-api/internal/store/environment_executor_management.go @@ -0,0 +1,68 @@ +package store + +import ( + "context" + "encoding/json" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" +) + +// IssueEnvironmentExecutorCredential exposes only exact self-hosted targets to +// project callers. The operator issuer remains available for broader principals. +func (s *Store) IssueEnvironmentExecutorCredential(ctx context.Context, principal identity.Principal, environment, keyID string, rotate bool) (IssuedExecutorCredential, error) { + if err := s.selfHostedExecutorTarget(ctx, principal, environment); err != nil { + return IssuedExecutorCredential{}, err + } + if !rotate { + return s.IssueExecutorCredential(ctx, principal, keyID, environment) + } + if err := s.exactExecutorRestriction(ctx, principal, environment, keyID); err != nil { + return IssuedExecutorCredential{}, err + } + return s.RotateExecutorCredential(ctx, principal, keyID) +} + +func (s *Store) RevokeEnvironmentExecutorCredential(ctx context.Context, principal identity.Principal, environment, keyID string) error { + if err := s.selfHostedExecutorTarget(ctx, principal, environment); err != nil { + return err + } + if err := s.exactExecutorRestriction(ctx, principal, environment, keyID); err != nil { + return err + } + return s.RevokeExecutorCredential(ctx, principal, keyID) +} + +func (s *Store) selfHostedExecutorTarget(ctx context.Context, principal identity.Principal, environment string) error { + if err := principal.Validate(); err != nil { + return ErrInvalidInput + } + owned, err := s.GetEnvironment(ctx, principal.TenantID, environment) + if err != nil { + return err + } + var configuration struct { + Type string `json:"type"` + } + if json.Unmarshal(owned.Configuration, &configuration) != nil || configuration.Type != "self_hosted" { + return ErrNotFound + } + return nil +} + +func (s *Store) exactExecutorRestriction(ctx context.Context, principal identity.Principal, environment, keyID string) error { + tenant, id, err := executorCredentialIdentity(principal, keyID) + if err != nil { + return err + } + want := parsePathID(environment) + actual, err := s.executorCredentialRestriction(ctx, principal, tenant, id) + if err != nil { + return err + } + // Restrictions and principals are immutable, so checking before the existing + // rotation/revocation transaction cannot authorize a different target. + if !actual.Valid || actual != want { + return ErrNotFound + } + return nil +} diff --git a/services/agents-api/internal/store/environment_executor_management_test.go b/services/agents-api/internal/store/environment_executor_management_test.go new file mode 100644 index 000000000..7fd2e2868 --- /dev/null +++ b/services/agents-api/internal/store/environment_executor_management_test.go @@ -0,0 +1,103 @@ +package store + +import ( + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/google/uuid" +) + +func TestProjectEnvironmentExecutorManagement(t *testing.T) { + s, _ := testStore(t) + ctx := t.Context() + p := FixtureExecutorPrincipal(t, s, uuid.NewString()) + create := func(principal identity.Principal, kind string) (Session, Environment) { + t.Helper() + input := environmentInput(uuid.NewString(), kind, "/workspace") + input.Creator = principal.Subject() + session, err := s.CreateSession(ctx, principal.TenantID, input) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, principal.TenantID, session.ID) + if err != nil { + t.Fatal(err) + } + return session, environment + } + session, one := create(p, "self_hosted") + _, two := create(p, "self_hosted") + _, hosted := create(p, "openai_hosted") + keyID := uuid.NewString() + issued, err := s.IssueEnvironmentExecutorCredential(ctx, p, one.ID, keyID, false) + if err != nil || issued.EnvironmentID != one.ID { + t.Fatal("issue", err) + } + // A lost issuance response must not cause a new key or replace the old secret. + if _, err := s.IssueEnvironmentExecutorCredential(ctx, p, one.ID, keyID, false); !errors.Is(err, ErrExecutorCredentialExists) { + t.Fatal("uncertain retry", err) + } + if _, err := s.AuthenticateEnvironmentExecutor(ctx, one.ID, executorDigest(issued.Token)); err != nil { + t.Fatal("retry changed credential", err) + } + other := p + other.SubjectID = "different-creator" + foreign := FixtureExecutorPrincipal(t, s, uuid.NewString()) + for _, principal := range []identity.Principal{other, foreign} { + if _, err := s.IssueEnvironmentExecutorCredential(ctx, principal, one.ID, uuid.NewString(), false); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign issue", err) + } + if _, err := s.IssueEnvironmentExecutorCredential(ctx, principal, one.ID, keyID, true); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign rotate", err) + } + if err := s.RevokeEnvironmentExecutorCredential(ctx, principal, one.ID, keyID); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign revoke", err) + } + } + for _, environment := range []string{two.ID, hosted.ID, uuid.NewString()} { + if _, err := s.IssueEnvironmentExecutorCredential(ctx, p, environment, keyID, true); !errors.Is(err, ErrNotFound) { + t.Fatal("wrong target rotate", err) + } + if err := s.RevokeEnvironmentExecutorCredential(ctx, p, environment, keyID); !errors.Is(err, ErrNotFound) { + t.Fatal("wrong target revoke", err) + } + } + if _, err := s.IssueEnvironmentExecutorCredential(ctx, p, hosted.ID, uuid.NewString(), false); !errors.Is(err, ErrNotFound) { + t.Fatal("hosted issuance", err) + } + broad, err := s.IssueExecutorCredential(ctx, p, uuid.NewString(), "") + if err != nil { + t.Fatal(err) + } + if _, err := s.IssueEnvironmentExecutorCredential(ctx, p, one.ID, broad.KeyID, true); !errors.Is(err, ErrNotFound) { + t.Fatal("broad rotation", err) + } + if err := s.RevokeEnvironmentExecutorCredential(ctx, p, one.ID, broad.KeyID); !errors.Is(err, ErrNotFound) { + t.Fatal("broad revocation", err) + } + rotated, err := s.IssueEnvironmentExecutorCredential(ctx, p, one.ID, keyID, true) + if err != nil || rotated.Token == issued.Token { + t.Fatal("rotation", err) + } + if _, err := s.AuthenticateEnvironmentExecutor(ctx, one.ID, executorDigest(issued.Token)); !errors.Is(err, ErrNotFound) { + t.Fatal("old secret", err) + } + for range 2 { + if err := s.RevokeEnvironmentExecutorCredential(ctx, p, one.ID, keyID); err != nil { + t.Fatal(err) + } + } + if _, err := s.AuthenticateEnvironmentExecutor(ctx, one.ID, executorDigest(rotated.Token)); !errors.Is(err, ErrNotFound) { + t.Fatal("revoked secret", err) + } + if _, err := s.IssueEnvironmentExecutorCredential(ctx, p, one.ID, keyID, false); !errors.Is(err, ErrExecutorCredentialExists) { + t.Fatal("resurrected secret", err) + } + if err := s.DeleteSession(ctx, p.TenantID, session.ID); err != nil { + t.Fatal(err) + } + if _, err := s.IssueEnvironmentExecutorCredential(ctx, p, one.ID, keyID, true); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted target", err) + } +} diff --git a/services/agents-api/internal/store/runtime_node_status.go b/services/agents-api/internal/store/runtime_node_status.go new file mode 100644 index 000000000..580e38281 --- /dev/null +++ b/services/agents-api/internal/store/runtime_node_status.go @@ -0,0 +1,27 @@ +package store + +import "context" + +// RuntimeNodeStatus reports only the authenticated node's Core-owned presence. +type RuntimeNodeStatus struct { + RuntimeNodeIdentity + Connected bool `json:"connected"` + ProviderReady bool `json:"provider_ready"` +} + +func (s *Store) RuntimeNodeStatus(ctx context.Context, nodeID, credential string) (RuntimeNodeStatus, error) { + identity, err := s.AuthenticateRuntimeNode(ctx, nodeID, credential) + if err != nil { + return RuntimeNodeStatus{}, err + } + nodes, err := s.ListRuntimeNodes(ctx) + if err != nil { + return RuntimeNodeStatus{}, err + } + for _, node := range nodes { + if node.ID == identity.NodeID { + return RuntimeNodeStatus{RuntimeNodeIdentity: identity, Connected: node.Online, ProviderReady: node.Online && node.ProviderReady}, nil + } + } + return RuntimeNodeStatus{}, ErrRuntimeNodeCredential +} diff --git a/services/agents-api/internal/store/runtime_node_status_test.go b/services/agents-api/internal/store/runtime_node_status_test.go new file mode 100644 index 000000000..edc2bbcd1 --- /dev/null +++ b/services/agents-api/internal/store/runtime_node_status_test.go @@ -0,0 +1,40 @@ +package store + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestRuntimeNodeStatusUsesAuthenticatedFreshPresence(t *testing.T) { + s, _, d := managerFixture(t, 1, 4) + status := func(connected, ready bool) { + t.Helper() + got, err := s.RuntimeNodeStatus(t.Context(), d.LocalNodeID, "local-node-credential") + if err != nil || got.NodeID != d.LocalNodeID || got.Connected != connected || got.ProviderReady != ready { + t.Fatal(got, err) + } + raw, err := json.Marshal(got) + if err != nil || strings.Contains(string(raw), "credential") || strings.Contains(string(raw), "backend_fingerprint") { + t.Fatal("private identity leaked", string(raw), err) + } + } + status(true, true) + if _, err := s.RuntimeNodeStatus(t.Context(), d.LocalNodeID, "different-credential"); !errors.Is(err, ErrRuntimeNodeCredential) { + t.Fatal("status admitted another credential", err) + } + if _, err := s.pool.Exec(t.Context(), "UPDATE runtime_nodes SET provider_ready=false WHERE id=$1", d.LocalNodeID); err != nil { + t.Fatal(err) + } + status(true, false) + if _, err := s.pool.Exec(t.Context(), "UPDATE runtime_nodes SET provider_ready=true,last_seen_at=clock_timestamp()-interval '46 seconds' WHERE id=$1", d.LocalNodeID); err != nil { + t.Fatal(err) + } + status(false, false) + onlineManagerNode(t, s, d.LocalNodeID) + if _, err := s.pool.Exec(t.Context(), "UPDATE runtime_deployment SET owner_epoch=owner_epoch+1 WHERE singleton=true"); err != nil { + t.Fatal(err) + } + status(false, false) +} diff --git a/services/core-console/distribution_test.go b/services/core-console/distribution_test.go new file mode 100644 index 000000000..595ea9851 --- /dev/null +++ b/services/core-console/distribution_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnvironmentConnectionCredentialsStayScoped(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "Bearer project-token" + if r.URL.Path == "/api/v1/agent-daemon/enroll" { + want = "Bearer executor-key" + } + if r.Header.Get("Authorization") != want { + t.Errorf("incorrect authority on %s", r.URL.Path) + } + _, _ = io.WriteString(w, "{}") + })) + defer upstream.Close() + u, _ := url.Parse(upstream.URL) + dist := t.TempDir() + if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("console"), 0600); err != nil { + t.Fatal(err) + } + h, err := newConsole(config{origin: testOrigin, upstream: u, dist: dist, token: "project-token", password: "private-console-password", adminToken: "server-admin"}) + if err != nil { + t.Fatal(err) + } + defer h.Close() + server := httptest.NewServer(h) + defer server.Close() + for _, tc := range []struct { + method, path, bearer string + status int + }{ + {"POST", "/core/v1/environments/env/executor-credentials", "", 200}, + {"POST", "/core/v1/environments/env/executor-credentials", "project-token", 200}, + {"DELETE", "/core/v1/environments/env/executor-credentials/key", "project-token", 200}, + {"GET", "/core/v1/environments/env/executor-credentials", "", 404}, + {"POST", "/api/v1/agent-daemon/enroll", "executor-key", 200}, + {"POST", "/api/v1/agent-daemon/enroll", "", 403}, + } { + req := consoleRequest(t, server, tc.method, tc.path) + if tc.bearer != "" { + req.Header.Set("Authorization", "Bearer "+tc.bearer) + req.Header.Del("Origin") + req.Header.Del("Sec-Fetch-Site") + } + response, _ := responseBody(t, server, req) + if response.StatusCode != tc.status { + t.Errorf("%s %s: %d", tc.method, tc.path, response.StatusCode) + } + } +} + +func TestOfflineArtifactsAreManifestAllowlisted(t *testing.T) { + dist, payload := t.TempDir(), t.TempDir() + for _, item := range []struct{ root, name, body string }{{dist, "index.html", "console"}, {payload, "node-install.pyz", "bootstrap"}} { + if err := os.WriteFile(filepath.Join(item.root, item.name), []byte(item.body), 0600); err != nil { + t.Fatal(err) + } + } + if err := os.Mkdir(filepath.Join(payload, "artifacts"), 0700); err != nil { + t.Fatal(err) + } + manifest := map[string]any{"artifacts": map[string]any{"native/bin/parsar-sandbox-node": map[string]string{"filename": "matched-node"}, "private/key": map[string]string{"filename": "private-key"}}} + raw, _ := json.Marshal(manifest) + if err := os.WriteFile(filepath.Join(payload, "manifest.json"), raw, 0600); err != nil { + t.Fatal(err) + } + for _, name := range []string{"matched-node", "private-key", "undeclared"} { + if err := os.WriteFile(filepath.Join(payload, "artifacts", name), []byte("payload"), 0600); err != nil { + t.Fatal(err) + } + } + upstream, _ := url.Parse("http://127.0.0.1:1") + h, err := newConsole(config{origin: testOrigin, upstream: upstream, dist: dist, token: "key", password: "private-console-password", nodePayloadDir: payload}) + if err != nil { + t.Fatal(err) + } + defer h.Close() + server := httptest.NewServer(h) + defer server.Close() + for _, name := range []string{"matched-node", "private-key", "undeclared"} { + req := consoleRequest(t, server, "GET", "/node-install/artifacts/"+name) + req.Header.Del("Authorization") + response, body := responseBody(t, server, req) + if name == "matched-node" { + if response.StatusCode != 200 || !strings.Contains(body, "payload") { + t.Fatal("matched artifact unavailable") + } + } else if response.StatusCode != 404 { + t.Fatal("unexpected artifact exposed") + } + } +} diff --git a/services/core-console/node_installation.go b/services/core-console/node_installation.go index bc5a5ea2b..ea0bbbef7 100644 --- a/services/core-console/node_installation.go +++ b/services/core-console/node_installation.go @@ -14,16 +14,48 @@ import ( // These are distribution artifacts, never installation configuration or secrets. // Serving the fixed list avoids a package registry or an arbitrary file endpoint. var nodePayloadFiles = map[string]bool{ - "node_install.py": true, "manifest.json": true, "SHA256SUMS": true, - "native/bin/parsar-sandbox-node": true, - "native/bin/agents-api-microsandbox-provider": true, - "native/microsandbox/msb": true, - "native/microsandbox/libkrunfw.so.5.6.1": true, - "images/runtime.tar": true, "runtime/seccomp.json": true, + "node-install.pyz": true, "self-hosted-install.pyz": true, + "manifest.json": true, "SHA256SUMS": true, "runtime/seccomp.json": true, +} + +// An offline distribution exposes only artifacts declared for these payloads. +var optionalPayloadFiles = map[string]bool{ + "native/bin/parsar-sandbox-node": true, "native/bin/parsar-daemon": true, + "native/bin/parsar-runtime": true, "native/bin/agents-api-microsandbox-provider": true, + "native/microsandbox/msb": true, "native/microsandbox/libkrunfw.so.5.6.1": true, + "images/runtime.tar.gz": true, "runtime/seccomp.json": true, +} + +func (h *console) allowedNodePayload(name string) bool { + if nodePayloadFiles[name] { + return true + } + if !strings.HasPrefix(name, "artifacts/") || strings.Contains(strings.TrimPrefix(name, "artifacts/"), "/") { + return false + } + f, err := h.nodePayload.Open("manifest.json") + if err != nil { + return false + } + defer f.Close() + var manifest struct { + Artifacts map[string]struct { + Filename string `json:"filename"` + } `json:"artifacts"` + } + if json.NewDecoder(io.LimitReader(f, 1024*1024)).Decode(&manifest) != nil { + return false + } + for logical, entry := range manifest.Artifacts { + if optionalPayloadFiles[logical] && entry.Filename != "" && "artifacts/"+entry.Filename == name { + return true + } + } + return false } func nodeInstallerDigest(root *os.Root) (string, error) { - f, err := root.Open("node_install.py") + f, err := root.Open("node-install.pyz") if err != nil { return "", errors.New("node installer is missing") } @@ -46,7 +78,7 @@ func (h *console) serveNodePayload(w http.ResponseWriter, r *http.Request) { return } name := strings.TrimPrefix(r.URL.Path, "/node-install/") - if !nodePayloadFiles[name] { + if !h.allowedNodePayload(name) { http.NotFound(w, r) return } @@ -78,7 +110,7 @@ func (h *console) serveConsoleConfiguration(w http.ResponseWriter, _ *http.Reque // Console or project credentials are never substituted on these public routes. func nodeTransportRequest(r *http.Request) bool { switch r.URL.Path { - case "/core/v1/sandbox/enroll", "/api/v1/agent-daemon/bootstrap": + case "/core/v1/sandbox/enroll", "/api/v1/agent-daemon/enroll", "/api/v1/agent-daemon/bootstrap": return r.Method == http.MethodPost && r.Header.Get("Upgrade") == "" case "/core/v1/sandbox/node/identity", "/api/v1/agent-daemon/device-status": return r.Method == http.MethodGet && r.Header.Get("Upgrade") == "" @@ -87,3 +119,16 @@ func nodeTransportRequest(r *http.Request) bool { } return false } + +// Environment credentials use project ownership, never deployment administration. +func projectExtensionRequest(r *http.Request) bool { + const prefix = "/core/v1/environments/" + if !strings.HasPrefix(r.URL.Path, prefix) { + return false + } + parts := strings.Split(strings.TrimPrefix(r.URL.Path, prefix), "/") + if len(parts) < 2 || parts[0] == "" || parts[1] != "executor-credentials" { + return false + } + return len(parts) == 2 && r.Method == http.MethodPost || len(parts) == 3 && parts[2] != "" && r.Method == http.MethodDelete +} diff --git a/services/core-console/node_installation_test.go b/services/core-console/node_installation_test.go index 21bc98fca..14cb6c9c7 100644 --- a/services/core-console/node_installation_test.go +++ b/services/core-console/node_installation_test.go @@ -32,7 +32,7 @@ func TestPairedConsoleKeepsAdminAndNodeCredentialsSeparated(t *testing.T) { defer upstream.Close() u, _ := url.Parse(upstream.URL) dist, payload := t.TempDir(), t.TempDir() - for _, file := range []struct{ path, value string }{{filepath.Join(dist, "index.html"), "console"}, {filepath.Join(payload, "node_install.py"), "print('installer')"}, {filepath.Join(payload, "caller.key"), "must-not-be-served"}} { + for _, file := range []struct{ path, value string }{{filepath.Join(dist, "index.html"), "console"}, {filepath.Join(payload, "node-install.pyz"), "print('installer')"}, {filepath.Join(payload, "caller.key"), "must-not-be-served"}} { if err := os.WriteFile(file.path, []byte(file.value), 0600); err != nil { t.Fatal(err) } @@ -58,9 +58,9 @@ func TestPairedConsoleKeepsAdminAndNodeCredentialsSeparated(t *testing.T) { {"POST", "/api/v1/agent-daemon/unknown", "node", 403}, {"GET", "/console/config", "basic", 200}, {"GET", "/console/config", "none", 401}, - {"GET", "/node-install/node_install.py", "none", 200}, + {"GET", "/node-install/node-install.pyz", "none", 200}, {"GET", "/node-install/caller.key", "none", 404}, - {"POST", "/node-install/node_install.py", "none", 405}, + {"POST", "/node-install/node-install.pyz", "none", 405}, } { r := consoleRequest(t, server, tc.method, tc.path) if tc.auth == "none" { diff --git a/services/core-console/server.go b/services/core-console/server.go index 511899b77..3409e9aba 100644 --- a/services/core-console/server.go +++ b/services/core-console/server.go @@ -64,7 +64,7 @@ func newConsole(c config) (*console, error) { r.Out.Header.Del("Cookie") r.Out.Header.Del("Origin") r.Out.Header.Del("Referer") - if nodeTransportRequest(r.In) || (sandboxAdminRequest(r.In) && c.adminToken == "") { + if (projectExtensionRequest(r.In) && explicitBearer(r.In)) || nodeTransportRequest(r.In) || (sandboxAdminRequest(r.In) && c.adminToken == "") { r.Out.Header.Set("Authorization", r.In.Header.Get("Authorization")) } else if sandboxAdminRequest(r.In) { r.Out.Header.Set("Authorization", "Bearer "+c.adminToken) @@ -113,7 +113,7 @@ func (h *console) ServeHTTP(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "ok\n") return } - if h.adminToken != "" && nodeTransportRequest(r) { + if (h.adminToken != "" && nodeTransportRequest(r)) || (projectExtensionRequest(r) && explicitBearer(r)) { if r.Host != h.host || !safePath(r.URL.Path) || r.URL.IsAbs() || !explicitBearer(r) || (r.Header.Get("Origin") != "" && r.Header.Get("Origin") != h.origin) { http.Error(w, "Invalid node transport request", http.StatusForbidden) return @@ -137,7 +137,7 @@ func (h *console) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "Invalid request", http.StatusBadRequest) return } - if (r.URL.Path == "/core" || strings.HasPrefix(r.URL.Path, "/core/")) && h.adminToken == "" { + if (r.URL.Path == "/core" || strings.HasPrefix(r.URL.Path, "/core/")) && h.adminToken == "" && !projectExtensionRequest(r) { if !sandboxAdminRequest(r) { http.NotFound(w, r) return @@ -163,7 +163,7 @@ func (h *console) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } if r.URL.Path == "/core" || strings.HasPrefix(r.URL.Path, "/core/") { - if !sandboxAdminRequest(r) { + if !sandboxAdminRequest(r) && !projectExtensionRequest(r) { http.NotFound(w, r) return }