From f789861a4be8a38d84adc0556f7ba98bd4bec3af Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 12:09:48 +0000 Subject: [PATCH 01/78] Add fixed-batch release promotion controller with guarded qualification interface --- .github/workflows/release.yml | 4 +- CONTRIBUTING.md | 43 ++- Makefile | 1 + docs/maintainers.md | 58 ++++ scripts/promote-qualified-release.py | 365 ++++++++++++++++++++++ scripts/promote-qualified-release.test.py | 263 ++++++++++++++++ scripts/qualify-core-release.py | 29 ++ 7 files changed, 759 insertions(+), 4 deletions(-) create mode 100644 scripts/promote-qualified-release.py create mode 100644 scripts/promote-qualified-release.test.py create mode 100644 scripts/qualify-core-release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f20be5be..e3b26da7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ on: type: string offline: description: Also create the full offline archive - default: false + default: true type: boolean draft_release: description: Upload files to a new draft release; never publish @@ -88,7 +88,7 @@ jobs: RELEASE_REVISION: ${{ steps.source.outputs.revision }} RELEASE_TAG: ${{ steps.source.outputs.release_tag }} RELEASE_REPOSITORY: ${{ github.repository }} - CORE_DISTRIBUTION_OFFLINE: ${{ inputs.offline && '1' || '0' }} + CORE_DISTRIBUTION_OFFLINE: ${{ (github.event_name == 'push' || inputs.offline) && '1' || '0' }} run: | inputs="$HOME/.oac/build/release-inputs/inputs.json" AGENTS_RUNTIME_CODEX_PACKAGE="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["codex"])' "$inputs")" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f71c09828..ce98e3d1c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1947,8 +1947,11 @@ 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. +artifact names. Candidate build automation creates artifacts and may create an +unpublished draft; a successful build is not real execution qualification. A +separate existing-host batch controller may publish that draft automatically only +after directly supervised real qualification and verified batch landing. Repository +visibility remains internal and independent of Release publication. Manual builds use the legal `build-` release tag; tag-triggered builds use the actual `v*` tag. The manifest download base and draft tag must match, while artifact filenames and source provenance retain the full source SHA. @@ -1961,6 +1964,42 @@ image containing a private test CA or model credential as a release input. Repository visibility is independent of publication. Do not add repository credentials to installed node/Runtime configuration to bypass download access. +For the current installation batch, `scripts/promote-qualified-release.py` fixes +candidate provenance and its tag to source +`48ed8158e134207d15cdd14ae0a30e10f070eb5c`. It uses existing local gh authentication +and SSH, uploads/downloads the complete matching thin/offline/Runtime asset set, +and verifies archive members and full asset hashes. The reviewed qualification +adapter receives a fresh run identity and exact inventory over the authenticated +command channel. Required checks are fresh-install, current-lifecycle, +managed-native, current-generations, node-runtime and diagnostics-observations. +All must run successfully; a supplied pass file, skipped check or old report cannot +release the candidate. The adapter currently refuses execution until the real +batch acceptance commands are connected. Controller tests never count as live +qualification. Historical reset evidence stays historical; later self-hosted +capability work is outside this batch. + +The caller supplies the independently reviewed promotion-tooling commit. Its +changes from the candidate may only affect the exact promotion files enumerated +in the controller, including CONTRIBUTING, docs/maintainers and the current-batch +node-generation protocol wording correction; the Makefile +exception permits only registration of the controller test. Main must contain the +candidate source and have the reviewed tooling commit's tree. This permits normal +merge commit identity changes and release-only documentation updates without +rebuilding or relabeling the original candidate. The candidate's bundled docs and +source archive retain source 48 (CONTRIBUTING and the node-generation protocol +are present through the source archive, not as direct bundled docs); new release instructions live in the tooling +commit. Product changes or a different main tree block promotion of the old +candidate. Never infer batch membership from all open PRs or automatically merge +them in the publication command. + +Use one controller invocation for the batch. It verifies unchanged draft identity, +target, tag and downloaded bytes immediately before publication and checks the +published bytes afterward. Conflicting assets are never overwritten. An interrupted +run is reconciled before another invocation; stored qualification output is evidence, +not a resumable permission to publish. Preserve its isolated local/remote evidence +and installation resources. No runner, background service, new GitHub secret or +repository-visibility change is required by this finite batch path. + Executor credentials are issued by the operator with the Core key, through Web or a Core-key script, under `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` diff --git a/Makefile b/Makefile index 90c1b28c2..d375cc9f1 100644 --- a/Makefile +++ b/Makefile @@ -132,6 +132,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 + PYTHONDONTWRITEBYTECODE=1 python3 scripts/promote-qualified-release.test.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/config-reference.py --check 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 diff --git a/docs/maintainers.md b/docs/maintainers.md index 15a2c2d86..eb13d5be1 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -56,6 +56,64 @@ draft. A workflow build alone is not live acceptance. Publish exactly the tested never rebuild or replace files under the same release identity. Publishing a Release does not change the repository's visibility. +The workflow defaults to offline output, including tag-triggered builds. It remains +a candidate builder; it does not publish merely because a build succeeded. + +For the current installation batch, the promotion controller runs on the existing +operator host with gh authentication and SSH access to the qualification host: + +```sh +python3 scripts/promote-qualified-release.py \ + --assets /absolute/path/to/flat-candidate-assets \ + --state /absolute/path/to/new-promotion-evidence \ + --host zju_a100_2 --remote-root /absolute/path/to/existing-isolated-acceptance-root \ + --promotion-commit FULL_REVIEWED_TOOLING_COMMIT_SHA +``` + +This command is an operational publication command, not a dry run. It does not +build, merge PRs, install services or create host accounts. Its qualification adapter +currently reports `ready=false`, so it stops before any Release operation until the +real batch checks are integrated and reviewed. Do not change readiness just to +bypass the missing implementation. + +Stage only the build's flat files, including thin/offline archives, both checksum +files and every versioned Runtime asset; exclude the extracted bundle directory. +Before staging files from the builder, compare their full inventory byte total plus +64 MiB of working reserve with actual local free space. The controller uses only +that existing single asset set and hashes subsequent GitHub downloads as streams; +it checks the additional reserve and records both byte counts. It never removes +user files to make room. +The remote parent directory must already exist within the batch's authorized scope. +The controller creates one new run directory, copies verified draft assets there, +and invokes the reviewed adapter over SSH. For this batch, zju coordinates managed stages and reaches mx2 through existing +SSH for the fresh Core/node stages; no new service or credentials are required. +GitHub credentials stay on the operator host; nodes continue to download from their console. + +The adapter protocol is documented in `scripts/qualify-core-release.py`: one JSON +request on stdin, one bound JSON result on stdout, redacted diagnostics on stderr, +and a nonzero exit for any failed or skipped required check. The six current-batch +checks cover fresh installation, current lifecycle, managed native execution, +current generations, node Runtime and diagnostics/observations. Their actual +commands must operate on freshly extracted supplied assets and respect the agreed +resource ownership. The controller verifies remote asset hashes before and after +execution; it has no pass-file or arbitrary acceptance-command option. + +This finite command publishes automatically when all checks pass and the batch is +landed. Candidate source and tag remain +`48ed8158e134207d15cdd14ae0a30e10f070eb5c` / `build-48ed8158e134207d15cdd14ae0a30e10f070eb5c`. +The reviewed tooling commit can add only the enumerated release files, the +node-generation protocol wording correction and test registration; main must have that commit's tree and include the candidate source. +Later release documentation is not retroactively inserted into the tested bundle. +Any product change blocks publication of this candidate instead of silently +publishing an obsolete product or relabeling old bytes. + +On failure, retain the new evidence and resources and reconcile the Release state. +Never overwrite assets or use saved check results to resume publication. A release +already published is refused before any new acceptance; a failed final verification +needs investigation, not automatic deletion or replacement. Run only one controller +for this batch. This path does not require unattended cloud scheduling for future +pushes, public repository visibility or historical-installation upgrades. + ## Run Core without the installer These paths give you Core alone, without Web, the `oac` command or `config.json`. diff --git a/scripts/promote-qualified-release.py b/scripts/promote-qualified-release.py new file mode 100644 index 000000000..69427637a --- /dev/null +++ b/scripts/promote-qualified-release.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""Promote one landed batch through existing gh and SSH authentication.""" + +import argparse +import base64 +import hashlib +import importlib.util +import json +import pathlib +import re +import shlex +import shutil +import subprocess +import tarfile +import tempfile +import uuid + +spec = importlib.util.spec_from_file_location( + "distribution", pathlib.Path(__file__).with_name("core-distribution-manifest.py")) +distribution = importlib.util.module_from_spec(spec) +spec.loader.exec_module(distribution) + +REPO = "MiniMax-AI/parsar-core" +SOURCE = "48ed8158e134207d15cdd14ae0a30e10f070eb5c" +TAG = "build-" + SOURCE +BASE = "https://github.com/" + REPO + "/releases/download/" + TAG +STEM = "oac-" + SOURCE + "-linux-amd64" +REQUIRED_CHECKS = ("fresh-install", "current-lifecycle", "managed-native", + "current-generations", "node-runtime", "diagnostics-observations") +# Only this separate promotion change may follow the qualified source on main. +PROMOTION_FILES = { + ".github/workflows/release.yml", "CONTRIBUTING.md", "docs/maintainers.md", + "Makefile", "contracts/agents-api/node-generation-protocol.md", + "scripts/promote-qualified-release.py", + "scripts/promote-qualified-release.test.py", "scripts/qualify-core-release.py", +} + + +def run(argv, **kwargs): + return subprocess.run(argv, check=True, text=True, capture_output=True, **kwargs).stdout + + +def gh(*args): + return run(["gh", *args, "--repo", REPO]) + + +def api(path): + return json.loads(run(["gh", "api", "repos/" + REPO + "/" + path])) + + +def digest(stream): + result = hashlib.sha256() + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + result.update(chunk) + return result.hexdigest() + + +def file_identity(path): + if path.is_symlink() or not path.is_file(): + raise ValueError("Asset must be a regular file: " + path.name) + with path.open("rb") as stream: + return {"sha256": digest(stream), "size": path.stat().st_size} + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":")) + + +def verify_archive(path, offline): + """Read without extraction; verify every ordinary member and optional payload.""" + hashes, metadata, sums = {}, None, None + with tarfile.open(path, "r|gz") as archive: + for member in archive: + parts = pathlib.PurePosixPath(member.name).parts + if (not member.isfile() or len(parts) < 2 or parts[0] != STEM + or ".." in parts or member.name in hashes): + raise ValueError("Invalid archive member") + relative = "/".join(parts[1:]) + stream = archive.extractfile(member) + if relative in ("manifest.json", "SHA256SUMS"): + if member.size > 1024 * 1024: + raise ValueError("Oversized archive metadata") + raw = stream.read() + hashes[member.name] = hashlib.sha256(raw).hexdigest() + if relative == "manifest.json": + metadata = json.loads(raw) + else: + sums = raw.decode() + else: + hashes[member.name] = digest(stream) + if metadata is None or sums is None: + raise ValueError("Missing archive manifest/checksums") + if (metadata.get("source_commit") != SOURCE + or metadata.get("artifact_base_url") != BASE + or metadata.get("platform") != "linux/amd64" + or not re.fullmatch(r"[0-9a-f]{40}", metadata.get("source_tree", ""))): + raise ValueError("Candidate source/platform/release URL mismatch") + expected = {} + for line in sums.splitlines(): + checksum, name = line.split(" ", 1) + if name in expected or not re.fullmatch(r"[0-9a-f]{64}", checksum): + raise ValueError("Invalid archive checksum list") + expected[name] = checksum + artifacts = metadata.get("artifacts", {}) + if set(artifacts) != set(distribution.ARTIFACTS): + raise ValueError("Missing or unexpected Runtime artifacts") + for key in ("images", "image_manifest_digests"): + values = metadata.get(key, {}) + if set(values) != {"core", "web", "runtime", "database"} or any( + not distribution.DIGEST.fullmatch(value) for value in values.values()): + raise ValueError("Missing immutable image identities") + if not re.fullmatch(r"oac-runtime@sha256:[0-9a-f]{64}", metadata.get("runtime_ref", "")): + raise ValueError("Missing immutable Runtime identity") + for logical, entry in artifacts.items(): + name = entry["filename"] + if name != STEM + "-" + distribution.ARTIFACTS[logical]: + raise ValueError("Unexpected Runtime filename") + if not re.fullmatch(re.escape(STEM) + r"-[A-Za-z0-9_.-]+", name): + raise ValueError("Invalid Runtime asset name") + if offline: + expected["artifacts/" + name] = entry["sha256"] + actual = {name[len(STEM) + 1:]: value for name, value in hashes.items() + if name != STEM + "/SHA256SUMS"} + if actual != expected: + raise ValueError("Archive member checksum/set mismatch") + return metadata + + +def inspect_candidate(directory): + files = {p.name: file_identity(p) for p in directory.iterdir()} + metadata = verify_archive(directory / (STEM + "-offline.tar.gz"), True) + if verify_archive(directory / (STEM + ".tar.gz"), False) != metadata: + raise ValueError("Thin and offline manifests differ") + expected = set() + for suffix in (".tar.gz", "-offline.tar.gz"): + name = STEM + suffix + expected.update((name, name + ".sha256")) + if (directory / (name + ".sha256")).read_text() != files[name]["sha256"] + " " + name + "\n": + raise ValueError("Archive checksum mismatch") + for entry in metadata["artifacts"].values(): + name = entry["filename"] + expected.add(name) + if files.get(name) != {key: entry[key] for key in ("sha256", "size")}: + raise ValueError("Runtime asset checksum/size mismatch") + if set(files) != expected: + raise ValueError("Candidate asset set mismatch; provide only generated flat files") + return metadata, files + + +def verify_files(directory, inventory): + actual = {p.name: file_identity(p) for p in directory.iterdir()} + if actual != inventory: + raise ValueError("Release asset bytes/set changed") + + +def verify_landed(tree, promotion_commit): + source = api("commits/" + SOURCE) + if source["commit"]["tree"]["sha"] != tree: + raise ValueError("Source tree differs from GitHub commit") + comparison = api("compare/" + SOURCE + "..." + promotion_commit) + if comparison["status"] not in ("identical", "ahead"): + raise ValueError("Promotion commit does not descend from qualified source") + if any(f["filename"] not in PROMOTION_FILES or f.get("previous_filename", f["filename"]) not in PROMOTION_FILES + for f in comparison.get("files", [])): + raise ValueError("Promotion commit contains product changes") + if any(f["filename"] == "Makefile" for f in comparison.get("files", [])): + original = base64.b64decode(api("contents/Makefile?ref=" + SOURCE)["content"]).decode() + updated = base64.b64decode(api("contents/Makefile?ref=" + promotion_commit)["content"]).decode() + anchor = "\tPYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py\n" + addition = "\tPYTHONDONTWRITEBYTECODE=1 python3 scripts/promote-qualified-release.test.py\n" + if original.count(anchor) != 1 or updated != original.replace(anchor, anchor + addition): + raise ValueError("Makefile change exceeds promotion test registration") + reviewed_tree = api("commits/" + promotion_commit)["commit"]["tree"]["sha"] + main = api("commits/main") + if main["commit"]["tree"]["sha"] != reviewed_tree: + raise ValueError("Main tree differs from reviewed promotion commit") + comparison = api("compare/" + SOURCE + "..." + main["sha"]) + if comparison["status"] not in ("identical", "ahead"): + raise ValueError("Qualified source has not landed on main") + + +def release_state(): + # Listing avoids treating authentication/network errors as a missing release. + pages = json.loads(run(["gh", "api", "--paginate", "--slurp", + "repos/" + REPO + "/releases?per_page=100"])) + matches = [release for page in pages for release in page if release["tag_name"] == TAG] + if len(matches) > 1: + raise ValueError("Ambiguous release identity") + return matches[0] if matches else None + + +def verify_tag(required=False): + # gh api's matching-refs endpoint returns [] when the exact tag is absent. + refs = api("git/matching-refs/tags/" + TAG) + refs = [ref for ref in refs if ref["ref"] == "refs/tags/" + TAG] + if required and not refs: + raise ValueError("Published release tag is missing") + if refs and (refs[0]["object"]["type"] != "commit" or refs[0]["object"]["sha"] != SOURCE): + raise ValueError("Conflicting release tag") + + +def download(evidence, inventory): + release = release_state() + if not release: + raise ValueError("Release disappeared") + pages = json.loads(run(["gh", "api", "--paginate", "--slurp", + "repos/" + REPO + "/releases/" + str(release["id"]) + "/assets?per_page=100"])) + assets = [asset for page in pages for asset in page] + if len(assets) != len(inventory) or {asset["name"] for asset in assets} != set(inventory): + raise ValueError("Release asset set mismatch") + for name, expected in sorted(inventory.items()): + command = ["gh", "release", "download", TAG, "--repo", REPO, + "--pattern", name, "--output", "-", "--allow-escape-sequences"] + # Stream binary downloads into the hash, never into another archive copy. + with tempfile.TemporaryFile() as errors: + with subprocess.Popen(command, stdout=subprocess.PIPE, stderr=errors) as process: + checksum, size = hashlib.sha256(), 0 + for block in iter(lambda: process.stdout.read(1024 * 1024), b""): + checksum.update(block) + size += len(block) + status = process.wait() + if status: + raise subprocess.CalledProcessError(status, command) + if {"sha256": checksum.hexdigest(), "size": size} != expected: + raise ValueError("Downloaded release asset bytes changed: " + name) + evidence.with_suffix(".json").write_text(canonical(inventory) + "\n") + + +def verify_remote(request, host): + code = """import hashlib,json,pathlib,sys +request=json.load(sys.stdin) +directory=pathlib.Path(request['directory']) +expected=request['inventory'] +actual={} +for name in expected: + path=directory/name + if path.is_symlink() or not path.is_file(): raise ValueError('Invalid remote asset') + digest=hashlib.sha256() + with path.open('rb') as stream: + for block in iter(lambda:stream.read(1024*1024),b''): digest.update(block) + actual[name]={'sha256':digest.hexdigest(),'size':path.stat().st_size} +if actual != expected: raise ValueError('Remote candidate bytes changed') +""" + run(["ssh", "-o", "BatchMode=yes", host, shlex.join(["python3", "-c", code])], + input=canonical(request)) + + +def qualification(request, host, remote, adapter, downloaded): + adapter_bytes = adapter.read_bytes() + request["adapter_sha256"] = hashlib.sha256(adapter_bytes).hexdigest() + command = shlex.join(["mkdir", "-m", "700", remote]) + run(["ssh", "-o", "BatchMode=yes", host, command]) + run(["scp", "-q", "--", str(adapter), *[str(p) for p in sorted(downloaded.iterdir())], + host + ":" + remote + "/"]) + verify_remote(request, host) + # Hash and execute the same bytes, avoiding a check-then-open script race. + bootstrap = ("import hashlib,pathlib,sys; p=pathlib.Path(sys.argv[1]); b=p.read_bytes(); " + "hashlib.sha256(b).hexdigest()==sys.argv[2] or sys.exit('Adapter bytes changed'); " + "sys.argv=[str(p)]; exec(compile(b,str(p),'exec'),{'__name__':'__main__','__file__':str(p)})") + argv = ["python3", "-c", bootstrap, remote + "/" + adapter.name, request["adapter_sha256"]] + response = run(["ssh", "-o", "BatchMode=yes", host, shlex.join(argv)], input=canonical(request)) + verify_remote(request, host) + result = json.loads(response) + required = {"source": SOURCE, "tree": request["tree"], "run_id": request["run_id"], + "inventory_sha256": request["inventory_sha256"], "adapter_sha256": request["adapter_sha256"]} + if any(result.get(key) != value for key, value in required.items()): + raise ValueError("Qualification identity mismatch") + if result.get("checks") != {name: "passed" for name in REQUIRED_CHECKS}: + raise ValueError("Qualification checks missing, failed or skipped") + return result + + +def verify_tooling(promotion_commit): + for name in ("promote-qualified-release.py", "qualify-core-release.py", "core-distribution-manifest.py"): + expected = base64.b64decode(api("contents/scripts/" + name + "?ref=" + promotion_commit)["content"]) + if pathlib.Path(__file__).with_name(name).read_bytes() != expected: + raise ValueError("Local tooling differs from reviewed commit: " + name) + + +def promote(assets, state, host, remote_root, promotion_commit): + if not re.fullmatch(r"[0-9a-f]{40}", promotion_commit): + raise ValueError("A reviewed full promotion commit is required") + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.@-]*", host): + raise ValueError("Invalid SSH host") + if (not re.fullmatch(r"/[A-Za-z0-9_./-]+", remote_root) + or ".." in pathlib.PurePosixPath(remote_root).parts): + raise ValueError("Remote root must be an absolute safe path") + adapter = pathlib.Path(__file__).with_name("qualify-core-release.py") + description = json.loads(run(["python3", str(adapter), "--describe"])) + if description != {"ready": True, "required_checks": list(REQUIRED_CHECKS)}: + raise ValueError("Live qualification adapter is not connected; no release changes made") + verify_tooling(promotion_commit) + state.mkdir(mode=0o700) # Never resume a previous pass record. + metadata, inventory = inspect_candidate(assets) + (state / "inventory.json").write_text(canonical(inventory) + "\n") + staged_bytes = sum(entry["size"] for entry in inventory.values()) + reserve = 64 * 1024 * 1024 + if shutil.disk_usage(state).free < reserve: + raise ValueError("Insufficient local evidence/streaming disk reserve") + (state / "capacity.json").write_text(canonical({"existing_asset_bytes": staged_bytes, + "additional_reserve_bytes": reserve}) + "\n") + if api("commits/" + SOURCE)["commit"]["tree"]["sha"] != metadata["source_tree"]: + raise ValueError("Candidate source tree mismatch") + verify_tag() + existing = release_state() + if existing and existing["target_commitish"] != SOURCE: + raise ValueError("Draft source target mismatch") + if existing and not existing["draft"]: + raise ValueError("Release already published; reconcile without rerunning acceptance") + if not existing: + notes = state / "release-notes.md" + notes.write_text("Fresh-install offline distribution from " + SOURCE + ".\n") + gh("release", "create", TAG, "--draft", "--target", SOURCE, + "--title", "OpenAgentCore " + SOURCE, "--notes-file", str(notes), + *[str(assets / name) for name in sorted(inventory)]) + downloaded = state / "downloaded" + download(downloaded, inventory) + initial_release = release_state() + if not initial_release or not initial_release["draft"] or initial_release["target_commitish"] != SOURCE: + raise ValueError("Draft identity changed") + request = {"source": SOURCE, "tree": metadata["source_tree"], "run_id": uuid.uuid4().hex, + "inventory": inventory, "inventory_sha256": hashlib.sha256(canonical(inventory).encode()).hexdigest(), + "required_checks": list(REQUIRED_CHECKS)} + remote = remote_root.rstrip("/") + "/" + request["run_id"] + request["directory"] = remote + result = qualification(request, host, remote, + adapter, assets) + (state / "request.json").write_text(canonical(request) + "\n") + (state / "qualification.json").write_text(canonical(result) + "\n") + verify_files(assets, inventory) + verify_landed(metadata["source_tree"], promotion_commit) + verify_tag() + current = release_state() + if (not current or not current["draft"] or current["id"] != initial_release["id"] + or current["target_commitish"] != SOURCE): + raise ValueError("Draft replaced or published during qualification") + download(state / "before-publication", inventory) + notes = state / "release-notes.md" + notes.write_text("Fresh-install offline distribution from " + SOURCE + ".\n\n" + + "Qualified checks: " + ", ".join(REQUIRED_CHECKS) + ".\n" + + "Asset inventory SHA256: " + request["inventory_sha256"] + ".\n" + + "Promotion tooling commit: " + promotion_commit + ".\n") + gh("release", "edit", TAG, "--draft=false", "--prerelease=false", "--latest", + "--notes-file", str(notes)) + download(state / "published", inventory) + final = release_state() + if not final or final["id"] != current["id"] or final["draft"] or final["prerelease"]: + raise ValueError("Final publication state mismatch") + verify_tag(required=True) + print(final["html_url"]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--promotion-commit", required=True, help="Reviewed tooling commit, never artifact provenance") + parser.add_argument("--assets", required=True, type=pathlib.Path, help="Flat candidate files only") + parser.add_argument("--state", required=True, type=pathlib.Path, help="New local evidence directory") + parser.add_argument("--host", required=True, help="Existing SSH host alias") + parser.add_argument("--remote-root", required=True, help="Existing isolated remote parent directory") + args = parser.parse_args() + promote(args.assets.resolve(), args.state.resolve(), args.host, args.remote_root, args.promotion_commit) + + +if __name__ == "__main__": + main() diff --git a/scripts/promote-qualified-release.test.py b/scripts/promote-qualified-release.test.py new file mode 100644 index 000000000..e49be607b --- /dev/null +++ b/scripts/promote-qualified-release.test.py @@ -0,0 +1,263 @@ +"""Controller refusal tests; these fixtures never count as live qualification.""" + +import hashlib +import importlib.util +import io +import json +import pathlib +import subprocess +import tarfile +import tempfile +import unittest +from unittest import mock + +spec = importlib.util.spec_from_file_location( + "promotion", pathlib.Path(__file__).with_name("promote-qualified-release.py")) +promotion = importlib.util.module_from_spec(spec) +spec.loader.exec_module(promotion) + + +class PromotionTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = pathlib.Path(self.temp.name) + self.assets = self.root / "assets" + self.assets.mkdir() + self.tree = "b" * 40 + self.metadata = { + "source_commit": promotion.SOURCE, "source_tree": self.tree, + "artifact_base_url": promotion.BASE, "platform": "linux/amd64", + "images": {name: "sha256:" + "a" * 64 for name in ("core", "web", "runtime", "database")}, + "image_manifest_digests": {name: "sha256:" + "b" * 64 for name in ("core", "web", "runtime", "database")}, + "runtime_ref": "oac-runtime@sha256:" + "c" * 64, "artifacts": {}, + } + for logical, suffix in promotion.distribution.ARTIFACTS.items(): + name = promotion.STEM + "-" + suffix + (self.assets / name).write_bytes(logical.encode()) + self.metadata["artifacts"][logical] = dict(promotion.file_identity(self.assets / name), filename=name) + self.archives() + + def archives(self): + for offline in (False, True): + data = {"manifest.json": json.dumps(self.metadata).encode(), "source.tar.gz": b"source fixture"} + data["SHA256SUMS"] = "".join( + hashlib.sha256(value).hexdigest() + " " + key + "\n" for key, value in data.items()).encode() + if offline: + data.update({"artifacts/" + entry["filename"]: (self.assets / entry["filename"]).read_bytes() + for entry in self.metadata["artifacts"].values()}) + name = promotion.STEM + ("-offline" if offline else "") + ".tar.gz" + with tarfile.open(self.assets / name, "w:gz") as archive: + for key, value in data.items(): + entry = tarfile.TarInfo(promotion.STEM + "/" + key) + entry.size = len(value) + archive.addfile(entry, io.BytesIO(value)) + checksum = promotion.file_identity(self.assets / name)["sha256"] + (self.assets / (name + ".sha256")).write_text(checksum + " " + name + "\n") + + def test_full_asset_inventory_and_archive_contents(self): + metadata, inventory = promotion.inspect_candidate(self.assets) + self.assertEqual(metadata, self.metadata) + promotion.verify_files(self.assets, inventory) + name = next(iter(self.metadata["artifacts"].values()))["filename"] + (self.assets / name).write_bytes(b"changed") + with self.assertRaisesRegex(ValueError, "checksum/size"): + promotion.inspect_candidate(self.assets) + with self.assertRaisesRegex(ValueError, "bytes/set"): + promotion.verify_files(self.assets, inventory) + + def test_source_and_url_mismatch(self): + for key in ("source_commit", "artifact_base_url"): + with self.subTest(key=key): + old = self.metadata[key] + self.metadata[key] = "wrong" + self.archives() + with self.assertRaisesRegex(ValueError, "source/platform/release URL"): + promotion.inspect_candidate(self.assets) + self.metadata[key] = old + + def test_missing_or_extra_asset_rejected(self): + extra = self.assets / "pass.json" + extra.write_text('{"passed":true}') + with self.assertRaisesRegex(ValueError, "asset set mismatch"): + promotion.inspect_candidate(self.assets) + extra.unlink() + name = next(iter(self.metadata["artifacts"].values()))["filename"] + (self.assets / name).unlink() + with self.assertRaisesRegex(ValueError, "checksum/size"): + promotion.inspect_candidate(self.assets) + + def test_missing_manifest_artifact_rejected(self): + self.metadata["artifacts"].pop(next(iter(self.metadata["artifacts"]))) + self.archives() + with self.assertRaisesRegex(ValueError, "Missing or unexpected"): + promotion.inspect_candidate(self.assets) + + def test_unlanded_and_unrelated_main_changes_rejected(self): + for comparison in ({"status": "diverged", "files": []}, + {"status": "ahead", "files": [{"filename": "services/agents-api/main.go"}]}, + {"status": "ahead", "files": [{"filename": "Makefile", "previous_filename": "go.mod"}]}): + with self.subTest(comparison=comparison), mock.patch.object( + promotion, "api", side_effect=[{"commit": {"tree": {"sha": self.tree}}}, comparison]): + with self.assertRaises(ValueError): + promotion.verify_landed(self.tree, "d" * 40) + + def test_landed_source_keeps_original_identity(self): + with mock.patch.object(promotion, "api", side_effect=[ + {"commit": {"tree": {"sha": self.tree}}}, + {"status": "ahead", "files": [{"filename": "scripts/promote-qualified-release.py"}]}, + {"commit": {"tree": {"sha": "e" * 40}}}, + {"sha": "f" * 40, "commit": {"tree": {"sha": "e" * 40}}}, + {"status": "ahead"}, + ]): + promotion.verify_landed(self.tree, "d" * 40) + + def test_conflicting_tag_rejected(self): + with mock.patch.object(promotion, "api", return_value=[{ + "ref": "refs/tags/" + promotion.TAG, "object": {"type": "commit", "sha": "f" * 40}, + }]): + with self.assertRaisesRegex(ValueError, "Conflicting"): + promotion.verify_tag() + + def test_main_tree_mismatch_blocks_old_candidate(self): + with mock.patch.object(promotion, "api", side_effect=[ + {"commit": {"tree": {"sha": self.tree}}}, {"status": "ahead", "files": []}, + {"commit": {"tree": {"sha": "d" * 40}}}, + {"sha": "e" * 40, "commit": {"tree": {"sha": "f" * 40}}}, + ]): + with self.assertRaisesRegex(ValueError, "Main tree differs"): + promotion.verify_landed(self.tree, "a" * 40) + + def test_makefile_allowance_cannot_hide_build_changes(self): + encode = lambda value: promotion.base64.b64encode(value.encode()).decode() + anchor = "\tPYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py\n" + with mock.patch.object(promotion, "api", side_effect=[ + {"commit": {"tree": {"sha": self.tree}}}, + {"status": "ahead", "files": [{"filename": "Makefile"}]}, + {"content": encode(anchor)}, {"content": encode(anchor + "build:\n\techo changed\n")}, + ]): + with self.assertRaisesRegex(ValueError, "exceeds promotion test"): + promotion.verify_landed(self.tree, "a" * 40) + + def test_adapter_not_ready_has_no_remote_side_effects(self): + with mock.patch.object(promotion, "gh") as gh, mock.patch.object(promotion, "api") as api: + with self.assertRaisesRegex(ValueError, "not connected"): + promotion.promote(self.assets, self.root / "state", "mx2", "/tmp/acceptance", "d" * 40) + gh.assert_not_called() + api.assert_not_called() + self.assertFalse((self.root / "state").exists()) + + def qualify(self, checks=None, failure=False, wrong_identity=False): + adapter = self.root / "adapter.py" + adapter.write_text("# Transport test fixture, never live acceptance\n") + request = {"source": promotion.SOURCE, "tree": self.tree, "run_id": "test-run", + "inventory_sha256": "c" * 64, "directory": "/tmp/test", "inventory": {}} + + def transport(argv, **kwargs): + if "exec(compile" in argv[-1]: + if failure: + raise subprocess.CalledProcessError(1, argv) + result = {key: request[key] for key in ( + "source", "tree", "run_id", "inventory_sha256", "adapter_sha256")} + result["checks"] = checks if checks is not None else {name: "passed" for name in promotion.REQUIRED_CHECKS} + if wrong_identity: + result["run_id"] = "previous-run" + return json.dumps(result) + return "" + + with mock.patch.object(promotion, "run", side_effect=transport): + return promotion.qualification(request, "mx2", "/tmp/test", adapter, self.assets) + + def test_nonzero_ssh_missing_checks_and_replayed_result_rejected(self): + with self.assertRaises(subprocess.CalledProcessError): + self.qualify(failure=True) + with self.assertRaisesRegex(ValueError, "missing, failed or skipped"): + self.qualify(checks={"fresh-install": "passed"}) + with self.assertRaisesRegex(ValueError, "identity mismatch"): + self.qualify(wrong_identity=True) + + def test_complete_transport_result(self): + self.assertEqual(self.qualify()["checks"], {name: "passed" for name in promotion.REQUIRED_CHECKS}) + + def test_publication_waits_for_live_result_and_merge(self): + for failure in ("qualification", "verify_landed"): + with self.subTest(failure=failure): + state = self.root / failure + draft = {"id": 1, "draft": True, "target_commitish": promotion.SOURCE} + ready = json.dumps({"ready": True, "required_checks": list(promotion.REQUIRED_CHECKS)}) + with mock.patch.object(promotion, "run", return_value=ready), \ + mock.patch.object(promotion, "verify_tooling"), \ + mock.patch.object(promotion, "api", return_value={"commit": {"tree": {"sha": self.tree}}}), \ + mock.patch.object(promotion, "verify_tag"), \ + mock.patch.object(promotion, "release_state", return_value=draft), \ + mock.patch.object(promotion, "download"), \ + mock.patch.object(promotion, "verify_files"), \ + mock.patch.object(promotion, "qualification", return_value={}) as qualify, \ + mock.patch.object(promotion, "verify_landed") as landed, \ + mock.patch.object(promotion, "gh") as gh: + (qualify if failure == "qualification" else landed).side_effect = ValueError("blocked") + with self.assertRaisesRegex(ValueError, "blocked"): + promotion.promote(self.assets, state, "mx2", "/tmp/acceptance", "d" * 40) + gh.assert_not_called() + + def test_success_publishes_only_after_final_download_verification(self): + draft = {"id": 1, "draft": True, "target_commitish": promotion.SOURCE} + final = dict(draft, draft=False, prerelease=False, html_url="https://example.invalid/release") + ready = json.dumps({"ready": True, "required_checks": list(promotion.REQUIRED_CHECKS)}) + events = [] + + def download(path, inventory): + events.append(path.name) + + with mock.patch.object(promotion, "run", return_value=ready), \ + mock.patch.object(promotion, "verify_tooling"), \ + mock.patch.object(promotion, "api", return_value={"commit": {"tree": {"sha": self.tree}}}), \ + mock.patch.object(promotion, "verify_tag"), \ + mock.patch.object(promotion, "release_state", side_effect=[draft, draft, draft, final]), \ + mock.patch.object(promotion, "download", side_effect=download), \ + mock.patch.object(promotion, "verify_files"), \ + mock.patch.object(promotion, "qualification", side_effect=lambda *args: events.append("qualified") or {}), \ + mock.patch.object(promotion, "verify_landed", side_effect=lambda *args: events.append("landed")), \ + mock.patch.object(promotion, "gh", side_effect=lambda *args: events.append(args[:3])), \ + mock.patch("builtins.print"): + promotion.promote(self.assets, self.root / "success", "mx2", "/tmp/acceptance", "d" * 40) + self.assertEqual(events, ["downloaded", "qualified", "landed", "before-publication", + ("release", "edit", promotion.TAG), "published"]) + + def test_download_hashes_stream_without_storing_an_archive(self): + payload = b"downloaded bytes" + inventory = {"asset.tar.gz": {"sha256": hashlib.sha256(payload).hexdigest(), "size": len(payload)}} + class Process: + def __init__(self, *args, **kwargs): + self.stdout = io.BytesIO(payload) + def __enter__(self): + return self + def __exit__(self, *args): + self.stdout.close() + def wait(self): + return 0 + with mock.patch.object(promotion, "release_state", return_value={"id": 1}), \ + mock.patch.object(promotion, "run", return_value='[[{"name":"asset.tar.gz"}]]'), \ + mock.patch.object(promotion.subprocess, "Popen", Process): + promotion.download(self.root / "streamed", inventory) + self.assertTrue((self.root / "streamed.json").is_file()) + self.assertFalse((self.root / "streamed").exists()) + inventory["asset.tar.gz"]["sha256"] = "0" * 64 + with self.assertRaisesRegex(ValueError, "bytes changed"): + promotion.download(self.root / "bad-stream", inventory) + self.assertFalse((self.root / "bad-stream.json").exists()) + + def test_recorded_pass_cannot_resume_an_interrupted_run(self): + state = self.root / "previous-run" + state.mkdir() + (state / "qualification.json").write_text('{"passed":true}') + ready = json.dumps({"ready": True, "required_checks": list(promotion.REQUIRED_CHECKS)}) + with mock.patch.object(promotion, "run", return_value=ready), \ + mock.patch.object(promotion, "verify_tooling"), mock.patch.object(promotion, "gh") as gh: + with self.assertRaises(FileExistsError): + promotion.promote(self.assets, state, "mx2", "/tmp/acceptance", "d" * 40) + gh.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/qualify-core-release.py b/scripts/qualify-core-release.py new file mode 100644 index 000000000..868d70952 --- /dev/null +++ b/scripts/qualify-core-release.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Reserved entrypoint for this batch's real fresh-install qualification. + +The controller executes these reviewed bytes over authenticated SSH. stdin is one +JSON request with source, tree, run_id, directory, inventory, inventory_sha256, +adapter_sha256 and required_checks. The directory contains the exact downloaded +release assets. The implementation must verify their hashes before and after real +checks and freshly extract them into newly owned installation resources. stdout +must contain one JSON response echoing the identity fields and checks mapping each +required check to passed. Send only redacted diagnostics to stderr. A failed or +skipped required check must exit nonzero. Never load completion from a supplied +pass file. Resources must follow the batch's separately agreed ownership bounds. + +This interface deliberately cannot pass until the actual acceptance commands are +integrated and independently reviewed. No synthetic fallback is provided. +""" + +import json +import sys + + +if __name__ == "__main__": + if sys.argv[1:] == ["--describe"]: + print(json.dumps({"ready": False, "required_checks": [ + "fresh-install", "current-lifecycle", "managed-native", + "current-generations", "node-runtime", "diagnostics-observations", + ]})) + sys.exit(0) + sys.exit("Live qualification adapter is not connected; publication is blocked") From 6521aedf64b974be14b89e24a64b20c58c22b2cf Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 12:10:33 +0000 Subject: [PATCH 02/78] Document fresh-only node program installation policy --- .../agents-api/node-generation-protocol.md | 70 +++++++------------ 1 file changed, 27 insertions(+), 43 deletions(-) diff --git a/contracts/agents-api/node-generation-protocol.md b/contracts/agents-api/node-generation-protocol.md index 17d23342b..1fdb2de57 100644 --- a/contracts/agents-api/node-generation-protocol.md +++ b/contracts/agents-api/node-generation-protocol.md @@ -66,8 +66,9 @@ Neither opener adopts a missing identity, replaces its inode, or erases it after GC. Initialization interrupted before the identity is durable refuses re-adoption; preserve the installation for inspection. A removed identity or an owned replacement 0600 lease still refuses, even when its current fstat/lstat agree. -The approved v1 update initializes this new fence before starting v2 but retains -its separate legacy-unfenced marker for already-running old helpers. +Historical v1 installations and their `legacy-unfenced` records are retained +for inspection; the current installer does not adopt or upgrade them. Do not +create a new fence to bypass a missing historical lease identity. A dropped generation cannot be prepared or used again; a future rollback would require a new generation and a separate policy. @@ -151,8 +152,9 @@ metadata refuse cleanup. Interruption resumes under the same collection journal. The current node executable, preparer, identity, base provider configuration and manifests remain, so restart can read its enrolled identity and construct a newer retained provider after the original Runtime bytes have gone. Shared native paths -are compared across all retained configurations before removal. The v1 -legacy-unfenced marker still prevents any collection of its original payload. +are compared across all retained configurations before removal. A historical +`legacy-unfenced` marker remains a reason to retain its original payload, not +evidence that upgrading or serving the historical installation is supported. New preparation has two distinct records. Before downloads, `.preparing` holds the immutable installation/generation/specification identity, private provider paths @@ -176,7 +178,9 @@ collection precedes any import attempt, the preparation journal proves that this generation has no imported native image. An older or interrupted generation whose native executable is missing and whose import may have started remains retained; missing files do not prove native absence. Receipt/store history and a -legacy-unfenced generation are never erased using an empty native inventory. +historical `legacy-unfenced` record are never erased using an empty native +inventory. These retention checks do not authorize historical installation +conversion or adoption. Preparation diagnostics preserve fixed typed causes. Only artifact transfer, checksum or release-provenance failures report `runtime_download_failed`. A private @@ -190,45 +194,25 @@ then may atomically add only absent, checksum-matched declared artifacts. This supports thin-release completion and retained HTTP artifact repair. Any existing conflict prevents all additions; repair never overwrites a conflicting artifact. -## Identity-preserving program updates - -Run the checksum-verified `node-install.pyz --update` as the original installation -owner, with the same installation ID and Core URL and the selected release's -`--source-url` or `--bundle`. Do not supply a new enrollment token. The installer -first authenticates the retained node through a read-only configuration request. -It verifies and stages the new node executable and matching private preparer, -checks the executable's `protocol-version`, and persists a resume journal before -stopping the fixed service unit. This capability check is not Runtime readiness. - -The journal pins the staged file hashes. Rerunning an interrupted update resumes -those files even if the console has since published another release. Atomic file -replacement changes the node program and preparer; the enrolled provider, -credential, Runtime identity, resource files and retained generation state remain. -The previous executable is preserved separately from Runtime releases. Core must -confirm connection and provider readiness before the journal becomes complete. -An unavailable Core or failed restart leaves recoverable files and journal; rerun -the same command after resolving the reported error. - -The existing units use `KillMode=process`: update stops and restarts their main -node process without terminating native VMs or helper processes. System-mode -updates reuse the existing recorded service account; all user-owned files and -programs are handled after dropping privileges. Root controls only the fixed -root-owned service unit and host installation lock. - -A v1-to-v2 update cannot retrospectively add a file lock to an already-running v1 -helper. Before stopping v1, it durably marks precisely the original enrolled -generation `legacy-unfenced`. Automatic collection retains that generation's local -payload and store across interruptions and node restarts. Unknown original -generation identity refuses the update. Neither an empty allocation list nor PID -absence clears this marker. It is a single-generation disk-retention limitation, -not a Core resource, serving-readiness or admission claim. Fresh v2 installations -and subsequent generations use normal flock-based collection. No historical VM -or daemon is killed to clear the marker. +## Node program version policy + +Node program upgrades, historical conversion and adoption are not supported. +`node-install.pyz --update` refuses before changing the installation. Preserve old +installation files, credentials, Runtime stores, provider resources and history; +install the current program separately through the ordinary fresh-node enrollment +flow. Reinstallation does not automatically delete or migrate existing data. + +Current Runtime generation changes operate within an installation of the current +node program. They do not upgrade that program or establish compatibility with +historical node installations. A historical `legacy-unfenced` marker records that +older helpers did not share the current lease protocol. Preserve its files and +resources for inspection; do not clear it, recreate its lease, or infer safe +collection from an empty allocation list, process absence or a node restart. ## Qualification boundary Protocol and process tests do not qualify Runtime readiness, VM coexistence, -native image/store locking, or identity-preserving updates. Those require the -separate exact-artifact KVM and Docker acceptance matrix. In particular, immutable -legacy helper descendant FD behavior must be recorded against its actual artifact; -absence of local test coverage is not evidence of successful garbage collection. +or native image/store locking. Current-version Runtime behavior requires the +separate exact-artifact KVM and Docker acceptance matrix. Historical helper +behavior remains historical evidence, not a supported upgrade workflow; absence +of local test coverage is not evidence of successful garbage collection. From 2474ed4665d98b5a05c3eee9fe70009911b999da Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 12:47:12 +0000 Subject: [PATCH 03/78] Supervise reviewed private qualification packages and wait for batch merge --- CONTRIBUTING.md | 40 +++-- docs/maintainers.md | 43 ++++- scripts/promote-qualified-release.py | 74 ++++++-- scripts/promote-qualified-release.test.py | 162 +++++++++++++++-- scripts/qualify-core-release.py | 206 +++++++++++++++++++--- 5 files changed, 461 insertions(+), 64 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce98e3d1c..c923df7ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1964,19 +1964,27 @@ image containing a private test CA or model credential as a release input. Repository visibility is independent of publication. Do not add repository credentials to installed node/Runtime configuration to bypass download access. -For the current installation batch, `scripts/promote-qualified-release.py` fixes -candidate provenance and its tag to source -`48ed8158e134207d15cdd14ae0a30e10f070eb5c`. It uses existing local gh authentication -and SSH, uploads/downloads the complete matching thin/offline/Runtime asset set, -and verifies archive members and full asset hashes. The reviewed qualification -adapter receives a fresh run identity and exact inventory over the authenticated -command channel. Required checks are fresh-install, current-lifecycle, -managed-native, current-generations, node-runtime and diagnostics-observations. -All must run successfully; a supplied pass file, skipped check or old report cannot -release the candidate. The adapter currently refuses execution until the real -batch acceptance commands are connected. Controller tests never count as live -qualification. Historical reset evidence stays historical; later self-hosted -capability work is outside this batch. +`scripts/promote-qualified-release.py` requires an explicit full candidate source +SHA, binds it to the archive manifests and matching `build-` tag, and uses +existing local gh authentication and SSH. It uploads/downloads the complete +matching thin/offline/Runtime asset set and verifies archive members and asset +hashes. The separate qualification package is supplied by the maintainer with an +explicit reviewed manifest SHA256. Its complete file inventory, ordered Python +commands, bounded stage timeouts and private path/resource configuration are +verified before any Release mutation and again by the remote supervisor. Candidate +assets cannot select or replace this execution package. Keep host-specific +acceptance scripts, usernames and credential paths outside this public repository; +never put credential values in either manifest. + +The reviewed adapter receives a fresh canonical UUID and exact inventory over the +authenticated command channel. It directly supervises fresh-install, +current-lifecycle, managed-native, current-generations, node-runtime and +diagnostics-observations in that order. Every child must exit successfully and +return only its own passed check, the current controller identity and its observed +owned resources. Resources and the previous result flow between live children; +a supplied pass file, skipped check or old report cannot release the candidate. +Verify package and candidate bytes again after each stage. Control tests exercise +short-lived fixture children only and never establish live qualification. The caller supplies the independently reviewed promotion-tooling commit. Its changes from the candidate may only affect the exact promotion files enumerated @@ -1992,7 +2000,11 @@ commit. Product changes or a different main tree block promotion of the old candidate. Never infer batch membership from all open PRs or automatically merge them in the publication command. -Use one controller invocation for the batch. It verifies unchanged draft identity, +Use one controller invocation for the batch. After successful qualification it +waits in the same process, within the explicit merge-wait budget, for the exact +reviewed batch tree to reach main. An ancestor main waits; conflicting main changes +fail immediately. Cancellation or expiration retains evidence and cannot turn a +saved result into resume authority. It verifies unchanged draft identity, target, tag and downloaded bytes immediately before publication and checks the published bytes afterward. Conflicting assets are never overwritten. An interrupted run is reconciled before another invocation; stored qualification output is evidence, diff --git a/docs/maintainers.md b/docs/maintainers.md index eb13d5be1..a95612bb5 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -64,17 +64,23 @@ operator host with gh authentication and SSH access to the qualification host: ```sh python3 scripts/promote-qualified-release.py \ + --source FULL_CANDIDATE_SOURCE_SHA \ --assets /absolute/path/to/flat-candidate-assets \ + --qualification-package /absolute/path/to/reviewed-private-package \ + --qualification-manifest-sha256 FULL_REVIEWED_MANIFEST_SHA256 \ + --merge-wait-seconds 86400 \ --state /absolute/path/to/new-promotion-evidence \ --host zju_a100_2 --remote-root /absolute/path/to/existing-isolated-acceptance-root \ --promotion-commit FULL_REVIEWED_TOOLING_COMMIT_SHA ``` This command is an operational publication command, not a dry run. It does not -build, merge PRs, install services or create host accounts. Its qualification adapter -currently reports `ready=false`, so it stops before any Release operation until the -real batch checks are integrated and reviewed. Do not change readiness just to -bypass the missing implementation. +build, merge PRs or create host accounts. Its directly supervised qualification +scripts perform the reviewed fresh installation and real execution actions. Before +starting, independently review the private package and record its manifest hash. +The package is separate from the candidate. `ready=true` describes the generic +adapter protocol only; it does not establish package review, host readiness or +successful live qualification. Stage only the build's flat files, including thin/offline archives, both checksum files and every versioned Runtime asset; exclude the extracted bundle directory. @@ -96,7 +102,34 @@ checks cover fresh installation, current lifecycle, managed native execution, current generations, node Runtime and diagnostics/observations. Their actual commands must operate on freshly extracted supplied assets and respect the agreed resource ownership. The controller verifies remote asset hashes before and after -execution; it has no pass-file or arbitrary acceptance-command option. +execution; it has no pass-file option. Execution commands come only from the +maintainer-pinned package, never from candidate metadata or a stage result. + +The package root contains `manifest.json` and exactly its enumerated regular files; +symlinks, extra files and changed bytes are rejected. Manifest version 1 has `files` +(relative path to SHA256/size), `configuration` (reviewed resource bounds and private +file paths, no credential values), and six ordered `stages`. Each stage has `name`, +absolute `python` interpreter path, package-relative `.py` `script`, structured +string `args`, and `timeout_seconds` (1–14400). No shell command or candidate-driven +substitution is used. The explicit SHA256 covers the exact manifest bytes. Preserve +the reviewed package together with all evidence. + +The supervisor gives each child the unchanged controller identity and inventory, +`qualification_package`, `qualification_manifest_sha256`, `package_configuration`, +`owned_resources`, and `previous_stage_result`. The latter two come from the actual +preceding child, initially empty/null. Private wrappers adapt host-specific harness +interfaces and must verify their detailed subchecks before returning +`status: passed`, exact `checks: {stage-name: passed}`, the five identity fields +(source/tree/run_id/inventory_sha256/adapter_sha256), and `owned_resources`. +The run ID is a canonical UUID string. Child stdout/stderr remain private files; +nonzero exit, timeout, changed bytes or mismatched identity stops the sequence. + +After all six stages pass, the same controller waits up to `--merge-wait-seconds` +for main to reach the exact reviewed promotion tree. While main is an ancestor of +that reviewed commit it continues waiting; divergent changes stop publication. +Do not restart merely because the merge is pending. Cancellation or timeout retains +resources and evidence but cannot resume from a recorded pass. The command never +merges the batch itself. This finite command publishes automatically when all checks pass and the batch is landed. Candidate source and tag remain diff --git a/scripts/promote-qualified-release.py b/scripts/promote-qualified-release.py index 69427637a..c88629df5 100644 --- a/scripts/promote-qualified-release.py +++ b/scripts/promote-qualified-release.py @@ -12,6 +12,7 @@ import shutil import subprocess import tarfile +import time import tempfile import uuid @@ -21,10 +22,25 @@ spec.loader.exec_module(distribution) REPO = "MiniMax-AI/parsar-core" -SOURCE = "48ed8158e134207d15cdd14ae0a30e10f070eb5c" -TAG = "build-" + SOURCE -BASE = "https://github.com/" + REPO + "/releases/download/" + TAG -STEM = "oac-" + SOURCE + "-linux-amd64" +SOURCE = TAG = BASE = STEM = None + + +def select_source(source): + """One invocation owns one explicit immutable candidate identity.""" + if not isinstance(source, str) or not re.fullmatch(r"[0-9a-f]{40}", source): + raise ValueError("An explicit full candidate source commit is required") + global SOURCE, TAG, BASE, STEM + SOURCE = source + TAG = "build-" + source + BASE = "https://github.com/" + REPO + "/releases/download/" + TAG + STEM = "oac-" + source + "-linux-amd64" + + +adapter_spec = importlib.util.spec_from_file_location( + "qualification_adapter", pathlib.Path(__file__).with_name("qualify-core-release.py")) +qualification_adapter = importlib.util.module_from_spec(adapter_spec) +adapter_spec.loader.exec_module(qualification_adapter) + REQUIRED_CHECKS = ("fresh-install", "current-lifecycle", "managed-native", "current-generations", "node-runtime", "diagnostics-observations") # Only this separate promotion change may follow the qualified source on main. @@ -153,7 +169,7 @@ def verify_files(directory, inventory): raise ValueError("Release asset bytes/set changed") -def verify_landed(tree, promotion_commit): +def verify_landed(tree, promotion_commit, allow_pending=False): source = api("commits/" + SOURCE) if source["commit"]["tree"]["sha"] != tree: raise ValueError("Source tree differs from GitHub commit") @@ -173,10 +189,25 @@ def verify_landed(tree, promotion_commit): reviewed_tree = api("commits/" + promotion_commit)["commit"]["tree"]["sha"] main = api("commits/main") if main["commit"]["tree"]["sha"] != reviewed_tree: + if allow_pending: + pending = api("compare/" + main["sha"] + "..." + promotion_commit) + if pending["status"] == "ahead": + return False raise ValueError("Main tree differs from reviewed promotion commit") comparison = api("compare/" + SOURCE + "..." + main["sha"]) if comparison["status"] not in ("identical", "ahead"): raise ValueError("Qualified source has not landed on main") + return True + + +def wait_for_landed(tree, promotion_commit, timeout_seconds): + deadline = time.monotonic() + timeout_seconds + while not verify_landed(tree, promotion_commit, allow_pending=True): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Reviewed batch did not land within the merge wait budget") + # Qualification remains in this process; no saved pass file is reloaded. + time.sleep(min(30, remaining)) def release_state(): @@ -245,13 +276,17 @@ def verify_remote(request, host): input=canonical(request)) -def qualification(request, host, remote, adapter, downloaded): +def qualification(request, host, remote, adapter, downloaded, package, manifest_hash): adapter_bytes = adapter.read_bytes() request["adapter_sha256"] = hashlib.sha256(adapter_bytes).hexdigest() command = shlex.join(["mkdir", "-m", "700", remote]) run(["ssh", "-o", "BatchMode=yes", host, command]) run(["scp", "-q", "--", str(adapter), *[str(p) for p in sorted(downloaded.iterdir())], host + ":" + remote + "/"]) + qualification_adapter.verify_package(package, manifest_hash) + run(["scp", "-q", "-r", "--", str(package), host + ":" + remote + "/tools"]) + request["qualification_package"] = remote + "/tools" + request["qualification_manifest_sha256"] = manifest_hash verify_remote(request, host) # Hash and execute the same bytes, avoiding a check-then-open script race. bootstrap = ("import hashlib,pathlib,sys; p=pathlib.Path(sys.argv[1]); b=p.read_bytes(); " @@ -265,7 +300,8 @@ def qualification(request, host, remote, adapter, downloaded): "inventory_sha256": request["inventory_sha256"], "adapter_sha256": request["adapter_sha256"]} if any(result.get(key) != value for key, value in required.items()): raise ValueError("Qualification identity mismatch") - if result.get("checks") != {name: "passed" for name in REQUIRED_CHECKS}: + if (result.get("status") != "passed" or result.get("qualification_manifest_sha256") != manifest_hash + or result.get("checks") != {name: "passed" for name in REQUIRED_CHECKS}): raise ValueError("Qualification checks missing, failed or skipped") return result @@ -277,7 +313,14 @@ def verify_tooling(promotion_commit): raise ValueError("Local tooling differs from reviewed commit: " + name) -def promote(assets, state, host, remote_root, promotion_commit): +def promote(assets, state, host, remote_root, promotion_commit, *, source, package, manifest_hash, + merge_wait_seconds=86400): + select_source(source) + if type(merge_wait_seconds) is not int or not 1 <= merge_wait_seconds <= 604800: + raise ValueError("Merge wait must be between one second and seven days") + qualification_adapter.verify_package(package, manifest_hash) + if package == assets or package.is_relative_to(assets): + raise ValueError("Qualification tooling must be separate from candidate assets") if not re.fullmatch(r"[0-9a-f]{40}", promotion_commit): raise ValueError("A reviewed full promotion commit is required") if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.@-]*", host): @@ -318,17 +361,17 @@ def promote(assets, state, host, remote_root, promotion_commit): initial_release = release_state() if not initial_release or not initial_release["draft"] or initial_release["target_commitish"] != SOURCE: raise ValueError("Draft identity changed") - request = {"source": SOURCE, "tree": metadata["source_tree"], "run_id": uuid.uuid4().hex, + request = {"source": SOURCE, "tree": metadata["source_tree"], "run_id": str(uuid.uuid4()), "inventory": inventory, "inventory_sha256": hashlib.sha256(canonical(inventory).encode()).hexdigest(), "required_checks": list(REQUIRED_CHECKS)} remote = remote_root.rstrip("/") + "/" + request["run_id"] request["directory"] = remote result = qualification(request, host, remote, - adapter, assets) + adapter, assets, package, manifest_hash) (state / "request.json").write_text(canonical(request) + "\n") (state / "qualification.json").write_text(canonical(result) + "\n") verify_files(assets, inventory) - verify_landed(metadata["source_tree"], promotion_commit) + wait_for_landed(metadata["source_tree"], promotion_commit, merge_wait_seconds) verify_tag() current = release_state() if (not current or not current["draft"] or current["id"] != initial_release["id"] @@ -352,13 +395,20 @@ def promote(assets, state, host, remote_root, promotion_commit): def main(): parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", required=True, help="Full candidate source commit; never inferred from latest") + parser.add_argument("--qualification-package", required=True, type=pathlib.Path, + help="Separately reviewed private package directory, containing manifest.json") + parser.add_argument("--qualification-manifest-sha256", required=True, help="Explicit reviewed manifest digest") + parser.add_argument("--merge-wait-seconds", type=int, default=86400, help="Same-process merge wait, at most seven days") parser.add_argument("--promotion-commit", required=True, help="Reviewed tooling commit, never artifact provenance") parser.add_argument("--assets", required=True, type=pathlib.Path, help="Flat candidate files only") parser.add_argument("--state", required=True, type=pathlib.Path, help="New local evidence directory") parser.add_argument("--host", required=True, help="Existing SSH host alias") parser.add_argument("--remote-root", required=True, help="Existing isolated remote parent directory") args = parser.parse_args() - promote(args.assets.resolve(), args.state.resolve(), args.host, args.remote_root, args.promotion_commit) + promote(args.assets.resolve(), args.state.resolve(), args.host, args.remote_root, args.promotion_commit, + source=args.source, package=args.qualification_package.resolve(), + manifest_hash=args.qualification_manifest_sha256, merge_wait_seconds=args.merge_wait_seconds) if __name__ == "__main__": diff --git a/scripts/promote-qualified-release.test.py b/scripts/promote-qualified-release.test.py index e49be607b..8f7eabc03 100644 --- a/scripts/promote-qualified-release.test.py +++ b/scripts/promote-qualified-release.test.py @@ -6,6 +6,7 @@ import json import pathlib import subprocess +import sys import tarfile import tempfile import unittest @@ -19,11 +20,21 @@ class PromotionTests(unittest.TestCase): def setUp(self): + promotion.select_source("a" * 40) self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.root = pathlib.Path(self.temp.name) self.assets = self.root / "assets" self.assets.mkdir() + self.package = self.root / "private-package" + self.package.mkdir() + (self.package / "fixture.py").write_text("# Control fixture, never live qualification\n") + self.manifest = {"version": 1, "files": {"fixture.py": promotion.file_identity(self.package / "fixture.py")}, + "configuration": {}, "stages": [{"name": name, "python": sys.executable, + "script": "fixture.py", "args": [], "timeout_seconds": 3} for name in promotion.REQUIRED_CHECKS]} + (self.package / "manifest.json").write_text(promotion.canonical(self.manifest)) + self.manifest_hash = promotion.file_identity(self.package / "manifest.json")["sha256"] + self.kwargs = dict(source=promotion.SOURCE, package=self.package, manifest_hash=self.manifest_hash) self.tree = "b" * 40 self.metadata = { "source_commit": promotion.SOURCE, "source_tree": self.tree, @@ -139,10 +150,11 @@ def test_makefile_allowance_cannot_hide_build_changes(self): with self.assertRaisesRegex(ValueError, "exceeds promotion test"): promotion.verify_landed(self.tree, "a" * 40) - def test_adapter_not_ready_has_no_remote_side_effects(self): + def test_unreviewed_package_has_no_remote_side_effects(self): + (self.package / "fixture.py").write_text("changed") with mock.patch.object(promotion, "gh") as gh, mock.patch.object(promotion, "api") as api: - with self.assertRaisesRegex(ValueError, "not connected"): - promotion.promote(self.assets, self.root / "state", "mx2", "/tmp/acceptance", "d" * 40) + with self.assertRaisesRegex(ValueError, "bytes/set mismatch"): + promotion.promote(self.assets, self.root / "state", "mx2", "/tmp/acceptance", "d" * 40, **self.kwargs) gh.assert_not_called() api.assert_not_called() self.assertFalse((self.root / "state").exists()) @@ -159,6 +171,8 @@ def transport(argv, **kwargs): raise subprocess.CalledProcessError(1, argv) result = {key: request[key] for key in ( "source", "tree", "run_id", "inventory_sha256", "adapter_sha256")} + result["status"] = "passed" + result["qualification_manifest_sha256"] = self.manifest_hash result["checks"] = checks if checks is not None else {name: "passed" for name in promotion.REQUIRED_CHECKS} if wrong_identity: result["run_id"] = "previous-run" @@ -166,7 +180,7 @@ def transport(argv, **kwargs): return "" with mock.patch.object(promotion, "run", side_effect=transport): - return promotion.qualification(request, "mx2", "/tmp/test", adapter, self.assets) + return promotion.qualification(request, "mx2", "/tmp/test", adapter, self.assets, self.package, self.manifest_hash) def test_nonzero_ssh_missing_checks_and_replayed_result_rejected(self): with self.assertRaises(subprocess.CalledProcessError): @@ -180,7 +194,7 @@ def test_complete_transport_result(self): self.assertEqual(self.qualify()["checks"], {name: "passed" for name in promotion.REQUIRED_CHECKS}) def test_publication_waits_for_live_result_and_merge(self): - for failure in ("qualification", "verify_landed"): + for failure in ("qualification", "wait_for_landed"): with self.subTest(failure=failure): state = self.root / failure draft = {"id": 1, "draft": True, "target_commitish": promotion.SOURCE} @@ -193,11 +207,11 @@ def test_publication_waits_for_live_result_and_merge(self): mock.patch.object(promotion, "download"), \ mock.patch.object(promotion, "verify_files"), \ mock.patch.object(promotion, "qualification", return_value={}) as qualify, \ - mock.patch.object(promotion, "verify_landed") as landed, \ + mock.patch.object(promotion, "wait_for_landed") as landed, \ mock.patch.object(promotion, "gh") as gh: (qualify if failure == "qualification" else landed).side_effect = ValueError("blocked") with self.assertRaisesRegex(ValueError, "blocked"): - promotion.promote(self.assets, state, "mx2", "/tmp/acceptance", "d" * 40) + promotion.promote(self.assets, state, "mx2", "/tmp/acceptance", "d" * 40, **self.kwargs) gh.assert_not_called() def test_success_publishes_only_after_final_download_verification(self): @@ -217,10 +231,10 @@ def download(path, inventory): mock.patch.object(promotion, "download", side_effect=download), \ mock.patch.object(promotion, "verify_files"), \ mock.patch.object(promotion, "qualification", side_effect=lambda *args: events.append("qualified") or {}), \ - mock.patch.object(promotion, "verify_landed", side_effect=lambda *args: events.append("landed")), \ + mock.patch.object(promotion, "wait_for_landed", side_effect=lambda *args: events.append("landed")), \ mock.patch.object(promotion, "gh", side_effect=lambda *args: events.append(args[:3])), \ mock.patch("builtins.print"): - promotion.promote(self.assets, self.root / "success", "mx2", "/tmp/acceptance", "d" * 40) + promotion.promote(self.assets, self.root / "success", "mx2", "/tmp/acceptance", "d" * 40, **self.kwargs) self.assertEqual(events, ["downloaded", "qualified", "landed", "before-publication", ("release", "edit", promotion.TAG), "published"]) @@ -255,9 +269,137 @@ def test_recorded_pass_cannot_resume_an_interrupted_run(self): with mock.patch.object(promotion, "run", return_value=ready), \ mock.patch.object(promotion, "verify_tooling"), mock.patch.object(promotion, "gh") as gh: with self.assertRaises(FileExistsError): - promotion.promote(self.assets, state, "mx2", "/tmp/acceptance", "d" * 40) + promotion.promote(self.assets, state, "mx2", "/tmp/acceptance", "d" * 40, **self.kwargs) gh.assert_not_called() +class SupervisionTests(unittest.TestCase): + """Actual short-lived child fixtures exercise supervision, never Core acceptance.""" + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = pathlib.Path(self.temp.name) + self.package = self.root / 'tools' + self.package.mkdir() + self.assets = self.root / 'assets' + self.assets.mkdir() + (self.assets / 'fixture.tar.gz').write_bytes(b'not a release') + self.script = self.package / 'fixture.py' + self.script.write_text("""import json,sys +q=json.load(sys.stdin);name=sys.argv[1] +r={k:q[k] for k in ('source','tree','run_id','inventory_sha256','adapter_sha256')} +count=q['owned_resources'].get('child_count',0) +assert (q['previous_stage_result'] is None)==(count==0) +r.update(status='passed',checks={name:'passed'},owned_resources={'child_count':count+1}) +print(json.dumps(r)) +""") + self.manifest = {'version': 1, 'configuration': {}, 'files': {}, 'stages': [ + {'name': name, 'python': sys.executable, 'script': 'fixture.py', 'args': [name], + 'timeout_seconds': 3} for name in promotion.REQUIRED_CHECKS]} + inventory = {'fixture.tar.gz': promotion.file_identity(self.assets / 'fixture.tar.gz')} + self.request = {'source': 'a'*40, 'tree': 'b'*40, 'run_id': 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + 'directory': str(self.assets), 'inventory': inventory, + 'inventory_sha256': hashlib.sha256(promotion.canonical(inventory).encode()).hexdigest(), + 'adapter_sha256': 'd'*64, 'required_checks': list(promotion.REQUIRED_CHECKS), + 'qualification_package': str(self.package)} + self.freeze() + + def freeze(self): + self.manifest['files'] = {'fixture.py': promotion.file_identity(self.script)} + path = self.package / 'manifest.json' + path.write_text(promotion.canonical(self.manifest)) + self.request['qualification_manifest_sha256'] = promotion.file_identity(path)['sha256'] + + def test_six_actual_children_and_exclusive_supervision(self): + result = promotion.qualification_adapter.qualify(self.request) + self.assertEqual(result['owned_resources'], {'child_count': 6}) + self.assertEqual(result['checks'], {name: 'passed' for name in promotion.REQUIRED_CHECKS}) + with self.assertRaises(FileExistsError): + promotion.qualification_adapter.qualify(self.request) + + def test_package_mutation_and_unlisted_file_rejected(self): + self.script.write_text('# changed') + with self.assertRaisesRegex(ValueError, 'bytes/set mismatch'): + promotion.qualification_adapter.qualify(self.request) + self.freeze() + (self.package / 'pass.json').write_text('{"passed":true}') + with self.assertRaisesRegex(ValueError, 'bytes/set mismatch'): + promotion.qualification_adapter.qualify(self.request) + self.assertFalse((self.assets / 'qualification').exists()) + + def test_old_pass_file_cannot_replace_failed_child(self): + self.script.write_text("import sys;sys.exit(9)\n") + self.freeze() + (self.assets / 'old-pass.json').write_text('{"status":"passed"}') + with self.assertRaisesRegex(ValueError, 'stage failed'): + promotion.qualification_adapter.qualify(self.request) + self.assertFalse((self.assets / 'qualification/supervision.result.json').exists()) + + def test_wrong_identity_and_extra_passed_check_rejected(self): + self.script.write_text(self.script.read_text().replace("print(json.dumps(r))", "r['checks']['invented']='passed';print(json.dumps(r))")) + self.freeze() + with self.assertRaisesRegex(ValueError, 'identity or required check'): + promotion.qualification_adapter.qualify(self.request) + + def test_child_timeout_never_completes_qualification(self): + self.script.write_text('import time;time.sleep(20)\n') + self.manifest['stages'][0]['timeout_seconds'] = 1 + self.freeze() + with self.assertRaises(subprocess.TimeoutExpired): + promotion.qualification_adapter.qualify(self.request) + self.assertFalse((self.assets / 'qualification/supervision.result.json').exists()) + + def test_structured_commands_reject_traversal_and_missing_stage(self): + for change in ('path', 'stage'): + if change == 'path': + self.manifest['stages'][0]['script'] = '../fixture.py' + else: + self.manifest['stages'] = self.manifest['stages'][:-1] + self.freeze() + with self.subTest(change=change), self.assertRaises(ValueError): + promotion.qualification_adapter.qualify(self.request) + + def test_merge_wait_stays_in_process_without_requalification(self): + with mock.patch.object(promotion, 'verify_landed', side_effect=[False, False, True]) as landed, \ + mock.patch.object(promotion.time, 'monotonic', side_effect=[0, 1, 2]), \ + mock.patch.object(promotion.time, 'sleep') as sleep, \ + mock.patch.object(promotion, 'qualification') as qualify: + promotion.wait_for_landed('b'*40, 'd'*40, 60) + self.assertEqual(landed.call_count, 3) + self.assertEqual(sleep.call_count, 2) + qualify.assert_not_called() + + def test_only_ancestor_main_waits_for_exact_reviewed_tree(self): + promotion.select_source('a'*40) + prefix=[{'commit':{'tree':{'sha':'b'*40}}}, {'status':'ahead','files':[]}, + {'commit':{'tree':{'sha':'d'*40}}}, {'sha':'e'*40,'commit':{'tree':{'sha':'f'*40}}}] + with mock.patch.object(promotion,'api',side_effect=prefix+[{'status':'ahead'}]): + self.assertIs(promotion.verify_landed('b'*40,'c'*40,allow_pending=True),False) + with mock.patch.object(promotion,'api',side_effect=prefix+[{'status':'diverged'}]): + with self.assertRaisesRegex(ValueError,'Main tree differs'): + promotion.verify_landed('b'*40,'c'*40,allow_pending=True) + + def test_merge_timeout_and_conflict_do_not_publish(self): + with mock.patch.object(promotion, 'verify_landed', return_value=False), \ + mock.patch.object(promotion.time, 'monotonic', side_effect=[0, 61]), \ + mock.patch.object(promotion, 'gh') as gh: + with self.assertRaises(TimeoutError): + promotion.wait_for_landed('b'*40, 'd'*40, 60) + gh.assert_not_called() + with mock.patch.object(promotion, 'verify_landed', side_effect=ValueError('conflict')), \ + mock.patch.object(promotion.time, 'sleep') as sleep: + with self.assertRaisesRegex(ValueError, 'conflict'): + promotion.wait_for_landed('b'*40, 'd'*40, 60) + sleep.assert_not_called() + + def test_candidate_source_is_explicit_and_strict(self): + for source in ('latest', '', 'a'*39, 'A'*40): + with self.subTest(source=source), self.assertRaises(ValueError): + promotion.select_source(source) + promotion.select_source('f'*40) + self.assertEqual(promotion.TAG, 'build-'+'f'*40) + self.assertIn('f'*40, promotion.BASE) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/qualify-core-release.py b/scripts/qualify-core-release.py index 868d70952..0256198f4 100644 --- a/scripts/qualify-core-release.py +++ b/scripts/qualify-core-release.py @@ -1,29 +1,189 @@ #!/usr/bin/env python3 -"""Reserved entrypoint for this batch's real fresh-install qualification. - -The controller executes these reviewed bytes over authenticated SSH. stdin is one -JSON request with source, tree, run_id, directory, inventory, inventory_sha256, -adapter_sha256 and required_checks. The directory contains the exact downloaded -release assets. The implementation must verify their hashes before and after real -checks and freshly extract them into newly owned installation resources. stdout -must contain one JSON response echoing the identity fields and checks mapping each -required check to passed. Send only redacted diagnostics to stderr. A failed or -skipped required check must exit nonzero. Never load completion from a supplied -pass file. Resources must follow the batch's separately agreed ownership bounds. - -This interface deliberately cannot pass until the actual acceptance commands are -integrated and independently reviewed. No synthetic fallback is provided. -""" +"""Execute a maintainer-pinned private qualification package over authenticated SSH. +The package is independent of candidate assets. Its manifest fixes all helper +bytes, structured Python commands, timeouts and private path/resource configuration. +Only fresh child exit status and identity-bound stdout establish stage completion. +""" +import hashlib import json +import os +from pathlib import Path, PurePosixPath +import re +import signal +import subprocess import sys +import uuid + +CHECKS = ('fresh-install', 'current-lifecycle', 'managed-native', + 'current-generations', 'node-runtime', 'diagnostics-observations') +IDENTITY = ('source', 'tree', 'run_id', 'inventory_sha256', 'adapter_sha256') + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(',', ':')).encode() + + +def file_identity(path): + if path.is_symlink() or not path.is_file(): + raise ValueError('Expected a regular package file') + checksum = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + checksum.update(chunk) + return {'sha256': checksum.hexdigest(), 'size': path.stat().st_size} + + +def relative_path(name): + if (not isinstance(name, str) or not re.fullmatch(r'[A-Za-z0-9_./-]+', name) + or PurePosixPath(name).is_absolute() or any(p in ('', '.', '..') for p in name.split('/'))): + raise ValueError('Invalid package path') + return name + + +def verify_package(root, expected_hash): + if root.is_symlink() or not root.is_dir() or not re.fullmatch('[0-9a-f]{64}', expected_hash): + raise ValueError('Explicit qualification package and manifest SHA256 required') + manifest_path = root / 'manifest.json' + if file_identity(manifest_path)['sha256'] != expected_hash: + raise ValueError('Qualification manifest hash mismatch') + manifest = json.loads(manifest_path.read_bytes()) + if (set(manifest) != {'version', 'files', 'stages', 'configuration'} or manifest['version'] != 1 + or not isinstance(manifest['configuration'], dict) or not isinstance(manifest['files'], dict)): + raise ValueError('Invalid qualification package manifest') + expected = manifest['files'] + for name, identity in expected.items(): + relative_path(name) + if name == 'manifest.json' or not isinstance(identity, dict) or set(identity) != {'sha256', 'size'}: + raise ValueError('Invalid package file identity') + if (not re.fullmatch('[0-9a-f]{64}', identity.get('sha256', '')) + or type(identity.get('size')) is not int or identity['size'] < 0): + raise ValueError('Invalid package file digest or size') + actual = {} + for path in root.rglob('*'): + if path.is_symlink(): + raise ValueError('Package symlinks are forbidden') + if path.is_dir(): + continue + name = path.relative_to(root).as_posix() + if name != 'manifest.json': + actual[name] = file_identity(path) + if actual != expected: + raise ValueError('Qualification package bytes/set mismatch') + stages = manifest['stages'] + if not isinstance(stages, list) or [stage.get('name') for stage in stages] != list(CHECKS): + raise ValueError('Exact ordered qualification stages required') + for stage in stages: + if set(stage) != {'name', 'python', 'script', 'args', 'timeout_seconds'}: + raise ValueError('Invalid stage command fields') + if (not isinstance(stage['python'], str) or not re.fullmatch(r'/[A-Za-z0-9_./-]+', stage['python']) + or '..' in PurePosixPath(stage['python']).parts + or relative_path(stage['script']) not in expected or not stage['script'].endswith('.py') + or not isinstance(stage['args'], list) + or any(not isinstance(arg, str) or '\x00' in arg for arg in stage['args']) + or type(stage['timeout_seconds']) is not int or not 1 <= stage['timeout_seconds'] <= 14400): + raise ValueError('Invalid bounded structured Python command') + return manifest + + +def verify_assets(request): + if (any(not re.fullmatch('[0-9a-f]{40}', request.get(key, '')) for key in ('source', 'tree')) + or str(uuid.UUID(request.get('run_id', ''))) != request.get('run_id') + or request.get('required_checks') != list(CHECKS)): + raise ValueError('Invalid qualification identity') + for key in ('inventory_sha256', 'adapter_sha256'): + if not re.fullmatch('[0-9a-f]{64}', request.get(key, '')): + raise ValueError('Missing qualification identity digest') + if hashlib.sha256(canonical(request['inventory'])).hexdigest() != request['inventory_sha256']: + raise ValueError('Inventory digest mismatch') + root = Path(request['directory']) + if not root.is_absolute() or root.resolve() != root: + raise ValueError('Invalid candidate directory') + for name, expected in request['inventory'].items(): + if relative_path(name) != Path(name).name or file_identity(root / name) != expected: + raise ValueError('Candidate bytes changed') + + +def save_new(path, value): + with path.open('x', encoding='utf8') as stream: + json.dump(value, stream, sort_keys=True) + stream.flush() + os.fsync(stream.fileno()) + + +def execute_stage(stage, package, request, evidence, script_hash): + # Execute the verified script bytes; helper imports are checked as a full set + # before and after each stage. The authorized host remains the trust boundary. + script = package / stage['script'] + code = ("import hashlib,pathlib,sys; p=pathlib.Path(sys.argv[1]); b=p.read_bytes(); " + "hashlib.sha256(b).hexdigest()==sys.argv[2] or sys.exit('Stage bytes changed'); " + "sys.path.insert(0,str(p.parent)); sys.argv=[str(p)]+sys.argv[3:]; " + "exec(compile(b,str(p),'exec'),{'__name__':'__main__','__file__':str(p)})") + argv = [stage['python'], '-B', '-c', code, str(script), script_hash, *stage['args']] + env = dict(os.environ, PYTHONDONTWRITEBYTECODE='1', PYTHONPATH=str(package)) + output = evidence / (stage['name'] + '.stdout.private.json') + with output.open('xb') as stdout, (evidence / (stage['name'] + '.stderr.private.log')).open('xb') as stderr: + with subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=stdout, stderr=stderr, + cwd=package, env=env, start_new_session=True) as process: + try: + process.communicate(canonical(request), timeout=stage['timeout_seconds']) + except BaseException: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + raise + if process.returncode: + raise ValueError('Qualification stage failed; private receipts retained') + if output.stat().st_size > 8 * 1024 * 1024: + raise ValueError('Oversized stage result') + result = json.loads(output.read_bytes()) + if (not isinstance(result, dict) or any(result.get(key) != request[key] for key in IDENTITY) + or result.get('status') != 'passed' or result.get('checks') != {stage['name']: 'passed'} + or not isinstance(result.get('owned_resources'), dict)): + raise ValueError('Stage result identity or required check mismatch') + return result + + +def qualify(request): + os.umask(0o077) + verify_assets(request) + package = Path(request['qualification_package']) + if not package.is_absolute() or package.resolve() != package: + raise ValueError('Invalid qualification package directory') + manifest = verify_package(package, request['qualification_manifest_sha256']) + evidence = Path(request['directory']) / 'qualification' + evidence.mkdir(mode=0o700) # Never read or resume an old passed result. + save_new(evidence / 'supervision.intent.json', {key: request[key] for key in IDENTITY}) + owned, previous, completed = {}, None, {} + for stage in manifest['stages']: + verify_assets(request) + if verify_package(package, request['qualification_manifest_sha256']) != manifest: + raise ValueError('Qualification package changed') + child = dict(request, owned_resources=owned, previous_stage_result=previous, + package_configuration=manifest['configuration']) + result = execute_stage(stage, package, child, evidence, manifest['files'][stage['script']]['sha256']) + verify_package(package, request['qualification_manifest_sha256']) + verify_assets(request) + owned, previous = result['owned_resources'], result + completed.update(result['checks']) + result = {key: request[key] for key in IDENTITY} + result.update(status='passed', checks=completed, owned_resources=owned, + qualification_manifest_sha256=request['qualification_manifest_sha256']) + save_new(evidence / 'supervision.result.json', result) + return result + + +def main(): + if sys.argv[1:] == ['--describe']: + print(json.dumps({'ready': True, 'required_checks': list(CHECKS)})) + return 0 + try: + print(json.dumps(qualify(json.load(sys.stdin)), sort_keys=True)) + return 0 + except Exception: + # Native output, credentials and private paths stay in private receipts. + print('Qualification stopped; retain owned resources and private receipts.', file=sys.stderr) + return 1 -if __name__ == "__main__": - if sys.argv[1:] == ["--describe"]: - print(json.dumps({"ready": False, "required_checks": [ - "fresh-install", "current-lifecycle", "managed-native", - "current-generations", "node-runtime", "diagnostics-observations", - ]})) - sys.exit(0) - sys.exit("Live qualification adapter is not connected; publication is blocked") +if __name__ == '__main__': + sys.exit(main()) From 2064a1779f022e68c0b4d6ef6be6a218b9e93406 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 21:13:33 +0800 Subject: [PATCH 04/78] docs: establish component decoupling principle (#190) --- AGENTS.md | 10 ++++++++++ CONTRIBUTING.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9a4fbdeb1..303be1247 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,16 @@ OpenAgentCore is the independent execution service behind Parsar. Product busine code, its database and migrations stay in the Parsar repository. Read [CONTRIBUTING.md](CONTRIBUTING.md) before changing code. +## Decoupling principle + +Sandbox providers (including user-owned machines), models and harnesses must be +replaceable through shared protocols and interfaces. Keep implementation-specific +behavior inside registered providers and adapters; do not add harness-, runtime- +or vendor-name branches to Core orchestration. Fix shared lifecycle or performance +problems in the common protocol and flow. Validate combinations through declared +capabilities, with explicit errors rather than silent fallback. See +[the canonical rules](CONTRIBUTING.md#decoupling-principle). + ## Interfaces and credentials Core serves three namespaces. Each has one kind of caller and its own credential; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b32f49e9c..80420dc72 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,34 @@ documented below. The source snapshot and per-file hashes provide an audit trail; future Core development need not preserve those hashes. Do not automatically sync or delete the original repository's Core. +## Decoupling principle + +Core orchestrates protocol-defined operations. Sandbox providers, Runtime +implementations, harnesses and model providers are replaceable components. +User-owned machines, E2B, Docker and other environments must expose the same +execution protocol; placement and connection details belong to their providers. + +- Keep component boundaries explicit through shared interfaces and versioned + protocols. Register implementations behind those interfaces. Adding an + implementation must not require a new orchestration path selected by its name. +- Core owns durable Session/Turn state and scheduling. Runtime owns local + execution resources. Harness adapters translate the common execution contract + into native operations; model and sandbox provider details remain behind their + respective interfaces. +- Fix shared lifecycle, admission, cancellation, reuse and performance problems + in the common protocol or flow. Do not patch them with harness-, runtime- or + vendor-specific branches in Core. Adapters may implement native differences, + but must preserve the shared operation and event semantics. +- Express compatibility through declared capabilities and validate selected + combinations explicitly. Replaceability does not guarantee that every model, + harness and environment combination is supported. Do not silently substitute + another implementation or give a capability different meanings per vendor. +- Evolve shared contracts and their implementations together, documenting + ownership and validating the same contract across implementations. This rule + does not claim existing implementations already satisfy every target, change + the pinned public API, or authorize unrelated refactors or live Session + configuration switching. + ## Workflow and quality Develop in an isolated worktree on a feature branch and submit a PR. Do not edit From 180da4a75d3ebff1f2a92c4597522378d4aeb9b4 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 13:22:10 +0000 Subject: [PATCH 05/78] Keep qualification children supervised across SSH disconnects --- CONTRIBUTING.md | 14 +- Makefile | 1 + docs/maintainers.md | 18 +- scripts/promote-qualified-release.py | 43 ++++- scripts/promote-qualified-release.test.py | 53 +++++- scripts/qualification-control.test.py | 106 +++++++++++ scripts/qualification_control.py | 212 ++++++++++++++++++++++ scripts/qualify-core-release.py | 23 +-- 8 files changed, 440 insertions(+), 30 deletions(-) create mode 100644 scripts/qualification-control.test.py create mode 100644 scripts/qualification_control.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c923df7ac..7a5e9f342 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1983,14 +1983,20 @@ diagnostics-observations in that order. Every child must exit successfully and return only its own passed check, the current controller identity and its observed owned resources. Resources and the previous result flow between live children; a supplied pass file, skipped check or old report cannot release the candidate. -Verify package and candidate bytes again after each stage. Control tests exercise +Verify package and candidate bytes again after each stage. The small shared +`qualification_control.py` is pinned to the reviewed tooling commit and private +package. A live SSH stdin channel carries the request then heartbeats; EOF, timeout, +SIGTERM or SIGHUP stops later work and cleans up owned foreground children, +including detached descendants. Private nested workers use the same channel. +Already-issued writes may have unknown outcomes: retain intents/resources and do +not replay or claim rollback. Control tests exercise short-lived fixture children only and never establish live qualification. The caller supplies the independently reviewed promotion-tooling commit. Its changes from the candidate may only affect the exact promotion files enumerated in the controller, including CONTRIBUTING, docs/maintainers and the current-batch node-generation protocol wording correction; the Makefile -exception permits only registration of the controller test. Main must contain the +exception permits only registration of the controller and control-channel tests. Main must contain the candidate source and have the reviewed tooling commit's tree. This permits normal merge commit identity changes and release-only documentation updates without rebuilding or relabeling the original candidate. The candidate's bundled docs and @@ -2005,7 +2011,9 @@ waits in the same process, within the explicit merge-wait budget, for the exact reviewed batch tree to reach main. An ancestor main waits; conflicting main changes fail immediately. Cancellation or expiration retains evidence and cannot turn a saved result into resume authority. It verifies unchanged draft identity, -target, tag and downloaded bytes immediately before publication and checks the +target, tag and downloaded bytes immediately before publication. After the final +download it rechecks main/tree/tag and the same draft ID, then updates that verified +Release ID directly rather than resolving the tag again. It checks the published bytes afterward. Conflicting assets are never overwritten. An interrupted run is reconciled before another invocation; stored qualification output is evidence, not a resumable permission to publish. Preserve its isolated local/remote evidence diff --git a/Makefile b/Makefile index d375cc9f1..d72d18bdd 100644 --- a/Makefile +++ b/Makefile @@ -133,6 +133,7 @@ check-distribution: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/install -p 'test_*.py' PYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/promote-qualified-release.test.py + PYTHONDONTWRITEBYTECODE=1 python3 scripts/qualification-control.test.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/config-reference.py --check 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 diff --git a/docs/maintainers.md b/docs/maintainers.md index a95612bb5..f9f9869c6 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -96,7 +96,8 @@ SSH for the fresh Core/node stages; no new service or credentials are required. GitHub credentials stay on the operator host; nodes continue to download from their console. The adapter protocol is documented in `scripts/qualify-core-release.py`: one JSON -request on stdin, one bound JSON result on stdout, redacted diagnostics on stderr, +request as the first stdin line followed by `ping` heartbeat lines, one bound JSON +result on stdout, redacted diagnostics on stderr, and a nonzero exit for any failed or skipped required check. The six current-batch checks cover fresh installation, current lifecycle, managed native execution, current generations, node Runtime and diagnostics/observations. Their actual @@ -121,7 +122,14 @@ preceding child, initially empty/null. Private wrappers adapt host-specific harn interfaces and must verify their detailed subchecks before returning `status: passed`, exact `checks: {stage-name: passed}`, the five identity fields (source/tree/run_id/inventory_sha256/adapter_sha256), and `owned_resources`. -The run ID is a canonical UUID string. Child stdout/stderr remain private files; +The run ID is a canonical UUID string. `qualification_control.py` is part of the +reviewed tooling bytes and the pinned private package. The sender holds the same +SSH stdin open, sends heartbeats every five seconds and never reloads a pass file. +The receiver stops later work on EOF or a thirty-second heartbeat timeout. Signal +handlers enter the same owned-child cleanup path. Nested SSH workers use this +protocol too; closing a local SSH process alone is insufficient. A write already +sent may still have an unknown result; retain its intent and resources without +replay or a rollback claim. Child stdout/stderr remain private files; nonzero exit, timeout, changed bytes or mismatched identity stops the sequence. After all six stages pass, the same controller waits up to `--merge-wait-seconds` @@ -166,3 +174,9 @@ Core reads only its environment; the lists the variables. The Docker-hosted variant of the standalone archive is retired: it could not describe a complete Runtime release by itself. Docker-hosted deployments use the Core distribution and its installer, whose manifest carries the complete release. + +Immediately after the final asset download, the controller repeats the exact +main/tree, tag, draft and Release ID checks. Publication uses the verified Release +ID through the existing authenticated API, so a replacement tag lookup cannot +select another release. Published bytes and final release/tag identity are checked +again afterward. diff --git a/scripts/promote-qualified-release.py b/scripts/promote-qualified-release.py index c88629df5..647a2134d 100644 --- a/scripts/promote-qualified-release.py +++ b/scripts/promote-qualified-release.py @@ -49,6 +49,7 @@ def select_source(source): "Makefile", "contracts/agents-api/node-generation-protocol.md", "scripts/promote-qualified-release.py", "scripts/promote-qualified-release.test.py", "scripts/qualify-core-release.py", + "scripts/qualification_control.py", "scripts/qualification-control.test.py", } @@ -183,7 +184,8 @@ def verify_landed(tree, promotion_commit, allow_pending=False): original = base64.b64decode(api("contents/Makefile?ref=" + SOURCE)["content"]).decode() updated = base64.b64decode(api("contents/Makefile?ref=" + promotion_commit)["content"]).decode() anchor = "\tPYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py\n" - addition = "\tPYTHONDONTWRITEBYTECODE=1 python3 scripts/promote-qualified-release.test.py\n" + addition = ("\tPYTHONDONTWRITEBYTECODE=1 python3 scripts/promote-qualified-release.test.py\n" + "\tPYTHONDONTWRITEBYTECODE=1 python3 scripts/qualification-control.test.py\n") if original.count(anchor) != 1 or updated != original.replace(anchor, anchor + addition): raise ValueError("Makefile change exceeds promotion test registration") reviewed_tree = api("commits/" + promotion_commit)["commit"]["tree"]["sha"] @@ -279,9 +281,11 @@ def verify_remote(request, host): def qualification(request, host, remote, adapter, downloaded, package, manifest_hash): adapter_bytes = adapter.read_bytes() request["adapter_sha256"] = hashlib.sha256(adapter_bytes).hexdigest() + control_hash = file_identity(adapter.with_name("qualification_control.py"))["sha256"] command = shlex.join(["mkdir", "-m", "700", remote]) run(["ssh", "-o", "BatchMode=yes", host, command]) - run(["scp", "-q", "--", str(adapter), *[str(p) for p in sorted(downloaded.iterdir())], + run(["scp", "-q", "--", str(adapter), str(adapter.with_name("qualification_control.py")), + *[str(p) for p in sorted(downloaded.iterdir())], host + ":" + remote + "/"]) qualification_adapter.verify_package(package, manifest_hash) run(["scp", "-q", "-r", "--", str(package), host + ":" + remote + "/tools"]) @@ -289,11 +293,16 @@ def qualification(request, host, remote, adapter, downloaded, package, manifest_ request["qualification_manifest_sha256"] = manifest_hash verify_remote(request, host) # Hash and execute the same bytes, avoiding a check-then-open script race. - bootstrap = ("import hashlib,pathlib,sys; p=pathlib.Path(sys.argv[1]); b=p.read_bytes(); " + bootstrap = ("import hashlib,pathlib,sys,types; p=pathlib.Path(sys.argv[1]); b=p.read_bytes(); " "hashlib.sha256(b).hexdigest()==sys.argv[2] or sys.exit('Adapter bytes changed'); " + "c=p.with_name('qualification_control.py'); raw=c.read_bytes(); " + "hashlib.sha256(raw).hexdigest()==sys.argv[3] or sys.exit('Control bytes changed'); " + "m=types.ModuleType('qualification_control'); m.__file__=str(c); " + "exec(compile(raw,str(c),'exec'),m.__dict__); sys.modules[m.__name__]=m; " "sys.argv=[str(p)]; exec(compile(b,str(p),'exec'),{'__name__':'__main__','__file__':str(p)})") - argv = ["python3", "-c", bootstrap, remote + "/" + adapter.name, request["adapter_sha256"]] - response = run(["ssh", "-o", "BatchMode=yes", host, shlex.join(argv)], input=canonical(request)) + argv = ["python3", "-c", bootstrap, remote + "/" + adapter.name, request["adapter_sha256"], control_hash] + response = qualification_adapter.control.transport( + ["ssh", "-o", "BatchMode=yes", host, shlex.join(argv)], request) verify_remote(request, host) result = json.loads(response) required = {"source": SOURCE, "tree": request["tree"], "run_id": request["run_id"], @@ -307,12 +316,21 @@ def qualification(request, host, remote, adapter, downloaded, package, manifest_ def verify_tooling(promotion_commit): - for name in ("promote-qualified-release.py", "qualify-core-release.py", "core-distribution-manifest.py"): + for name in ("promote-qualified-release.py", "qualify-core-release.py", "qualification_control.py", "core-distribution-manifest.py"): expected = base64.b64decode(api("contents/scripts/" + name + "?ref=" + promotion_commit)["content"]) if pathlib.Path(__file__).with_name(name).read_bytes() != expected: raise ValueError("Local tooling differs from reviewed commit: " + name) +def publish_release(release_id, body_file): + if type(release_id) is not int or release_id <= 0: + raise ValueError("Invalid verified Release ID") + updated = json.loads(run(["gh", "api", "--method", "PATCH", "repos/" + REPO + "/releases/" + str(release_id), + "--input", str(body_file)])) + if updated.get("id") != release_id or updated.get("draft") is not False or updated.get("prerelease") is not False: + raise ValueError("Publication response identity mismatch") + + def promote(assets, state, host, remote_root, promotion_commit, *, source, package, manifest_hash, merge_wait_seconds=86400): select_source(source) @@ -383,8 +401,17 @@ def promote(assets, state, host, remote_root, promotion_commit, *, source, packa + "Qualified checks: " + ", ".join(REQUIRED_CHECKS) + ".\n" + "Asset inventory SHA256: " + request["inventory_sha256"] + ".\n" + "Promotion tooling commit: " + promotion_commit + ".\n") - gh("release", "edit", TAG, "--draft=false", "--prerelease=false", "--latest", - "--notes-file", str(notes)) + # Downloading can take minutes. Revalidate immediately before mutation. + verify_landed(metadata["source_tree"], promotion_commit) + verify_tag() + final_draft = release_state() + if (not final_draft or final_draft["id"] != initial_release["id"] or not final_draft["draft"] + or final_draft["target_commitish"] != SOURCE): + raise ValueError("Draft identity changed during final verification") + publication = state / "publication.json" + publication.write_text(canonical({"draft": False, "prerelease": False, "make_latest": "true", + "body": notes.read_text()}) + "\n") + publish_release(final_draft["id"], publication) download(state / "published", inventory) final = release_state() if not final or final["id"] != current["id"] or final["draft"] or final["prerelease"]: diff --git a/scripts/promote-qualified-release.test.py b/scripts/promote-qualified-release.test.py index 8f7eabc03..5f220d960 100644 --- a/scripts/promote-qualified-release.test.py +++ b/scripts/promote-qualified-release.test.py @@ -14,6 +14,7 @@ spec = importlib.util.spec_from_file_location( "promotion", pathlib.Path(__file__).with_name("promote-qualified-release.py")) +PathControl = pathlib.Path(__file__).with_name('qualification_control.py') promotion = importlib.util.module_from_spec(spec) spec.loader.exec_module(promotion) @@ -162,10 +163,11 @@ def test_unreviewed_package_has_no_remote_side_effects(self): def qualify(self, checks=None, failure=False, wrong_identity=False): adapter = self.root / "adapter.py" adapter.write_text("# Transport test fixture, never live acceptance\n") + adapter.with_name('qualification_control.py').write_bytes(PathControl.read_bytes()) request = {"source": promotion.SOURCE, "tree": self.tree, "run_id": "test-run", "inventory_sha256": "c" * 64, "directory": "/tmp/test", "inventory": {}} - def transport(argv, **kwargs): + def transport(argv, request_input=None, **kwargs): if "exec(compile" in argv[-1]: if failure: raise subprocess.CalledProcessError(1, argv) @@ -179,7 +181,8 @@ def transport(argv, **kwargs): return json.dumps(result) return "" - with mock.patch.object(promotion, "run", side_effect=transport): + with mock.patch.object(promotion, "run", side_effect=transport), \ + mock.patch.object(promotion.qualification_adapter.control, "transport", side_effect=transport): return promotion.qualification(request, "mx2", "/tmp/test", adapter, self.assets, self.package, self.manifest_hash) def test_nonzero_ssh_missing_checks_and_replayed_result_rejected(self): @@ -227,16 +230,54 @@ def download(path, inventory): mock.patch.object(promotion, "verify_tooling"), \ mock.patch.object(promotion, "api", return_value={"commit": {"tree": {"sha": self.tree}}}), \ mock.patch.object(promotion, "verify_tag"), \ - mock.patch.object(promotion, "release_state", side_effect=[draft, draft, draft, final]), \ + mock.patch.object(promotion, "release_state", side_effect=[draft, draft, draft, draft, final]), \ mock.patch.object(promotion, "download", side_effect=download), \ mock.patch.object(promotion, "verify_files"), \ mock.patch.object(promotion, "qualification", side_effect=lambda *args: events.append("qualified") or {}), \ mock.patch.object(promotion, "wait_for_landed", side_effect=lambda *args: events.append("landed")), \ - mock.patch.object(promotion, "gh", side_effect=lambda *args: events.append(args[:3])), \ + mock.patch.object(promotion, "verify_landed", side_effect=lambda *args: events.append("rechecked")), \ + mock.patch.object(promotion, "publish_release", side_effect=lambda release_id, body: events.append(("publish", release_id))), \ mock.patch("builtins.print"): promotion.promote(self.assets, self.root / "success", "mx2", "/tmp/acceptance", "d" * 40, **self.kwargs) self.assertEqual(events, ["downloaded", "qualified", "landed", "before-publication", - ("release", "edit", promotion.TAG), "published"]) + "rechecked", ("publish", 1), "published"]) + + def test_final_identity_changes_never_publish(self): + draft = {"id": 1, "draft": True, "target_commitish": promotion.SOURCE} + ready = json.dumps({"ready": True, "required_checks": list(promotion.REQUIRED_CHECKS)}) + for change in ("main", "tag", "replacement", "published"): + with self.subTest(change=change): + final = dict(draft) + if change == "replacement": + final["id"] = 2 + if change == "published": + final["draft"] = False + with mock.patch.object(promotion, "run", return_value=ready), \ + mock.patch.object(promotion, "verify_tooling"), \ + mock.patch.object(promotion, "api", return_value={"commit": {"tree": {"sha": self.tree}}}), \ + mock.patch.object(promotion, "verify_tag", side_effect=[None, None, ValueError("changed") if change == "tag" else None]), \ + mock.patch.object(promotion, "release_state", side_effect=[draft, draft, draft, final]), \ + mock.patch.object(promotion, "download") as download, \ + mock.patch.object(promotion, "verify_files"), \ + mock.patch.object(promotion, "qualification", return_value={}), \ + mock.patch.object(promotion, "wait_for_landed"), \ + mock.patch.object(promotion, "verify_landed", side_effect=ValueError("changed") if change == "main" else None), \ + mock.patch.object(promotion, "publish_release") as publish: + with self.assertRaises(ValueError): + promotion.promote(self.assets, self.root / change, "mx2", "/tmp/acceptance", "d" * 40, **self.kwargs) + self.assertEqual(download.call_args_list[-1].args[0].name, "before-publication") + publish.assert_not_called() + + def test_publication_targets_verified_release_id(self): + body = self.root / "publication.json" + body.write_text('{"draft":false}') + with mock.patch.object(promotion, "run", return_value='{"id":37,"draft":false,"prerelease":false}') as run: + promotion.publish_release(37, body) + self.assertEqual(run.call_args.args[0], ["gh", "api", "--method", "PATCH", + "repos/" + promotion.REPO + "/releases/37", "--input", str(body)]) + with mock.patch.object(promotion, "run", return_value='{"id":38,"draft":false,"prerelease":false}'): + with self.assertRaisesRegex(ValueError, "identity mismatch"): + promotion.publish_release(37, body) def test_download_hashes_stream_without_storing_an_archive(self): payload = b"downloaded bytes" @@ -331,7 +372,7 @@ def test_old_pass_file_cannot_replace_failed_child(self): self.script.write_text("import sys;sys.exit(9)\n") self.freeze() (self.assets / 'old-pass.json').write_text('{"status":"passed"}') - with self.assertRaisesRegex(ValueError, 'stage failed'): + with self.assertRaisesRegex(ValueError, 'child failed'): promotion.qualification_adapter.qualify(self.request) self.assertFalse((self.assets / 'qualification/supervision.result.json').exists()) diff --git a/scripts/qualification-control.test.py b/scripts/qualification-control.test.py new file mode 100644 index 000000000..f159737e0 --- /dev/null +++ b/scripts/qualification-control.test.py @@ -0,0 +1,106 @@ +"""Real child/process control fixtures; no deployment, provider or model calls.""" +import hashlib +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time +import unittest +from concurrent.futures import ThreadPoolExecutor +import qualification_control as control + + +def alive(pid): + try: + return (Path('/proc')/str(pid)/'stat').read_text().rsplit(')',1)[1].split()[0] != 'Z' + except FileNotFoundError: + return False + + +class ControlTests(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup) + self.root=Path(self.temp.name) + self.source=Path(control.__file__).read_bytes() + self.digest=hashlib.sha256(self.source).hexdigest() + + def receiver(self,worker): + return control.inline_receiver(self.source,self.digest,[sys.executable,'-B','-c',worker]) + + def await_file(self,path): + deadline=time.monotonic()+5 + while not path.exists() and time.monotonic() limit: + raise ValueError('Oversized control frame') + return line + if len(self.buffer) > limit or time.monotonic() >= deadline: + raise ValueError('Control channel expired') + if select.select([self.fd], [], [], min(0.2, max(0, deadline-time.monotonic())))[0]: + chunk = os.read(self.fd, 65536) + if not chunk: + raise ValueError('Control channel closed') + self.buffer += chunk + return None + + def watch(self): + try: + while not self.stopped.is_set(): + frame = self.line(self.timeout, 16) + if frame is not None and frame != b'ping': + raise ValueError('Invalid control heartbeat') + except Exception: + if not self.stopped.is_set(): + os.kill(os.getpid(), signal.SIGTERM) + + def start(self): + self.thread = threading.Thread(target=self.watch, daemon=True) + self.thread.start() + + def close(self): + self.stopped.set() + self.thread.join(timeout=1) + + +def serve(callback, stream, timeout=30): + """Read one JSON line, then require heartbeats until the callback finishes.""" + with cancellable(): + life = Lifeline(stream, timeout) + request = json.loads(life.line(60, 4*1024*1024)) + life.start() + try: + return callback(request) + finally: + life.close() + + +def transport(argv, request, timeout=86400): + """Keep the same SSH stdin open; caller death/connection loss is remote EOF.""" + payload = json.dumps(request, sort_keys=True, separators=(',', ':')).encode()+b'\n' + with cancellable(), tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors: + with subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=output, stderr=errors, + start_new_session=True) as process: + try: + process.stdin.write(payload) + process.stdin.flush() + deadline = time.monotonic()+timeout + while True: + try: + process.wait(timeout=min(5, max(0, deadline-time.monotonic()))) + break + except subprocess.TimeoutExpired: + if time.monotonic() >= deadline: + raise TimeoutError('Qualification transport expired') + process.stdin.write(b'ping\n') + process.stdin.flush() + if process.returncode: + raise ValueError('Qualification transport failed; retain remote intents') + except BaseException: + try: + process.stdin.close() # Remote EOF stops any later stage. + except OSError: + pass + stop_child(process) + raise + finally: + try: + process.stdin.close() + except OSError: + pass + output.seek(0) + result = output.read(8*1024*1024+1) + if len(result) > 8*1024*1024: + raise ValueError('Oversized qualification result') + return result.decode() + + +def worker(argv, request): + """Wrap a nested worker in the same finite channel and owned-child cleanup.""" + with tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors: + run_child(argv, json.dumps(request).encode(), output, errors, 14400) + output.seek(0) + raw = output.read(8*1024*1024+1) + if len(raw) > 8*1024*1024: + raise ValueError('Oversized remote worker result') + return json.loads(raw) + + +def inline_receiver(source, expected_hash, argv): + """Verified control bytes travel with the existing SSH command, without keys.""" + import base64 + import hashlib + if hashlib.sha256(source).hexdigest() != expected_hash: + raise ValueError('Control helper bytes changed') + code = ("import base64,hashlib,os,pathlib,sys; b=base64.b64decode(sys.argv[1]); " + "assert hashlib.sha256(b).hexdigest()==sys.argv[2]; os.chdir(pathlib.Path.home()); " + "sys.argv=['qualification_control.py','--receive']+sys.argv[3:]; " + "exec(compile(b,'qualification_control.py','exec'),{'__name__':'__main__','__file__':'qualification_control.py'})") + return ['python3', '-B', '-c', code, base64.b64encode(source).decode(), expected_hash, *argv] + + +if __name__ == '__main__': + import sys + if sys.argv[1:2] != ['--receive'] or len(sys.argv) < 3: + raise SystemExit('A supervised worker command is required') + try: + print(json.dumps(serve(lambda request: worker(sys.argv[2:], request), sys.stdin))) + except Exception: + raise SystemExit('Remote control ended; retain unknown intents and owned resources. No replay.') from None diff --git a/scripts/qualify-core-release.py b/scripts/qualify-core-release.py index 0256198f4..5c98e223c 100644 --- a/scripts/qualify-core-release.py +++ b/scripts/qualify-core-release.py @@ -6,6 +6,7 @@ Only fresh child exit status and identity-bound stdout establish stage completion. """ import hashlib +import importlib.util import json import os from pathlib import Path, PurePosixPath @@ -15,6 +16,14 @@ import sys import uuid +if 'qualification_control' in sys.modules: + control = sys.modules['qualification_control'] +else: + _control_spec = importlib.util.spec_from_file_location( + 'qualification_control', Path(__file__).with_name('qualification_control.py')) + control = importlib.util.module_from_spec(_control_spec) + _control_spec.loader.exec_module(control) + CHECKS = ('fresh-install', 'current-lifecycle', 'managed-native', 'current-generations', 'node-runtime', 'diagnostics-observations') IDENTITY = ('source', 'tree', 'run_id', 'inventory_sha256', 'adapter_sha256') @@ -123,16 +132,8 @@ def execute_stage(stage, package, request, evidence, script_hash): env = dict(os.environ, PYTHONDONTWRITEBYTECODE='1', PYTHONPATH=str(package)) output = evidence / (stage['name'] + '.stdout.private.json') with output.open('xb') as stdout, (evidence / (stage['name'] + '.stderr.private.log')).open('xb') as stderr: - with subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=stdout, stderr=stderr, - cwd=package, env=env, start_new_session=True) as process: - try: - process.communicate(canonical(request), timeout=stage['timeout_seconds']) - except BaseException: - os.killpg(process.pid, signal.SIGKILL) - process.wait() - raise - if process.returncode: - raise ValueError('Qualification stage failed; private receipts retained') + control.run_child(argv, canonical(request), stdout, stderr, stage['timeout_seconds'], + cwd=package, env=env) if output.stat().st_size > 8 * 1024 * 1024: raise ValueError('Oversized stage result') result = json.loads(output.read_bytes()) @@ -177,7 +178,7 @@ def main(): print(json.dumps({'ready': True, 'required_checks': list(CHECKS)})) return 0 try: - print(json.dumps(qualify(json.load(sys.stdin)), sort_keys=True)) + print(json.dumps(control.serve(qualify, sys.stdin), sort_keys=True)) return 0 except Exception: # Native output, credentials and private paths stay in private receipts. From 049e21f0b8756134c41ab8eeadc5475aed709ea2 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 13:37:39 +0000 Subject: [PATCH 06/78] Own foreground groups through every qualification exit --- CONTRIBUTING.md | 9 +++- docs/maintainers.md | 8 +++- scripts/qualification-control.test.py | 59 ++++++++++++++++++++++++++- scripts/qualification_control.py | 50 ++++++++++++++--------- 4 files changed, 101 insertions(+), 25 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a5e9f342..c2832d2bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1986,8 +1986,13 @@ a supplied pass file, skipped check or old report cannot release the candidate. Verify package and candidate bytes again after each stage. The small shared `qualification_control.py` is pinned to the reviewed tooling commit and private package. A live SSH stdin channel carries the request then heartbeats; EOF, timeout, -SIGTERM or SIGHUP stops later work and cleans up owned foreground children, -including detached descendants. Private nested workers use the same channel. +SIGTERM or SIGHUP stops later work. Each local stage or remote worker has one +foreground process group and a waiting owner outside that group. The owner cleans +the group on success, nonzero exit, timeout and cancellation, including foreground +descendants orphaned by an inner timeout or SIGKILL. Nested foreground commands +inherit the group; only explicitly recorded background resources may detach. +Those retained background resources are outside foreground cleanup. Private nested +workers use the same channel. Already-issued writes may have unknown outcomes: retain intents/resources and do not replay or claim rollback. Control tests exercise short-lived fixture children only and never establish live qualification. diff --git a/docs/maintainers.md b/docs/maintainers.md index f9f9869c6..ec142b1aa 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -126,8 +126,12 @@ The run ID is a canonical UUID string. `qualification_control.py` is part of the reviewed tooling bytes and the pinned private package. The sender holds the same SSH stdin open, sends heartbeats every five seconds and never reloads a pass file. The receiver stops later work on EOF or a thirty-second heartbeat timeout. Signal -handlers enter the same owned-child cleanup path. Nested SSH workers use this -protocol too; closing a local SSH process alone is insufficient. A write already +handlers enter the same cleanup path. Each stage or remote worker has a foreground +process group with a waiting owner outside it. The owner cleans the group on every +exit, including success and nonzero exit, so an inner timeout or SIGKILL cannot +leave same-group foreground descendants running. Foreground commands inherit the +group; only recorded background resources may create separate sessions and remain +running. Nested SSH workers use this protocol too; closing a local SSH process alone is insufficient. A write already sent may still have an unknown result; retain its intent and resources without replay or a rollback claim. Child stdout/stderr remain private files; nonzero exit, timeout, changed bytes or mismatched identity stops the sequence. diff --git a/scripts/qualification-control.test.py b/scripts/qualification-control.test.py index f159737e0..13ad543a9 100644 --- a/scripts/qualification-control.test.py +++ b/scripts/qualification-control.test.py @@ -9,6 +9,7 @@ import tempfile import time import unittest +from unittest import mock from concurrent.futures import ThreadPoolExecutor import qualification_control as control @@ -39,7 +40,7 @@ def await_file(self,path): def sleeping_worker(self): ready=self.root/'ready.json';late=self.root/'late' grandchild="import time;from pathlib import Path;time.sleep(2);Path("+repr(str(late))+").write_text('escaped');time.sleep(60)" - code="import json,os,subprocess,sys,time;from pathlib import Path; child=subprocess.Popen([sys.executable,'-c',"+repr(grandchild)+"],start_new_session=True);Path("+repr(str(ready))+").write_text(json.dumps({'worker':os.getpid(),'grandchild':child.pid}));time.sleep(60)" + code="import json,os,subprocess,sys,time;from pathlib import Path; child=subprocess.Popen([sys.executable,'-c',"+repr(grandchild)+"]);Path("+repr(str(ready))+").write_text(json.dumps({'worker':os.getpid(),'grandchild':child.pid}));time.sleep(60)" return code,ready,late def assert_stopped(self,pids): @@ -52,7 +53,7 @@ def test_normal_completion_with_heartbeat_and_nested_worker(self): result=control.transport(self.receiver(worker),{'value':'fixture'},timeout=10) self.assertEqual(json.loads(result),{'observed':'fixture'}) - def test_eof_and_real_signals_stop_detached_descendants(self): + def test_eof_and_real_signals_stop_foreground_descendants(self): for reason in ('eof',signal.SIGTERM,signal.SIGHUP): with self.subTest(reason=reason): code,ready,late=self.sleeping_worker() @@ -99,6 +100,60 @@ def test_parallel_worker_transports_do_not_install_thread_signal_handlers(self): results=list(pool.map(lambda value:json.loads(control.transport(receiver,{'value':value},timeout=5)),(1,2))) self.assertEqual(results,[{'value':1},{'value':2}]) + def test_three_level_exit_paths_reap_foreground_and_preserve_background(self): + for reason in ('timeout', 'kill', 'nonzero', 'normal'): + with self.subTest(reason=reason): + ready = self.root / (reason + '-foreground.json') + background = self.root / (reason + '-background.json') + foreground = ("import os,json,time;from pathlib import Path;Path(" + repr(str(ready)) + + ").write_text(json.dumps({'foreground':os.getpid()}));time.sleep(60)") + middle = ("import subprocess,sys,time; p=subprocess.Popen([sys.executable,'-c'," + + repr(foreground) + "]);time.sleep(60)") + # This worker stands in for node_worker -> lifecycle -> installer. + # The outside control owner survives the inner timeout/SIGKILL. + worker = ("import subprocess,sys,time,json;from pathlib import Path;" + "bg=subprocess.Popen([sys.executable,'-c','import time;time.sleep(60)'],start_new_session=True);" + "Path(" + repr(str(background)) + ").write_text(json.dumps({'background':bg.pid}));") + if reason == 'timeout': + worker += "subprocess.run([sys.executable,'-c'," + repr(middle) + "],timeout=.3)" + else: + worker += ("p=subprocess.Popen([sys.executable,'-c'," + repr(middle) + "]);" + "time.sleep(.3);") + if reason == 'kill': + worker += "p.kill();p.wait();sys.exit(1)" + elif reason == 'nonzero': + worker += "sys.exit(7)" + else: + worker += "print(json.dumps({'completed':True}))" + bg = None + try: + with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err: + if reason == 'normal': + control.run_child([sys.executable,'-c',worker], b'', out, err, 3) + else: + with self.assertRaises(ValueError): + control.run_child([sys.executable,'-c',worker], b'', out, err, 3) + fg = self.await_file(ready) + bg = self.await_file(background)['background'] + self.assert_stopped(fg) + self.assertTrue(alive(bg), 'Explicit background resource was removed') + finally: + if bg is None and background.exists(): + bg = json.loads(background.read_text())['background'] + if bg is not None: + try: os.kill(bg, signal.SIGKILL) + except ProcessLookupError: pass + + def test_sender_child_cleanup_without_linux_proc(self): + process = subprocess.Popen([sys.executable, '-c', 'import time;time.sleep(60)']) + try: + with mock.patch.object(control, 'descendants', return_value={}): + control.stop_child(process, own_group=False) + self.assertIsNotNone(process.returncode) + finally: + if process.poll() is None: + process.kill(); process.wait() + def test_control_bytes_are_pinned(self): with self.assertRaises(ValueError):control.inline_receiver(self.source,'0'*64,['python3']) diff --git a/scripts/qualification_control.py b/scripts/qualification_control.py index e59d4f401..f87c5caec 100644 --- a/scripts/qualification_control.py +++ b/scripts/qualification_control.py @@ -48,21 +48,31 @@ def descendants(pid): owned.update(children) -def stop_child(process): - owned = descendants(process.pid) - def send(sig): - # A process group also catches children created after the snapshot. - try: - os.killpg(process.pid, sig) - except ProcessLookupError: - pass - for pid, birth in owned.items(): +def stop_child(process, *, own_group=True): + """Reap foreground work; explicit detached background resources are retained.""" + if own_group: + def send(sig): try: - actual = (Path('/proc') / str(pid) / 'stat').read_text().rsplit(')', 1)[1].split()[19] - if actual == birth: - os.kill(pid, sig) - except (OSError, IndexError): + os.killpg(process.pid, sig) + except ProcessLookupError: pass + else: + # Nested commands inherit their enclosing foreground group. Never kill + # that whole group here: its outside owner performs final group cleanup. + owned = descendants(process.pid) + group = os.getpgrp() + def send(sig): + # The top-level sender can run on macOS without /proc. Its direct + # SSH child is still owned and must always receive cancellation. + if process.poll() is None: + process.send_signal(sig) + for pid, birth in owned.items(): + try: + actual = (Path('/proc') / str(pid) / 'stat').read_text().rsplit(')', 1)[1].split()[19] + if actual == birth and os.getpgid(pid) == group: + os.kill(pid, sig) + except (OSError, IndexError): + pass send(signal.SIGTERM) try: process.wait(timeout=10) @@ -73,15 +83,17 @@ def send(sig): def run_child(argv, payload, stdout, stderr, timeout, **kwargs): + """Outside owner for one foreground group, cleaned on every exit path.""" with subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=stdout, stderr=stderr, start_new_session=True, **kwargs) as process: try: process.communicate(payload, timeout=timeout) - except BaseException: + if process.returncode: + raise ValueError('Qualification child failed; retain private receipts') + finally: + # The direct child may already be gone. Its foreground descendants + # remain addressable by group even after timeout or SIGKILL orphaning. stop_child(process) - raise - if process.returncode: - raise ValueError('Qualification child failed; retain private receipts') class Lifeline: @@ -143,7 +155,7 @@ def transport(argv, request, timeout=86400): payload = json.dumps(request, sort_keys=True, separators=(',', ':')).encode()+b'\n' with cancellable(), tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors: with subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=output, stderr=errors, - start_new_session=True) as process: + start_new_session=False) as process: try: process.stdin.write(payload) process.stdin.flush() @@ -164,7 +176,7 @@ def transport(argv, request, timeout=86400): process.stdin.close() # Remote EOF stops any later stage. except OSError: pass - stop_child(process) + stop_child(process, own_group=False) raise finally: try: From f9871472eb49e6f8f2d7fa61d4345354588751c7 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 13:46:18 +0000 Subject: [PATCH 07/78] Limit release qualification to requested real smoke coverage --- CONTRIBUTING.md | 7 +++++-- docs/maintainers.md | 15 +++++++++------ scripts/promote-qualified-release.py | 8 ++++---- scripts/promote-qualified-release.test.py | 4 ++-- scripts/qualify-core-release.py | 4 ++-- 5 files changed, 22 insertions(+), 16 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index afce6ca03..d3a8a84c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2006,8 +2006,11 @@ never put credential values in either manifest. The reviewed adapter receives a fresh canonical UUID and exact inventory over the authenticated command channel. It directly supervises fresh-install, -current-lifecycle, managed-native, current-generations, node-runtime and -diagnostics-observations in that order. Every child must exit successfully and +current-lifecycle, managed-native-smoke, diagnostics-observations-smoke and +node-runtime-smoke in that order. This batch uses one fresh container installation, +one completed managed Session, read-only diagnostics for that Session, and one +current Runtime Session on one new node. It does not rerun the full multi-host, +generation or GC matrix. Every child must exit successfully and return only its own passed check, the current controller identity and its observed owned resources. Resources and the previous result flow between live children; a supplied pass file, skipped check or old report cannot release the candidate. diff --git a/docs/maintainers.md b/docs/maintainers.md index ec142b1aa..594da8543 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -98,9 +98,11 @@ GitHub credentials stay on the operator host; nodes continue to download from th The adapter protocol is documented in `scripts/qualify-core-release.py`: one JSON request as the first stdin line followed by `ping` heartbeat lines, one bound JSON result on stdout, redacted diagnostics on stderr, -and a nonzero exit for any failed or skipped required check. The six current-batch -checks cover fresh installation, current lifecycle, managed native execution, -current generations, node Runtime and diagnostics/observations. Their actual +and a nonzero exit for any failed or skipped required check. The five current-batch +checks cover one fresh container installation, its current lifecycle, one managed +native Session, read-only diagnostics/observations for that Session, and one +current Runtime Session on one new node. The full multi-host, generation and GC +matrix is not rerun for this promotion. Their actual commands must operate on freshly extracted supplied assets and respect the agreed resource ownership. The controller verifies remote asset hashes before and after execution; it has no pass-file option. Execution commands come only from the @@ -109,7 +111,7 @@ maintainer-pinned package, never from candidate metadata or a stage result. The package root contains `manifest.json` and exactly its enumerated regular files; symlinks, extra files and changed bytes are rejected. Manifest version 1 has `files` (relative path to SHA256/size), `configuration` (reviewed resource bounds and private -file paths, no credential values), and six ordered `stages`. Each stage has `name`, +file paths, no credential values), and five ordered `stages`. Each stage has `name`, absolute `python` interpreter path, package-relative `.py` `script`, structured string `args`, and `timeout_seconds` (1–14400). No shell command or candidate-driven substitution is used. The explicit SHA256 covers the exact manifest bytes. Preserve @@ -136,7 +138,7 @@ sent may still have an unknown result; retain its intent and resources without replay or a rollback claim. Child stdout/stderr remain private files; nonzero exit, timeout, changed bytes or mismatched identity stops the sequence. -After all six stages pass, the same controller waits up to `--merge-wait-seconds` +After all five stages pass, the same controller waits up to `--merge-wait-seconds` for main to reach the exact reviewed promotion tree. While main is an ancestor of that reviewed commit it continues waiting; divergent changes stop publication. Do not restart merely because the merge is pending. Cancellation or timeout retains @@ -147,7 +149,8 @@ This finite command publishes automatically when all checks pass and the batch i landed. Candidate source and tag remain `48ed8158e134207d15cdd14ae0a30e10f070eb5c` / `build-48ed8158e134207d15cdd14ae0a30e10f070eb5c`. The reviewed tooling commit can add only the enumerated release files, the -node-generation protocol wording correction and test registration; main must have that commit's tree and include the candidate source. +node-generation protocol wording correction, shared component documentation in +AGENTS.md/CONTRIBUTING.md and test registration; main must have that commit's tree and include the candidate source. Later release documentation is not retroactively inserted into the tested bundle. Any product change blocks publication of this candidate instead of silently publishing an obsolete product or relabeling old bytes. diff --git a/scripts/promote-qualified-release.py b/scripts/promote-qualified-release.py index 647a2134d..27b83824f 100644 --- a/scripts/promote-qualified-release.py +++ b/scripts/promote-qualified-release.py @@ -41,11 +41,10 @@ def select_source(source): qualification_adapter = importlib.util.module_from_spec(adapter_spec) adapter_spec.loader.exec_module(qualification_adapter) -REQUIRED_CHECKS = ("fresh-install", "current-lifecycle", "managed-native", - "current-generations", "node-runtime", "diagnostics-observations") +REQUIRED_CHECKS = qualification_adapter.CHECKS # Only this separate promotion change may follow the qualified source on main. PROMOTION_FILES = { - ".github/workflows/release.yml", "CONTRIBUTING.md", "docs/maintainers.md", + ".github/workflows/release.yml", "AGENTS.md", "CONTRIBUTING.md", "docs/maintainers.md", "Makefile", "contracts/agents-api/node-generation-protocol.md", "scripts/promote-qualified-release.py", "scripts/promote-qualified-release.test.py", "scripts/qualify-core-release.py", @@ -398,7 +397,8 @@ def promote(assets, state, host, remote_root, promotion_commit, *, source, packa download(state / "before-publication", inventory) notes = state / "release-notes.md" notes.write_text("Fresh-install offline distribution from " + SOURCE + ".\n\n" - + "Qualified checks: " + ", ".join(REQUIRED_CHECKS) + ".\n" + + "Real smoke checks: " + ", ".join(REQUIRED_CHECKS) + ".\n" + + "The full multi-host, generation and GC matrix was not rerun for this promotion.\n" + "Asset inventory SHA256: " + request["inventory_sha256"] + ".\n" + "Promotion tooling commit: " + promotion_commit + ".\n") # Downloading can take minutes. Revalidate immediately before mutation. diff --git a/scripts/promote-qualified-release.test.py b/scripts/promote-qualified-release.test.py index 5f220d960..1b59820fe 100644 --- a/scripts/promote-qualified-release.test.py +++ b/scripts/promote-qualified-release.test.py @@ -351,9 +351,9 @@ def freeze(self): path.write_text(promotion.canonical(self.manifest)) self.request['qualification_manifest_sha256'] = promotion.file_identity(path)['sha256'] - def test_six_actual_children_and_exclusive_supervision(self): + def test_required_actual_children_and_exclusive_supervision(self): result = promotion.qualification_adapter.qualify(self.request) - self.assertEqual(result['owned_resources'], {'child_count': 6}) + self.assertEqual(result['owned_resources'], {'child_count': len(promotion.REQUIRED_CHECKS)}) self.assertEqual(result['checks'], {name: 'passed' for name in promotion.REQUIRED_CHECKS}) with self.assertRaises(FileExistsError): promotion.qualification_adapter.qualify(self.request) diff --git a/scripts/qualify-core-release.py b/scripts/qualify-core-release.py index 5c98e223c..70ed3b6a9 100644 --- a/scripts/qualify-core-release.py +++ b/scripts/qualify-core-release.py @@ -24,8 +24,8 @@ control = importlib.util.module_from_spec(_control_spec) _control_spec.loader.exec_module(control) -CHECKS = ('fresh-install', 'current-lifecycle', 'managed-native', - 'current-generations', 'node-runtime', 'diagnostics-observations') +CHECKS = ('fresh-install', 'current-lifecycle', 'managed-native-smoke', + 'diagnostics-observations-smoke', 'node-runtime-smoke') IDENTITY = ('source', 'tree', 'run_id', 'inventory_sha256', 'adapter_sha256') From 77f576735a154c00007c232d0fc52254e86cb917 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 14:56:25 +0000 Subject: [PATCH 08/78] Align operator docs with current installation and Runtime boundaries --- CONTRIBUTING.md | 43 +++++++---- apps/docs/FOLLOW-UP.md | 36 +++++----- apps/docs/README.md | 6 +- apps/docs/content/docs/admin-api.mdx | 24 +++---- apps/docs/content/docs/agents-and-tools.mdx | 40 +++++------ .../content/docs/bootstrap-projects-keys.mdx | 4 +- apps/docs/content/docs/concepts.mdx | 6 +- apps/docs/content/docs/configure.mdx | 46 +++++++----- apps/docs/content/docs/configure.zh.mdx | 2 +- apps/docs/content/docs/console.mdx | 18 ++--- .../content/docs/environments-and-files.mdx | 32 ++++----- apps/docs/content/docs/execution-model.mdx | 6 +- apps/docs/content/docs/hosted-providers.mdx | 47 ++++-------- apps/docs/content/docs/index.mdx | 10 +-- apps/docs/content/docs/install.mdx | 13 ++-- apps/docs/content/docs/observability.mdx | 10 +-- apps/docs/content/docs/public-api.mdx | 64 ++++++++--------- apps/docs/content/docs/quickstart.mdx | 8 +-- .../content/docs/self-hosted-execution.mdx | 29 +++----- apps/docs/content/docs/sessions.mdx | 26 +++---- apps/docs/content/docs/troubleshooting.mdx | 72 ++++++------------- apps/docs/content/guide-sources.json | 52 +++++++------- apps/docs/scripts/generate-guides.mjs | 2 +- apps/docs/scripts/guides.json | 2 +- deploy/install/config.schema.json | 2 +- docs/configuration.md | 32 +++++---- docs/getting-started/README.md | 2 +- docs/getting-started/install.md | 7 +- docs/getting-started/nodes.md | 37 ++-------- docs/getting-started/operations.md | 64 +++++------------ docs/getting-started/self-hosted.md | 29 +++----- services/agents-api/README.md | 47 ++++-------- 32 files changed, 357 insertions(+), 461 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3a8a84c9..08d55c4e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,21 @@ hashes. Do not automatically sync or delete the original repository's Core. Core orchestrates protocol-defined operations. Sandbox providers, Runtime implementations, harnesses and model providers are replaceable components. User-owned machines, E2B, Docker and other environments must expose the same -execution protocol; placement and connection details belong to their providers. +execution protocol. Resource management selects machines, allocates capacity and +creates, renews and reclaims Environments; Runtime prepares capabilities, executes +work and recovers within an Environment. Keep these contracts and lifetimes +separate: closing a Session executor does not release its allocation, destroy its +Environment or delete its workspace. Reclamation is an explicit resource-manager +operation coordinated with active work. + +The capability-unification target is one Runtime preparation path for managed +bundles and self-hosted local directories. Core resolves configuration, versions +and resources; Runtime uses one parser and `installed.json` snapshot to supply +Skill paths and MCP declarations to adapters. Preparation must complete before +execution; reconnect reuses installed contents, and new Sessions freeze a new +configuration snapshot. The protocol is platform-neutral, while the first +implementation is Linux-only. This is pending work, not a statement that current +managed and self-hosted preparation or Windows/macOS isolation is already unified. - Keep component boundaries explicit through shared interfaces and versioned protocols. Register implementations behind those interfaces. Adding an @@ -84,8 +98,10 @@ removed; only the historical `source:"admin_copy"` provenance read remains, fed or request bodies enter logs. The console's fixed actor label (`console`) is only an audit display label, never an authorization input. -When requirements conflict, object ownership is unclear, or a design would need -parallel compatibility paths, raise the issue with a concrete recommendation and +Apply the latest explicit user decisions when older documents conflict, and +update the affected current guidance. Historical evidence does not override those +decisions. When requirements remain unresolved, object ownership is unclear, or a +design would need parallel compatibility paths, raise the issue with a concrete recommendation and tradeoffs before implementing the disputed behavior. Continue independent work while the decision is pending. Do not silently preserve obsolete private designs. @@ -2211,9 +2227,11 @@ TLS reverse proxy routes `/v1` (applications) and `/api/v1` (nodes and Runtime daemons, with their own credentials) directly to Core and everything else, including `/core/v1`, to Web. Operator scripts call `/core/v1` on Core's loopback port. -Nodes and Core come from one distribution; older nodes using the removed -`/core/v1/sandbox` node paths cannot connect and are replaced through the drained -upgrade and re-enrollment workflow. +Nodes and Core come from one distribution. Older nodes using the removed +`/core/v1/sandbox` paths are unsupported; preserve their installation and data and +use a separate fresh installation. There is no drained in-place upgrade or +historical re-enrollment procedure. Current-version Runtime generation rollout +retains its separate resource lifecycle. The Web manager offers no manual Core key entry outside sign-in, and Web refuses to start without its Core key file. It holds no Project API key and never calls @@ -3119,16 +3137,15 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti Item's Session position under the Session lock, preserving observation order for equal timestamps. Allocate a separate zero-based `output_index` per Turn; inputs do not consume output indexes. Updates and retries retain both values. - Existing indexed history keeps its pre-upgrade deterministic order; missing - original ordering cannot be reconstructed. Cursors are scoped to the authenticated Session. + Persisted indexed history keeps its deterministic order; missing original + ordering cannot be reconstructed. Cursors are scoped to the authenticated Session. Terminal Turns expose unfinished Items as `incomplete`, preserving completed message/tool states independently of the Turn outcome. - Public history reads use the persisted index; the private pre-Items journal - backfill is retired. Migration 15 rejects unprepared historical Turns before - removing the obsolete indexing marker. Prepare them with release `906069e` - before upgrading, following `services/agents-api/README.md`. The migration - preserves indexed Item payloads, positions, output indexes and source journals; - never mark unprepared history indexed by hand or replay engine execution. + backfill is retired. Migration 15 and its historical validation remain evidence, + not a supported upgrade procedure. An old installation is unsupported and must + be retained separately from a fresh installation. Never mark unprepared history + indexed by hand or replay engine execution to convert it. - Project only the declared public Item variants; native adapter metadata is not a response schema. Preserve structured tool JSON without float conversion. Completion text replaces accumulated deltas. Item merging must not mutate the diff --git a/apps/docs/FOLLOW-UP.md b/apps/docs/FOLLOW-UP.md index 6da02fefd..c2bbc31e2 100644 --- a/apps/docs/FOLLOW-UP.md +++ b/apps/docs/FOLLOW-UP.md @@ -1,23 +1,27 @@ -# Feature integration follow-up +# Documentation integration status -This candidate documents durable reset, retained Core generations and verified -same-team E2B online changes at source `b9b13b186089b317db271ab83b10f86b48168f66`. -Node changes still require zero held resources until the node protocol is complete. -The Core error-envelope foundation is included; later configuration validators and -diagnostic endpoints are not. Do not replace current procedures with planned behavior -before those features land. +The current repository implements durable reset, retained deployment generations, +same-team E2B online changes and Docker/microsandbox online size and Runtime targets +(design 50, including #178). Node target preparation, qualified serving generations, +retained ownership and generation GC coexist; target changes require neither an +execution drain nor node reenrollment. Core error details, configuration validators +and Session diagnostics are also present in the current source. -| Feature dependency | Site pages to reconcile | Source and verification work | -| --- | --- | --- | -| Final Web generation controls (design 50 PR-W) | configure, hosted-providers, troubleshooting, execution-model | Reconcile the actual Web workflow after node online changes are qualified; keep target preparation separate from serving readiness. | -| Node update protocol (design 50 PR-N) | hosted-providers, troubleshooting, API reference/machine | Explain the implemented update command, old-node handling and ownership preservation. Do not claim an update command now. | -| Later configuration/error changes (design 40 and remaining installer work) | install, configure, troubleshooting, quickstart | Regenerate current configuration fields and revise diagnostics against merged code; keep secrets out of examples. | -| Final feature assembly | every Chinese reading note, execution-model diagram, all generated references | Review translations against regenerated English, verify source provenance and links, run contract/route/copy/fact checks, typecheck/build, local browser review and the repository gate. | +These are repository implementation facts, not evidence that a release was published +or deployed. Use the merged source revision and its matching bundle for an installed +release. A proposal or an unmerged implementation must stay labelled as such until +integration and the requested acceptance have completed. + +| Remaining requirement | Documentation boundary | +| --- | --- | +| Unified Runtime capability preparation | Future design: keep resource allocation, Environment creation and reclamation separate from Runtime prepare/execute/recover. Closing an executor must not destroy its Environment, delete its workspace or release its allocation. Define a platform-neutral protocol with Linux as the first implementation; do not claim it exists today. | +| Agent metrics backend aggregation (BE8) | Still a proposal. Current browser fan-out is not a deployment-wide backend aggregation service; preserve coverage and missing-data limits. | +| Release qualification and publication | Reconcile the final merged source, generated references and reading notes, and record the requested checks. Source implementation alone does not establish published-release or live-deployment readiness. | English guides are rendered from repository sources. Chinese pages are explicitly -labelled reading notes, not complete translations; expand them only against the final -source procedures. API tag pages use the default-language fallback. Release publication, -remote deployment and preview replacement are outside this reconciliation task. +labelled reading notes, not complete translations. Review them when the canonical +procedures change. API tag pages use the default-language fallback. Remote deployment +and preview replacement are outside this documentation reconciliation. The source Web guide still carries a pre-rename screenshot. This site copies the approved current Web onboarding Monitor image instead, with its own source hash; diff --git a/apps/docs/README.md b/apps/docs/README.md index a526c4fa0..0e6ca5e36 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -43,6 +43,6 @@ Set `DOCS_SITE_ORIGIN` only when preparing actual publication. Generation links to the recorded source revision; update that revision after rebasing onto later canonical guide changes. Never point samples at an actual deployment by default. -[Feature follow-up inventory](FOLLOW-UP.md) lists the reset and online-generation -paragraphs that must be reconciled after those implementations land. This candidate -cannot establish final documentation acceptance for those unfinished features. +[Documentation integration status](FOLLOW-UP.md) distinguishes implemented Runtime +generation behavior from future requirements and release qualification. Regenerated +source pages do not by themselves establish that a release is published or deployed. diff --git a/apps/docs/content/docs/admin-api.mdx b/apps/docs/content/docs/admin-api.mdx index 351e5a328..81b78802b 100644 --- a/apps/docs/content/docs/admin-api.mdx +++ b/apps/docs/content/docs/admin-api.mdx @@ -8,7 +8,7 @@ resource inspection, Project and key management, credential issuance, audit, usa and sandbox operations. It never calls `/v1` or `/api/v1`, and has no Agent execution, copy or arbitrary asset editing operation. -Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/core-errors.md), +Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/core-errors.md), including optional typed safe details. Console sign-in endpoints retain their separate error shape described below. @@ -49,9 +49,9 @@ label, but could set any value. Never use it for authorization or as proof of origin. Web calls Core only through the typed Core clients in `packages/agents-client`: -[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/packages/agents-client/src/admin-client.ts) (`/core/v1`), +[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/packages/agents-client/src/admin-client.ts) (`/core/v1`), `SandboxAdminClient` (`/core/v1/sandbox`) and `CoreMetricsClient` -(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md) +(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md) for methods, fields, filters, pagination and response shapes. Routes below are relative to `/core/v1`: @@ -62,8 +62,8 @@ Routes below are relative to `/core/v1`: | Resource lists/details/deletion | `/projects/{id}/agents`, `/sessions`, `/environment-templates`, `/skills`, `/files`, `/vaults`, including the documented nested reads | | Asset ownership | `GET /projects/{id}/resource-owners` with batched resource IDs | | Key operation history | `GET /projects/{id}/write-operations` with key/resource/time filters | -| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-executor-credentials.md)) | -| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/model-execution.md#deployment-defaults)) | +| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-executor-credentials.md)) | +| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/model-execution.md#deployment-defaults)) | | Usage and health | `GET /summary`, `/sandbox/runtime-observations`, `/metrics` | | Administrator audit | `GET /audit-log`; deployment-wide entries have `project_id: null` | @@ -76,9 +76,9 @@ it is operational attribution, not per-key billing. ## Sandbox administration -The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/sandbox-deployment.md), -[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md) -and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/core.openapi.yaml) +The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/sandbox-deployment.md), +[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/core.openapi.yaml) define deployment and node operations: - `GET/POST/PUT /core/v1/sandbox/deployment` and @@ -115,7 +115,7 @@ is a durable serving pin, not proof of current connectivity. Target unknown, fai or update-required state does not by itself invalidate confirmed old-generation service; consume Core's connection/provider facts separately. -Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md#administrative-session-archive) +Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md#administrative-session-archive) at the current generation without reset. History and persisted Files/Artifacts survive; unpersisted workspace is lost and the original Session cannot resume. Cancel reset stops further archives, not cleanup already requested. After zero @@ -156,7 +156,7 @@ generated schema; do not interpret every empty or failed read as an absent resou `GET /core/v1/metrics?range=1h|6h|24h|7d` returns Core process, execution queue/slots, PostgreSQL and background-job measurements. It requires the Core key, rejects arbitrary query filters and never grants -Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/core-metrics.md) +Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/core-metrics.md) for complete buckets, null values, units and process-local retention. Frontend implementation is maintained separately; this backend change does not modify Agent metrics or the public Agent API. @@ -165,7 +165,7 @@ Agent metrics or the public Agent API. The Core key reads a node and its host history through `GET /core/v1/sandbox/nodes/{node_id}?range=1h|6h|24h`. See the -[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/node-host-history.md) +[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/node-host-history.md) for nullable observations, freshness and aggregation. The node list is unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/api/web-management.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/api/web-management.md) diff --git a/apps/docs/content/docs/agents-and-tools.mdx b/apps/docs/content/docs/agents-and-tools.mdx index 1f5432c42..a469cdf46 100644 --- a/apps/docs/content/docs/agents-and-tools.mdx +++ b/apps/docs/content/docs/agents-and-tools.mdx @@ -4,7 +4,7 @@ description: "Native harness tools, shared declarations and the limits of execut --- Assessed against Core `206474a4c10901442b1faa094281bfb5559082b5` on 2026-09-22. -The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/upstream.json), Beta +The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/upstream.json), Beta `agents=v1`. This matrix records the bounded execution/tools milestone. It does not establish complete protocol compatibility; the remaining limits below stay open. @@ -22,20 +22,20 @@ and Environment Plugin MCP have separate inventories and qualification. | Operation | Implemented behavior and real qualification | Unsupported or unverified boundary | | --- | --- | --- | -| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | -| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | -| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | -| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | -| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | -| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | +| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | +| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | +| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | +| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | +| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | +| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | | Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. MiniMax functions and self-hosted image workflows are unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | -| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | -| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | -| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | -| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | -| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | -| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | -| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | +| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | +| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | +| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | +| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | +| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | +| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | +| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | ## Required-action recovery contract @@ -52,7 +52,7 @@ application already performed the function, keep and submit that saved result; do not run an external effect again merely because an action remains visible. Core's acknowledgement boundary is native application. For an environment action, connect its exact Environment using the existing enrollment authorization. -[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/store/environment_connections.go) +[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/store/environment_connections.go) fence stale generations; transport connection can clear pre-Turn activity before native preparation. Neither registration nor an action disappearing proves that a model ran successfully. Query the matching Turn and Items for its outcome. @@ -61,8 +61,8 @@ Two timing questions remain open. Repeated `requires_action` notification order and acknowledgement timing are local implementation choices, not proven upstream semantics. Also, an admitted result that is cancelled before native observation can remain internally saved without a public output Item or `item.added`. -[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/internal/store/item_projection.go) and -[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md#public-function-configuration) preserve +[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/store/item_projection.go) and +[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md#public-function-configuration) preserve this gap. Successful retry cannot manufacture the missing observation or establish that the model consumed the result. @@ -82,12 +82,12 @@ not a claim that every historical workflow was rerun at the current baseline. | M2 | `20260922/workspace-images/validation-summary.json`; Codex `public-run-_lr5uiy_`, Claude `public-run-sb65p9e9`; candidate `2c295116d66ed9aafc808ec49a8cb6dcb5c674c2`, merge `1adb2489bc3415039440224e9a7905c53e68a557` ([#17](https://github.com/MiniMax-AI/parsar-core/pull/17)). Kimi K3, Codex 0.153.4, Claude SDK 0.3.269/native 2.1.269; Docker seven-Turn image/workspace workflow. Codex's receipt conclusion is superseded by F2. | | F1 | `20260922/function-result-images/validation-summary.json`, `validated/function-image-public-2567456666/public.json`; candidate `d3cdb225bc7628b968b76c7e1b400101894ce8cd`, merge `fd23f169e1ede1b2f1c39a1d9dcce71886e3c006` ([#16](https://github.com/MiniMax-AI/parsar-core/pull/16)). Claude SDK 0.3.269/native 2.1.269 with Kimi K3 on `none`; success PNG/JPEG, failed text, retries, cancellation, history continuation. Earlier Codex transport-only evidence does not qualify current receipts. | | F2 | `20260922/function-result-receipts/validation-summary.json`, `candidate-public-owner-sdk.log`; hosted `public-run-4fn70foy` (104.13s), `none` `function-image-public-945075091` (83.918s). Candidate `6dceb98e1c56469fcc70cd82025ce77fc870ed3d`, merge `206474a4c10901442b1faa094281bfb5559082b5` ([#20](https://github.com/MiniMax-AI/parsar-core/pull/20)); real Kimi, Codex 0.153.4. Qualifies native receipts, not completed provider consumption. | -| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | +| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | | S1 | `20260922/structured-output/acceptance-summary.json`, `structured-public-1905281777/public.json`; real candidate `7f533d46c472f3cdf7c16ce9471c225a2ba7eded`, merge `8716e699dbc34904499c94b6b0b03857bbebfaad` ([#11](https://github.com/MiniMax-AI/parsar-core/pull/11)). Claude/Kimi `none` schema/function execution and cold continuation. Schema-specific active steering was not separately qualified here. | | S2 | `20260922/hosted-structured-output/validation-summary.json`, `validated/public-inline.log`, `public-run-qvq3csf1` (186.09s); candidate `f6d2c3f2a1dc2ff5f177213bd2b20d78496011d0`, merge `aa85d8ecc60e0a8b7f2494b73caa81216ee197e2` ([#18](https://github.com/MiniMax-AI/parsar-core/pull/18)). Claude SDK 0.3.269/native 2.1.269, real Kimi; Docker saved/inline schema, prepared/active input, native files and four completed structured answers. | | D1 | `20260922/deferred-tools/tool-search-public-2039155387/public.json`, `public-image-isolated-tests.log` (86.71s); merge `178507ae7a63d4068e1e82bef9cd256ba398ae00` ([#13](https://github.com/MiniMax-AI/parsar-core/pull/13)). Claude SDK 0.3.269/native 2.1.269/Kimi K3 `none`; text results with initial/active PNGs, cancellation and cold continuation. `claude-native-1790052750` separately records provider schema inventories and native feasibility. | | P1 | `20260922/tool-policy/acceptance.json`, `server-evidence/tool-policy-{codex-2495445746,claude_sdk-2539346823,mcode-3747575401}/public.json`; candidate `eeb432c7495265ae3a9309e32f5b4323611c3245`, merge `4b8754a6eda6696d9b18eb8d583953ac16c6800f` ([#14](https://github.com/MiniMax-AI/parsar-core/pull/14)). Codex/Claude Kimi K3, MiniMax M2.7; four `none` Sessions per harness, native disable controls and cold continuation. | -| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | +| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | | E2 | `20260922/execution-tools-closure/environment-actions/result.json` contains Codex/Claude Kimi passes (13 checks each) and the retained MiniMax direct-network failure; `mcode-relay-retry/result.json` separately passes MiniMax M2.7 (13 checks) after correcting provider routing. Source `206474a`; same shared SDK/raw workflow, isolated Core/PostgreSQL and user-managed Docker. Covers initial creation SSE, client disconnect, exact pending Environment/no Turn or Items, scoped enrollment, one actual completed model Turn and creation retries preserving history. This is connection-action qualification, not another full lifecycle or E2B regression. | Controlled tests establish parser, projection, atomicity and race behavior; native @@ -121,4 +121,4 @@ remain separate limitations. They do not authorize silently relabeling an unprov principal workflow as deferred. OAuth, full Usage, release publication, new tool kinds and new execution loops are outside this closure batch. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/execution-tools.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/execution-tools.md) diff --git a/apps/docs/content/docs/bootstrap-projects-keys.mdx b/apps/docs/content/docs/bootstrap-projects-keys.mdx index 7b44799a4..834a766d5 100644 --- a/apps/docs/content/docs/bootstrap-projects-keys.mdx +++ b/apps/docs/content/docs/bootstrap-projects-keys.mdx @@ -69,7 +69,7 @@ and display only. Keep deployment, application, node and provider credentials ou Installation creates no Project or application key. An administrator creates a Project and issues named keys on the console's **Projects and keys** page, which -uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md), or with that +uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md), or with that API directly. The Project owns one tenant and one principal; its keys share assets and permissions. Writes retain each key's provenance. All Projects and application keys live in @@ -98,4 +98,4 @@ management request points to the console's Core key or Core connection. A resour deletion conflict must remain visible; it does not authorize an execution call. Node and daemon `/api/v1` routes and native Runtime interfaces retain their own authentication. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/web/core-connection.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/web/core-connection.md) diff --git a/apps/docs/content/docs/concepts.mdx b/apps/docs/content/docs/concepts.mdx index 38b3434ee..9287e6c7f 100644 --- a/apps/docs/content/docs/concepts.mdx +++ b/apps/docs/content/docs/concepts.mdx @@ -83,12 +83,12 @@ ownership. No request bodies, secrets or file contents enter audit records. Do not add product users, RBAC, cross-Project shared assets, administrator execution, or old private-protocol compatibility to this management model. Existing public -execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/CONTRIBUTING.md). +execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/CONTRIBUTING.md). Native failure classification is adapter-owned and uses finite structured native values. Optional Runtime error metadata is normalized once and retained through normal delivery and journal draining; it never replaces Core terminal authority, cancellation receipts, Usage or native identity. See -[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/native-error-classification.md). +[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/native-error-classification.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/design-principles.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/design-principles.md) diff --git a/apps/docs/content/docs/configure.mdx b/apps/docs/content/docs/configure.mdx index 0ec141481..ccb1d8e31 100644 --- a/apps/docs/content/docs/configure.mdx +++ b/apps/docs/content/docs/configure.mdx @@ -8,7 +8,7 @@ Every setting of a Core installation has exactly one home. There are two kinds: | Kind | Examples | Home | Change it with | Takes effect | | --- | --- | --- | --- | --- | | [Process settings](#process-settings-configjson) | Public URL, ports, logging, harnesses, execution concurrency, audit retention, OAuth origins, database pool, Runtime history export | `config.json` in the installation directory (default `~/.oac/core`) | Edit the file, then run `oac apply` | `oac apply` restarts the services that read the changed settings | -| [Runtime settings](#runtime-settings-web) | Sandbox backend and size, nodes, Projects and keys, default models, executor credentials | Core's PostgreSQL database | Web, or the Core API (`/core/v1`) with the Core key | At once, without a restart | +| [Runtime settings](#runtime-settings-web) | Sandbox backend and size, nodes, Projects and keys, default models, executor credentials | Core's PostgreSQL database | Web, or the Core API (`/core/v1`) with the Core key | Saved without a Core restart; node Runtime changes prepare asynchronously | Web's **System** page shows both: the installation's addresses, the process settings read-only under **Startup settings** with the path of `config.json` and the apply @@ -84,7 +84,7 @@ output or in Core's settings snapshot. Model providers are not process settings; | Key | Type | Default | Modes | Change | Restarts | Meaning | | --- | --- | --- | --- | --- | --- | --- | | `$schema` | string | none | all | any time | none | Editor hint that points at the installed copy of this schema. Ignored. | -| `format` | `1` | none | all | fixed | none | Configuration format. Only an upgrade changes it. | +| `format` | `1` | none | all | fixed | none | Configuration format for this release. Fixed after installation. | | `mode` | `"all"` \| `"core-only"` \| `"web-only"` | `"all"` | all | fixed | none | Which services this installation runs. Install flag: `--core-only` or `--web-only`. | | `native_core` | boolean | `false` | `all`, `core-only` | fixed | none | Run Core as a systemd user service instead of a container. Install flag: `--native-core`. | | `public_url` | string or null (canonical origin; HTTP only on loopback) | `null` | all | `oac apply` | core, web | Public origin of Core and Web behind your TLS reverse proxy, such as https://core.example. Nodes, sandboxes and self-hosted executors use it. null means local access only through http://127.0.0.1. Install flag: `--public-url`. | @@ -116,7 +116,7 @@ output or in Core's settings snapshot. Model providers are not process settings; The schema is -[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/deploy/install/config.schema.json); each +[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/deploy/install/config.schema.json); each installation keeps a copy in `generated/config.schema.json` for editors. Core serves the non-secret settings snapshot, with the path of `config.json` and the apply command, at `GET /core/v1/installation`. @@ -128,9 +128,9 @@ Core API with the Core key. | Setting | Where in Web | Core API | Notes | | --- | --- | --- | --- | -| Sandbox backend: Docker, microsandbox or E2B | **Nodes** (**Sandbox backend** with E2B): the setup wizard, ending with **Save configuration** | `/core/v1/sandbox/deployment` | One backend per deployment. `install.sh --sandbox` saves the first choice. Changing backend requires explicit reset and a new setup at the resulting generation | -| Sandbox size and Runtime release | **Nodes**: the setup wizard | `/core/v1/sandbox/deployment` | Every sandbox gets the same size. See [Sandbox deployment](#sandbox-deployment) | -| E2B API key and template build | **Nodes**: the setup wizard's **E2B cloud** (the page is then called **Sandbox backend**) | `/core/v1/sandbox/deployment` | The key is write-only and encrypted | +| Sandbox backend: Docker, microsandbox or E2B | **System** → **Sandbox backend**: the setup wizard, ending with **Save configuration** | `/core/v1/sandbox/deployment` | One backend per deployment. `install.sh --sandbox` saves the first choice. Changing backend requires explicit reset and a new setup at the resulting generation | +| Sandbox size and Runtime release | **System** → **Sandbox backend**: **Change resources** | `/core/v1/sandbox/deployment` | New targets use the saved size; retained generations keep their original specification. See [Sandbox deployment](#sandbox-deployment) | +| E2B API key and template build | **System** → **Sandbox backend**: the setup wizard's **E2B cloud** | `/core/v1/sandbox/deployment` | The key is write-only and encrypted | | Nodes and their capacity | **Nodes**: **Add node**, **Edit node**, **Remove node** on a node's page (**Remove** in its list row) | `/core/v1/sandbox/enrollment-tokens`, `/core/v1/sandbox/nodes` | See [Node capacity](#node-capacity) and the [nodes guide](/hosted-providers) | | Projects and API keys | **Projects and keys**: **Create project**, **Rename**, **Issue key**, **Revoke**, **Archive** | `/core/v1/projects` | Keys are shown once; Core stores digests | | Default model per harness | **System**: **Default model** | `/core/v1/harnesses/{harness}/model-provider` | See [Default models](#default-models) | @@ -145,7 +145,7 @@ Which harnesses are enabled, and the default one, are process settings Exactly one provider serves the deployment: Docker, microsandbox or E2B. The deployment specification holds the per-sandbox resources and, for Docker and microsandbox, the Runtime release. Web's setup proposes the Standard size from -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/apps/web/src/features/sandbox/standard-sizes.json) and +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/apps/web/src/features/sandbox/standard-sizes.json) and derives Small and Large from it; the installer applies the bundle's copy of the same file. The database owns the saved values. @@ -170,13 +170,21 @@ old key valid until the update succeeds. Initial setup requires a team-owned tem a legacy public-template configuration or an already revoked old key requires reset when Core cannot verify the committed ownership anchor. -Docker and microsandbox size/Runtime edits still require zero held resources and -retire the old nodes and enrollment commands. Add those hosts again after cleaning -up their retired node installations. Node online preparation and the corresponding -Web controls are separate follow-up work. History stays, and existing Sessions never -move between providers. See the -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) -and the [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/sandbox-deployment.md). +Docker and microsandbox size/Runtime edits advance the target generation online. +They require neither zero held resources nor node retirement or reenrollment. +Version 2 nodes prepare the target independently, while existing allocations and +suspended VMs retain their original generation. A qualified older serving generation +can still accept new Sessions when it has capacity, including while the target is +preparing or has failed. Target rollout and serving readiness are separate facts. +Use **System** → **Sandbox backend** → **Change resources** to edit the target; +**Nodes** owns node management and readiness. + +Current Runtime generation coexistence and ownership-scoped garbage collection +remain supported. They do not upgrade the installed node program or convert an old +installation. History stays, and existing Sessions never move between providers. +See the [nodes guide](/hosted-providers#change-the-sandbox-backend-or-size), +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/sandbox-deployment.md). ### Node capacity @@ -203,10 +211,10 @@ Session freezes the provider it starts with, so a later change affects only new Sessions. Self-hosted Sessions never use it. A hosted or self-hosted Session with no provider is refused with 400 `model_provider_required`. See [model providers](/quickstart#model-providers) and the -[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/model-execution.md#deployment-defaults). +[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/model-execution.md#deployment-defaults). The operator file `AGENTS_API_EXECUTION_OPTIONS_FILE` is retired; see -[Upgrade notes](/troubleshooting#upgrade-notes). +[installation version policy](/troubleshooting#installation-version-policy). ## Secrets and identity @@ -223,7 +231,7 @@ A Web-only installation has only `secrets/core.key`, a copy of its Core's key. Core reads only its environment. The installer renders `generated/core.env` from `config.json`; if you run Core yourself (see -[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/maintainers.md)), set these variables. Compose +[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/maintainers.md)), set these variables. Compose loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose must be 2.26.0 or newer. @@ -238,7 +246,7 @@ loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose mu | `OAC_INSTALLATION_ID` | `state.json`; pinned to the database | | `OAC_SETTINGS_FILE` | `generated/settings.json`, the snapshot Core serves at `GET /core/v1/installation`; Core does not act on it | | `OAC_EXECUTION_CONCURRENCY`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS` | The matching `core.*` settings | -| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/runtime-history-api.md) | +| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime-history-api.md) | | `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | `log.*`; Web reads the same three | | `OAC_E2B_STATE_DIR`, `OAC_E2B_PROVIDER_BIN` | The E2B receipt directory and, for native Core, the bundled helper. The E2B key and template live in the database | @@ -260,4 +268,4 @@ paths must be canonical absolute paths without control characters, quotes, backs or wildcards. Keep the installation ID and the database together; Core refuses a missing installation ID when its database already has a deployment. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/configuration.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/configuration.md) diff --git a/apps/docs/content/docs/configure.zh.mdx b/apps/docs/content/docs/configure.zh.mdx index 8f0c525b7..9ade67e5b 100644 --- a/apps/docs/content/docs/configure.zh.mdx +++ b/apps/docs/content/docs/configure.zh.mdx @@ -3,7 +3,7 @@ title: "Configuration reference · 中文导读" description: "当前版本的中文导读;完整操作步骤见英文正文。" --- -安装配置的唯一编辑入口是 config.json;运行 oac apply 应用变更。generated 目录由安装器生成。运行时后端、节点和默认模型由 Web 管理并保存在 PostgreSQL 中。 +安装配置的唯一编辑入口是 config.json;运行 oac apply 应用变更。generated 目录由安装器生成。运行时后端、节点和默认模型由 Web 管理并保存在 PostgreSQL 中。System 的 Sandbox backend 页面可在线调整同一后端的资源与 Runtime 目标;节点独立准备,现有资源保留原 generation,已就绪的旧 generation 仍可承接新 Session。 此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/configure)。 diff --git a/apps/docs/content/docs/console.mdx b/apps/docs/content/docs/console.mdx index 6af130720..49a2a64a9 100644 --- a/apps/docs/content/docs/console.mdx +++ b/apps/docs/content/docs/console.mdx @@ -27,7 +27,7 @@ management client, and it sends nothing to `/v1`. | Platform | Projects and keys, Nodes, System | Project and key lifecycle; sandbox deployment and nodes; System: the installation's public address, API base URL, ID and source commit (read-only), each harness's default model provider (write-only key) beside its read-only startup state, the sandbox configuration every Project shares: provider, sandbox size, Runtime or E2B template build, idle suspension and reset, and Core's config.json startup settings with where to change them | Missing data is shown as missing (—), never as zero. How each figure is read and -bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/web/protocol-coverage.md). +bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/web/protocol-coverage.md). ## Management scope @@ -60,15 +60,15 @@ only the console server sends it to Core. - [Connection and authentication](/bootstrap-projects-keys) - [Architecture and ownership](/execution-model) -- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/web/protocol-coverage.md) -- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/web/roadmap.md) -- [React application](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/apps/web/README.md) +- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/web/protocol-coverage.md) +- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/web/roadmap.md) +- [React application](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/apps/web/README.md) -The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md) defines -management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md) +The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md) defines +management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md) define the separate application interface. See the [design principles](/concepts) -for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/CONTRIBUTING.md) for required checks. +for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/CONTRIBUTING.md) for required checks. -OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/LICENSE). +OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/LICENSE). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/web/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/web/README.md) diff --git a/apps/docs/content/docs/environments-and-files.mdx b/apps/docs/content/docs/environments-and-files.mdx index 566f1d3c9..dab4c4eea 100644 --- a/apps/docs/content/docs/environments-and-files.mdx +++ b/apps/docs/content/docs/environments-and-files.mdx @@ -3,16 +3,16 @@ title: "Environments and files" description: "Execution placement, Environment resources and workspace ownership." --- -This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/upstream.json), with partial +This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/upstream.json), with partial coverage. Core-managed Docker runs the three qualified native harnesses. V1 user-managed `self_hosted` enrollment uses the same colocated Runtime for Codex, -Claude SDK and MiniMax at `/workspace`; see its [real deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/user-managed-runtime-v1.md). +Claude SDK and MiniMax at `/workspace`; see its [real deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/user-managed-runtime-v1.md). For hosted compute, the deployment selects E2B, Docker or microsandbox through -[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md). +[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md). For the separate caller-managed E2B path, the user owns allocation, renewal and -cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/deploy/e2b/README.md). -[Templates](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; -[Files](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. +cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/deploy/e2b/README.md). +[Templates](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; +[Files](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. The internal Store now owns a durable Environment association for newly created `self_hosted` and `openai_hosted` snapshots, atomically with Session creation. @@ -30,15 +30,15 @@ readiness. Rotation/revocation, Session deletion and ownership loss deny further access without promising immediate cessation of native effects. Native history remains local to the bound Runtime and cannot be replaced on retry. There is no registry/Noise relay or transient service-side harness credential. -See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/README.md#user-managed-runtime-enrollment). +See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/README.md#user-managed-runtime-enrollment). ## Basic public Docker-hosted profile An explicitly configured default managed Docker provider enables `type=openai_hosted` for the qualified Codex, Claude Code and MiniMax Code profiles. Each uses the same Runtime lifecycle and workspace interfaces with its own native adapter/isolation. -See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. -The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) +See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. +The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) selects the qualified immutable Runtime image; advertised capabilities alone do not enable admission. An idle or initial-text creation commits Session, Environment and retry identity before the existing leased Worker provisions its allocation. @@ -50,7 +50,7 @@ policies use the same qualified image with adapter-selected immutable native pol Templates and inline configuration share initial files, env, packages, ordered setup and inline or tenant-owned referenced Skills through the hosted initializer. Unsupported hostname forms and installation combinations reject explicitly; see -the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-templates.md). Empty/null installation +the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Service-origin hosted MCP remains unsupported; Environment Plugin MCP has its own qualified transport matrix. @@ -70,11 +70,11 @@ semantics; this profile does not establish complete Environment compatibility. The application creates, renews and destroys its E2B sandbox through the official SDK. It deploys the shared Runtime, then enrolls that Runtime into a `self_hosted` Session. Core neither keeps an E2B allocation nor issues Provider renew/kill calls. -The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/deploy/e2b/README.md) owns packaging and +The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/deploy/e2b/README.md) owns packaging and user-side lifecycle instructions. Expiry or lost workspace/history must not trigger -transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/user-managed-runtime-v1.md) +transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/user-managed-runtime-v1.md) records its own real deployment evidence. -The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md#e2b-v1-qualification) records a historical +The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md#e2b-v1-qualification) records a historical Core-managed route; it does not establish this caller-managed enrollment result. Current deployment-managed E2B is a separate hosted configuration in the manager guide. @@ -111,7 +111,7 @@ connection alone is not readiness. Retries preserve identity and the five-minute database deadline. HTTP disconnect retains the reservation. Local expired/cancelled outcomes return 409, lost ownership 503, and deletion 404; exact hosted error status/body and pending-input crash recovery are unverified. See the -[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/CONTRIBUTING.md#environment-ownership-and-placement). +[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/CONTRIBUTING.md#environment-ownership-and-placement). Cancellation-only batches use the existing locked admission and native delivery. An idle cancellation creates no Turn, and retry identity preserves the original @@ -191,7 +191,7 @@ from Session and Turn status. ## Ownership and placement decision -The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/CONTRIBUTING.md#environment-ownership-and-placement) +The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/CONTRIBUTING.md#environment-ownership-and-placement) keep Environment lifecycle common while leaving process placement and native transport to adapters. Logical ownership does not require a machine per object. @@ -215,4 +215,4 @@ native history independently of disposable compute, or prove native restoration; never treat an Environment ID as a filesystem or history backup. Do not silently move an existing Session away from its bound device. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environments.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environments.md) diff --git a/apps/docs/content/docs/execution-model.mdx b/apps/docs/content/docs/execution-model.mdx index 1c57f5eee..81991c732 100644 --- a/apps/docs/content/docs/execution-model.mdx +++ b/apps/docs/content/docs/execution-model.mdx @@ -10,7 +10,7 @@ Agents API independently with their own Project keys. The management backend, `AdminClient` and the React console built on them are implemented. The [design principles](/concepts) define identity and authority. -The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md) defines exact +The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md) defines exact routes, payloads, pagination and audit records. ## Request boundaries @@ -106,6 +106,6 @@ comes from the deployment's allocations; the hosted Runtime total counts hosted observation records across projects and reported lifecycle states. These sources have different coverage and refresh independently, so the console does not infer resource retention or cleanup from their difference. -Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/CONTRIBUTING.md). +Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/CONTRIBUTING.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/web/architecture.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/web/architecture.md) diff --git a/apps/docs/content/docs/hosted-providers.mdx b/apps/docs/content/docs/hosted-providers.mdx index ff42029e8..917360f38 100644 --- a/apps/docs/content/docs/hosted-providers.mdx +++ b/apps/docs/content/docs/hosted-providers.mdx @@ -10,7 +10,7 @@ With E2B you need no nodes. Self-hosted executors, which applications run for th own Sessions, are a different thing; see [Self-hosted executors](/self-hosted-execution). You add a node by generating a command in Web and pasting it on the host. The -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the node protocol, manual registration, placement and failure handling. ## Before you add a node @@ -187,7 +187,7 @@ The installation ID is in the command (`--installation-id`) and on the **System* 1. In Web, open **Nodes** and choose **Remove node** on the node's page, or **Remove** in its list row, then **Confirm removal**. Core refuses while the node still holds sandboxes, snapshots or pending cleanup; let them finish, or - [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) + [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) through the Core API. Removal is permanent: the host can come back only as a new node. 2. Web then shows **Clean up the host** with the uninstall command. Run it on the host: @@ -290,40 +290,17 @@ files. Shared daemon images remain; a host administrator may remove them only af confirming no installation still needs them. Microsandbox image cleanup remains scoped to the installation's private native store and verified ownership receipts. -The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) describes reset and API behavior. Same-team E2B edits have a separate online Core API path and do not involve node installation. -## Remove a node added before the rename - -A new installer refuses retained state for the same installation under the old -`parsar-node` names. It does not convert that state or remove nodes belonging to -another installation. Remove the node on its old Core's Nodes page, then run the -**previous release's** verified `node-install.pyz --uninstall --installation-id -<installation-id>`, with `sudo` only if that node was installed with sudo. Use -`--force` only when that Core no longer exists. - -If the previous installer is unavailable, inspect and remove only that -installation's resources: - -1. Stop and disable `parsar-node-.service` with `systemctl`, using - `sudo` for a system service or `--user` as the original installing user. -2. Remove its exact unit file or enablement link. System units live in - `/etc/systemd/system/`; user links live in `~/.config/systemd/user/`. Reload the - same systemd manager after removing the unit. -3. After stopping the node, remove its state directory as its owner: - `/var/lib/parsar-node/.parsar/nodes/` as `parsar-node` in sudo - mode, or `~/.parsar/nodes/` as the original user. Do not run - root cleanup through files or links controlled by the service account. -4. In sudo mode, remove only `/etc/parsar-node/.json`. Keep the - account record, the `parsar-node` user and group memberships while any other - installation or retained microsandbox store uses them. -5. For Docker, remove `parsar-node-` only after confirming that - its network has no attached containers. Keep Runtime images, volumes and - retained microsandbox stores until their owners have explicitly retired them. - -Then use the new Core's Add node command. The new account, service and state use -`oac-node` and `~/.oac/nodes`; historical Core Session records remain in Core. - -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/getting-started/nodes.md) +## Historical node installations + +An older node installation is unsupported by the current installer. Retained state +under the old `parsar-node` names is a refusal condition, not an invitation to +convert or delete it. Preserve the old installation, Runtime resources and history; +provision a fresh node separately through **Add node**. The current installer does +not adopt historical resources or remove another installation's state. + +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/getting-started/nodes.md) diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 9311b1bfb..691c47b46 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -13,16 +13,16 @@ applications call Core with. | [Install Core and Web](/install) | Administrators | Prerequisites, download, the installer and every option, HTTPS and the reverse proxy, a local trial, first sign-in, what is created on disk | | [Nodes](/hosted-providers) | Administrators | Adding a node with one command, sudo and no-sudo modes, removal, logs and troubleshooting | | [Self-hosted executors](/self-hosted-execution) | Administrators and application owners | Connecting an application's own machine to a `self_hosted` Session, rotating and revoking its credential | -| [Operations](/troubleshooting) | Administrators | The `oac` command, the Core key, backups, upgrades and troubleshooting | +| [Operations](/troubleshooting) | Administrators | The `oac` command, the Core key, backups, current-release repair and troubleshooting | | [Configuration reference](/configure) | Administrators | Every `config.json` setting and every runtime setting in Web | | [Call the API](/quickstart) | Application developers | `OPENAI_BASE_URL` and `OPENAI_API_KEY`, running a Session, model providers and `x_agents_core` | | [API reference](/public-api) | Developers | The `/v1`, `/core/v1` and `/api/v1` namespaces and their contracts | Deeper references: the -[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md) reference for operators, the -[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md), and -[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/maintainers.md) for building distributions +[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md), and +[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/maintainers.md) for building distributions and running Core without the installer. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/getting-started/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/getting-started/README.md) diff --git a/apps/docs/content/docs/install.mdx b/apps/docs/content/docs/install.mdx index 38e3ef03d..d91e71310 100644 --- a/apps/docs/content/docs/install.mdx +++ b/apps/docs/content/docs/install.mdx @@ -16,7 +16,8 @@ add nodes. Applications call Core's API with those keys. 6. [Add a node](/hosted-providers). These pages describe the current source. Every bundle carries the docs that match it -under `docs/`; if you install an older bundle, follow those. +under `docs/`. This private project supports fresh installation and repair of the +same release; historical-version upgrades and conversion are unsupported. ## Prerequisites @@ -139,7 +140,7 @@ A deployment runs its sandboxes on exactly one backend: | `none` | Nothing yet | Choose the backend on the **Nodes** page in Web | The Standard sizes come from the bundle's copy of Web's -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/apps/web/src/features/sandbox/standard-sizes.json). The +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/apps/web/src/features/sandbox/standard-sizes.json). The installer saves the choice through the same administrator API that Web uses once the services are healthy. If Core refuses it, for example because E2B rejects the key, the installer prints Core's message and exits with an error; the services keep @@ -163,7 +164,7 @@ it installs anything: ``` Prepare the template build with the -[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/deploy/e2b/README.md). To change the backend or +[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/deploy/e2b/README.md). To change the backend or the sandbox size later, use the Nodes page in Web; see [Nodes](/hosted-providers#change-the-sandbox-backend-or-size). @@ -489,7 +490,7 @@ at Add node. An existing pre-rename installation at `~/.parsar/core` blocks a new default install. Historical conversion is not supported. Preserve the existing installation and explicitly -choose another `--install-dir`. See [installation version policy](/troubleshooting#convert-an-earlier-installation). +choose another `--install-dir`. See [installation version policy](/troubleshooting#installation-version-policy). ## What the installer creates @@ -528,6 +529,6 @@ Rerunning `./install.sh` from the same bundle repairs an installation: it reload missing images, restores a missing `oac` command, copies node files newly placed in `artifacts/`, applies `config.json` and starts the services. It accepts only `--install-dir`. It refuses a bundle from another release; see -[Upgrades](/troubleshooting#upgrade-an-installation). +[installation version policy](/troubleshooting#installation-version-policy). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/getting-started/install.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/getting-started/install.md) diff --git a/apps/docs/content/docs/observability.mdx b/apps/docs/content/docs/observability.mdx index 4ca748148..c3eb9c45e 100644 --- a/apps/docs/content/docs/observability.mdx +++ b/apps/docs/content/docs/observability.mdx @@ -7,7 +7,7 @@ Status: Phase 2 and initial Core Web consumption implemented. The current-snapsh `packages/agents-client` projection, and generated `core.openapi.yaml` contract are implemented and consumed by the Dashboard through complete Session/observation identity joins. Durable history uses the separate optional -[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside +[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside this phase. These are administrator reads under `/core/v1`, authenticated by the Core key, @@ -92,7 +92,7 @@ Authorization: Bearer ... ``` This returns the same object shape as a list item's `observation`, without the -administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md)). It never starts a Turn, creates +administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md)). It never starts a Turn, creates an Environment, provisions compute, renews a lease, or changes lifecycle state. A valid `environment:none` Session returns `200` with status `unsupported`; the @@ -193,7 +193,7 @@ Use the existing Agents API error envelope. | 400 | `invalid_request_error` / `invalid_request_error` | List: a repeated supported query key, or an empty or invalid limit or order, with the shared Beta list messages. Unknown list query keys are ignored. | | 400 | `invalid_request_error` / `unsupported_parameter` | Single-Session retrieval with any query parameter. | | 401 | `invalid_request_error` / `invalid_admin_key` | Missing or invalid Core key. | -| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | +| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | | 500 | `server_error` / `internal_error` | Integrity, ownership, or invalid provider evidence. | | 503 | `server_error` / `execution_unavailable` | Required Runtime observation service is not configured, or list collection exceeded its request budget. | @@ -273,10 +273,10 @@ deletion makes the candidate incomplete and prevents publication. - Token usage: use existing Session/Turn Usage. - Billing and cost: product/backend concern. - Historical series in these routes: the optional capability uses the separate - [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/runtime-history-api.md). + [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime-history-api.md). - Container logs and command output. - Provider credentials or native configuration. - Start, stop, pause, resume, restart, renew, or delete operations. - Idle classification and automatic shutdown. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/runtime-observability-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime-observability-api.md) diff --git a/apps/docs/content/docs/public-api.mdx b/apps/docs/content/docs/public-api.mdx index 4c9bea5e2..369730108 100644 --- a/apps/docs/content/docs/public-api.mdx +++ b/apps/docs/content/docs/public-api.mdx @@ -9,8 +9,8 @@ no credential works in another namespace. | Namespace | Caller | Credential | Contents | Reference | | --- | --- | --- | --- | --- | | `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`) | [Public API](/sessions) | -| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/core.openapi.yaml) | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/runtime.openapi.yaml) | +| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/core.openapi.yaml) | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime.openapi.yaml) | A Project API key gets 401 on `/core/v1` and `/api/v1`; the Core key gets 401 on `/v1` and `/api/v1`. Projects own assets. Multiple equally privileged keys share @@ -34,7 +34,7 @@ with the Core key on Core's loopback port, not through the public entry. Applications call every `/v1` route with a Project API key. The routes are exactly the 58 method and path pairs in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/upstream-routes.json); the +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/upstream-routes.json); the [Public API guide](/sessions) groups them by resource. `/v1` has no Core-only route. @@ -45,18 +45,18 @@ with the Core key. | Routes | Contents | Contract | | --- | --- | --- | -| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/execution-configuration.md) | -| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/session-diagnostics.md) | +| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/execution-configuration.md) | +| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/session-diagnostics.md) | | `projects/{project_id}/sessions/{session_id}/runtime-observation`, `sandbox/runtime-observations` | Current Runtime observations | [Runtime observations](/observability) | -| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/runtime-history-api.md) | -| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-executor-credentials.md) | -| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/installation.md) | -| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/core-metrics.md) | -| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/node-host-history.md) | -| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/model-execution.md#deployment-defaults) | +| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime-history-api.md) | +| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-executor-credentials.md) | +| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/installation.md) | +| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/core-metrics.md) | +| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/node-host-history.md) | +| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/model-execution.md#deployment-defaults) | ## Machine connection API @@ -65,42 +65,42 @@ the Core key or a Project API key. | Routes | Caller | Credential | Contract | | --- | --- | --- | --- | -| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | -| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-executor-credentials.md) | -| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/services/agents-api/README.md#user-managed-runtime-enrollment) | +| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | +| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-executor-credentials.md) | +| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/README.md#user-managed-runtime-enrollment) | ## Contract sources -- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/upstream.json): OpenAI +- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/upstream.json): OpenAI Python SDK 3.13.0, exact upstream commit and `agents=v1`. -- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/upstream-routes.json) and - [fields](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/upstream-fields.json): extracted from the +- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/upstream-routes.json) and + [fields](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/upstream-fields.json): extracted from the pinned SDK by `scripts/extract-agents-api-upstream.py`. Contract tests require the public OpenAPI to have exactly these routes and to keep every other field inside `x_agents_core`. -- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/openapi.yaml): public schema snapshot; - combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/operation-evidence.md). -- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/core.openapi.yaml): generated `/core/v1` +- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/openapi.yaml): public schema snapshot; + combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/operation-evidence.md). +- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/core.openapi.yaml): generated `/core/v1` routes, all authenticated by the Core key. -- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/runtime.openapi.yaml): - generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/node-generation-protocol.md); the private +- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime.openapi.yaml): + generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/node-generation-protocol.md); the private daemon transport is described in the Runtime credential guide. -- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md): Project/key +- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md): Project/key lifecycle, resources, explicit hosted Session archive, summary, errors/deletion preconditions, audit and historical copy provenance. - [Web integration](/admin-api): browser, console and Core boundaries and frontend handoff. -- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/CONTRIBUTING.md): +- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/CONTRIBUTING.md): ownership, security and change requirements. Generated schemas do not establish complete compatibility or real execution -support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md) identifies +support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md) identifies qualified workflows, native differences and unresolved behavior. Update the relevant contract and this index when adding or moving an API surface. -Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/core-errors.md), +Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/core-errors.md), including typed optional safe details and distinct console proxy rejection codes. The public and machine error contracts remain unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/api/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/api/README.md) diff --git a/apps/docs/content/docs/quickstart.mdx b/apps/docs/content/docs/quickstart.mdx index bd3558799..3606c172e 100644 --- a/apps/docs/content/docs/quickstart.mdx +++ b/apps/docs/content/docs/quickstart.mdx @@ -101,8 +101,8 @@ The provider's protocol must match the harness: | Claude Code (`claude_sdk`) | `anthropic` | The provider's exact model ID | | MiniMax Code (`mcode`) | `anthropic` | `context_window` and `max_output_tokens` | -See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/harness-selection.md) and -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/model-execution.md) for the full contract. +See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/harness-selection.md) and +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/model-execution.md) for the full contract. Selecting a harness doesn't make an unsupported model or operation work. ## Model providers @@ -142,8 +142,8 @@ not replay history. Don't submit the same work as a new Session when a response lost. Files, Artifacts, cancellation and their current limits are in the -[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md). MCP credentials use the +[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md). MCP credentials use the separate Vault API; they are neither Project API keys nor model provider keys. The [API index](/public-api) lists every route. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/getting-started/quickstart.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/getting-started/quickstart.md) diff --git a/apps/docs/content/docs/self-hosted-execution.mdx b/apps/docs/content/docs/self-hosted-execution.mdx index 3e81ea259..38575ed5a 100644 --- a/apps/docs/content/docs/self-hosted-execution.mdx +++ b/apps/docs/content/docs/self-hosted-execution.mdx @@ -143,27 +143,20 @@ key_id=$(python3 -c 'import uuid; print(uuid.uuid4())'); echo "credential ID: $k Rotate with `{"key_id":"…","rotate":true}` on the same route; revoke with `DELETE …/executor-credentials/`. After an uncertain response, list the credentials with `GET` before trying again. The -[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-executor-credentials.md) +[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-executor-credentials.md) has every rule and error. -## Executors installed before the rename +## Historical executor installations -An existing `parsar-selfhost-*` container keeps its own image, daemon and -`~/.parsar/self-hosted/` directory. It can keep serving its -Environment after Core is renamed; Core does not upgrade or manage this container. +The current installer does not upgrade or adopt an executor from an older release. +It refuses when the same Environment retains state under the old +`~/.parsar/self-hosted` directory, including when another `--install-dir` is supplied. +Preserve the old container, files and native history; provision a fresh self-hosted +Session and connect its executor separately. There is no supported cross-version +continuation or automatic data deletion. -The new installer refuses when that exact Environment still has a directory under -`~/.parsar/self-hosted`, including when a different `--install-dir` is supplied. -Executors for other Environments are unaffected. To replace it, inspect the old -installation's `started.json` to identify its container, stop and remove that -exact container, and preserve any history you need before removing the old -Environment's installation directory. Then rerun the new verified installer. It -starts `oac-selfhost` with state under `~/.oac/self-hosted/`; -it does not reuse the old executor's volumes or native history. +Credential replacement for executors created by the current installer updates and +restarts the same owned container. It does not authorize adopting an older executor. -Credential replacement for executors created by the new installer still updates -and restarts the same owned container. The legacy refusal does not authorize -adopting an old container to replace its credential. - -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/getting-started/self-hosted.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/getting-started/self-hosted.md) diff --git a/apps/docs/content/docs/sessions.mdx b/apps/docs/content/docs/sessions.mdx index 36944b8b4..83cf6f516 100644 --- a/apps/docs/content/docs/sessions.mdx +++ b/apps/docs/content/docs/sessions.mdx @@ -34,8 +34,8 @@ leaves the others valid; archiving the Project revokes all its keys. The optiona ## Public resources Paths are relative to `/v1`. Detailed fields, union types and response shapes are -in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/openapi.yaml), fixed SDK source -and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/README.md#upstream-resource-inventory) +in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/openapi.yaml), fixed SDK source +and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md#upstream-resource-inventory) lists each operation and its coverage. | Resource | Route family | Semantics | @@ -44,11 +44,11 @@ lists each operation and its coverage. | Sessions and Turns | `/agents/sessions`, nested `/turns` | Admission, metadata, state and recovery queries | | Items and events | Session `/items`, `/events` | Persisted history and live SSE | | Artifacts | Session `/artifacts` | Captured output metadata, immutable content and deletion | -| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/subagents.md) | -| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-files.md) | -| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-templates.md) | -| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/source-files.md), distinct from live workspace files | -| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/file-resource-semantics.md) | +| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/subagents.md) | +| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-files.md) | +| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-templates.md) | +| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/source-files.md), distinct from live workspace files | +| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/file-resource-semantics.md) | | Vaults and Credentials | `/vaults`, nested `/credentials` | [Scoped write-only credential storage](/environments-and-files) | SSE is live, not a historical replay service. After a disconnect, query the Session, @@ -58,14 +58,14 @@ native execution. Do not automatically replay uncertain tool or file effects. ## Core extension fields The `/v1` route set is exactly the pinned SDK's operations, listed in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/upstream-routes.json); Core adds +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/upstream-routes.json); Core adds no route. It adds fields only inside `x_agents_core`, because it runs several harnesses and accepts custom model access: | Field | Where | Reference | | --- | --- | --- | -| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/harness-selection.md) | -| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/model-execution.md) | +| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/harness-selection.md) | +| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/model-execution.md) | Any other member of `x_agents_core` is rejected with 400. Deployment, placement, credential issuance and operational reads are not part of `/v1`; they belong to @@ -78,7 +78,7 @@ never to `self_hosted`; `none` Sessions accept only the deployment default. `openai_hosted` and `self_hosted` Sessions must end up with one: otherwise creation returns 400 with code `model_provider_required` and param `x_agents_core.model_provider`, and nothing is written. See -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/model-execution.md). +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/model-execution.md). An application reads a Session's model from `agent.model` and an explicitly selected harness from `agent.x_agents_core.harness`; a Session on the deployment @@ -90,8 +90,8 @@ The deployment chooses E2B or its own Docker/microsandbox nodes. A public `self_hosted` Environment uses caller-owned compute and the same colocated Runtime. For a `self_hosted` Session, the deployment operator issues the Environment's executor credential with the Core key; see -[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/environment-executor-credentials.md). +[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-executor-credentials.md). Our daemon transport and the supported native harness differences are explicit; they do not imply interoperability with OpenAI's stock executor transport. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/api/public-agent-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/api/public-agent-api.md) diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx index f4f07f235..cc17f119d 100644 --- a/apps/docs/content/docs/troubleshooting.mdx +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -123,7 +123,7 @@ refuses. `rotate-core-key` refuses to run on a Web-only installation. ## Projects and API keys Create Projects and issue their keys in Web, on **Projects and keys**, or with the -[Core API](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's +[Core API](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's plaintext is shown once; Core stores only its digest. All keys of a Project share its assets and execution principal, and each write records the key that made it. @@ -132,7 +132,7 @@ application, then **Revoke** the old key. **Archive** disables all of a Project' and keeps its assets and accepted work. Administrators can inspect and delete retained resources but can't execute with the Core key. -## Data and upgrades +## Data and current-release repair ### Back up @@ -162,7 +162,7 @@ and provider storage rather than registering over existing resources. Never prun Docker volumes or delete native history to make a retry pass. A deleted Session does not prove that all provider resources were reclaimed. -### Upgrade an installation +### Installation version policy In-place version upgrades, downgrades and historical conversions are not supported. Keep existing data and installations intact; install the new release into a new, @@ -174,49 +174,20 @@ and mutating `oac` commands use the same installation lock, including during rep and interrupted apply recovery. If another command owns it, retry after it finishes. Never remove or replace `.oac.lock` to bypass a busy installation. -### Convert an earlier installation - -Historical conversion is not supported. `--convert` refuses without changing the -installation. Preserve its files and data, and reinstall separately. - -### Upgrade notes - -Retired process settings remain rejected. Configure a fresh installation using the -current [configuration reference](/configure); do not copy old generated -files into it. - -#### Node connections at /api/v1 - -Core and its nodes must come from the same distribution; the node installer refuses a -mismatched release. Current releases serve every machine connection at `/api/v1`, and -Web returns 404 there. When the new Core and Web go live, route `/api/v1/*`, including -WebSocket upgrades, to Core instead of Web -([HTTPS and the reverse proxy](/install#https-and-the-reverse-proxy)); with the old -routing, node enrollment and every Runtime connection fail, for Docker, microsandbox, -E2B and self-hosted executors alike. - -Nodes from releases that used the removed `/core/v1/sandbox/enroll` and -`/core/v1/sandbox/node/*` paths can't connect to a current Core. For a Docker or -microsandbox deployment: - -1. Drain with the previous release while its nodes are connected: enter maintenance, - archive retained hosted Sessions and wait until nothing is retained using that - previous release's maintenance procedure. -2. Upgrade Core and Web, and change the routing as above. -3. Reset a drained selection that lacks a specification, then configure the new - release's Runtime and add nodes again. On each node host, - stop the old node service and move its state directory aside as a backup first. - -A Web-selected Docker or microsandbox deployment saved before deployment specifications -existed has an empty specification after migration. Draining it needs a Core that has -the pre-specification drain mode (pull request #114) but still serves the old node -paths. No such release was published: build a distribution from main commit -`7b66be236a627246c85658722314285e6b39d9b8`, or any commit that contains #114 but not the -move to `/api/v1/sandbox-node`. That Core loads the deployment only to drain; fresh -sandboxes, node configuration reads and enrollment are refused. Back up, replace Core -and Web with that build, drain as in step 1, then upgrade to the current release and -continue with steps 2 and 3. Node IDs change; Session history and persisted Files and -Artifacts remain. E2B deployments from that period are not covered. +Historical installations and removed CLI or machine-protocol variants have no +migration procedure. Preserve their files, database, Runtime resources and Session +history; use a fresh installation and separately provision its nodes. Reinstallation +does not authorize deleting the previous installation's data. Retired process +settings remain rejected; use the current +[configuration reference](/configure). + +### Machine connections + +Core and its nodes must come from the same distribution; the node installer refuses +a mismatched release. Route `/api/v1/*`, including WebSocket upgrades, to Core; +Web returns 404 there. This applies to Docker and microsandbox nodes, E2B and +self-hosted executors. See +[HTTPS and the reverse proxy](/install#https-and-the-reverse-proxy). ## Troubleshooting @@ -228,8 +199,7 @@ Artifacts remain. E2B deployments from that period are not covered. | `Port N is already in use; select another port` | Free the port, or install with `--core-port`/`--web-port` | | `Installation directory is not empty …` | Use an empty `--install-dir` | | `This installation is configured by …/config.json …` | Flags only seed a new installation: edit `config.json` and run `oac apply` | -| `This installation predates config.json …` | [Convert it](#convert-an-earlier-installation) | -| `This installation runs another release …` | See [Upgrade an installation](#upgrade-an-installation) | +| `This installation version or historical conversion is not supported …` | Preserve the installation and data; [install separately](#installation-version-policy) | | `generated/ was edited by hand` | Put the change in `config.json`, then `oac apply --discard-edits` | | `config.json has changes that are not applied` | Run `oac apply` | | `Core rejects secrets/core.key …` | Run `oac apply`, which restarts Core with the key's digest | @@ -271,9 +241,9 @@ Core records which key made each public resource write, for the console. Set `core.write_audit_retention` in `config.json` (Go duration, at least `1h`, default `2160h`) to control how long non-creation history is kept; creation ownership is kept for good. Removing keys or resources does not delete these records. See the -[query contract](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/write-audit.md); administrator mutations +[query contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/write-audit.md); administrator mutations have a separate -[audit log](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither +[audit log](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither logs bodies or secrets. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f36ac3592bfe9f77a2172680aa52b14c6f525062/docs/getting-started/operations.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/getting-started/operations.md) diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 0124fe977..c0cf11c74 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -1,61 +1,61 @@ { - "revision": "f36ac3592bfe9f77a2172680aa52b14c6f525062", + "revision": "f9871472eb49e6f8f2d7fa61d4345354588751c7", "sources": { - "docs/getting-started/README.md": "e4ca3bcbfdcbf3832c1d9e0525f98083ca11a4e42148f9f45090bbe1bc0f2b5c", + "docs/getting-started/README.md": "4eb3d21e073b25e2e6ffb8800e13b33032104ba44d8d93ea3e59394549dc7962", "docs/design-principles.md": "71285ff0b9a7796481b9fe42f7c4b71d0377c767157980ca6386792d9ca8951b", "docs/web/architecture.md": "80bdb8053c06c2b2030b193aae17d5434545f9c72af50e9d3df3ade522fe25ed", - "docs/getting-started/install.md": "706a1b05315e25211606bee7cc63923511268721fe7de88d5ec262938156796f", - "docs/configuration.md": "e911757a03fbce4b803a284c8bb40bdb7000ca2f70e27938706dcdcd74bc7f9a", + "docs/getting-started/install.md": "a4f5591793ba37a7200fa69f462b7bad2a7466eebc3aa5bc2514d1ab0b15238e", + "docs/configuration.md": "478b988c06a61f5555f8ec2b5fe4a9379c74e28cea4108734af2e478397905fe", "docs/web/core-connection.md": "46f86520e70bf41079708e2c398655aa9087ad9488c8e661d9a73035d11098c4", "docs/getting-started/quickstart.md": "5effeffcfb343f7939910f0c1758e1492d49e7bd66c409b1819cefe67af592d7", "docs/api/README.md": "592fc2aef474e36eb760be0dd1b2269b6766c0d984f826eaf6f47711ea139387", "contracts/agents-api/execution-tools.md": "e0b61f6c0c236c362186c5f6d0ad69a16dd8a8d9afabe1329a1fe22e5f4a71e9", "docs/api/public-agent-api.md": "18829b366bd2e49fe5beb76dc8a709bc1aa8e35d4fbfd2484a0c52b46cf75417", "contracts/agents-api/environments.md": "89ffc903ee44d00ee87c16950202a3ca4c8345160780dcdd4ed2a71b25b1c2dc", - "docs/getting-started/nodes.md": "aa95d21137c37ae1d4a2c0ac0b8f797738882400430e58bef58b17dbd75ed4cc", - "docs/getting-started/self-hosted.md": "3f1b402cf28bb320ba414eaf36110e9df4d792b81da0456a3c42d469357d8d99", + "docs/getting-started/nodes.md": "c1bc854c4798b9757bbb8cea4474a03442ba6f4c10444d5505733e8b94046038", + "docs/getting-started/self-hosted.md": "55d6b29b1826f5cb4aee193c4ac9011bfb59d20192938962b2572840c42e242b", "apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "docs/web/README.md": "9fce295f6f6ff04acefe338679c1e54723e123dd0027a778202adfa9ab95b6c4", "docs/api/web-management.md": "dc79df33dade6139749752c9ce53d4e126354cfabccada3e7826de4dd20fae62", "contracts/agents-api/runtime-observability-api.md": "cd46e777fe716a7e76374f4655c96dfbad9d8be1bfca6203ed6118ab87874c2b", - "docs/getting-started/operations.md": "438f8e679e6484d8effb6f4216b08c6472d5cbd50cbd05f5fef4d8648ae147a9", - "apps/docs/scripts/guides.json": "3d4bd123cb9246ca6c15055cee9a927b18eec506944c81f9f0cc4c85ee28e620" + "docs/getting-started/operations.md": "3f0980913fb63bc249e95e7cb41224354c750f5f6f4b4cb15007ce149d188984", + "apps/docs/scripts/guides.json": "1f6036e3cccaf5a71cdd9ea790be6f395acdf871c4552c490b6747957a10b174" }, "outputs": { - "content/docs/index.mdx": "75339c35292bb1f3c6b4313d717b82686bee8caa265bb575fcecbd0ba33bb780", + "content/docs/index.mdx": "abf2243197aec9d6510a2804f320ff049f45aebc2991e18fa7682b6088d308e7", "content/docs/index.zh.mdx": "5bdd41893946f1d229eb4cf1599a97f2e38cb9e09722d2a69f62053376ac88a9", - "content/docs/concepts.mdx": "2c2516963cccafed4f66083201a9f84e8d12c4d7f4406ce06179bc26616ea019", + "content/docs/concepts.mdx": "94ab4c7ca0b9dde2dd08786846656b59e6c1e269b62cd1496ccf0b680efa02df", "content/docs/concepts.zh.mdx": "c60644f395dbac5ee607cdb053249e05ec8352d28de97ef1c38d6c1e4443bbb1", - "content/docs/execution-model.mdx": "8dd05250255fdb39a4aee6ffa51a16379fbd19d2761f510e1ad9cc3a193f750c", + "content/docs/execution-model.mdx": "f35d0fc76b4c8527f1b35274294c05d729333b767124c2e4657be294a75ea27c", "content/docs/execution-model.zh.mdx": "9976a9203c6a83d49c2c753e3a8528a369078e87aeb88850654da948ac8c17e1", - "content/docs/install.mdx": "a3ec8393f0b3260fa1e8d6f6c6921f0807c6eed247a92e69f91119c4382c4dc2", + "content/docs/install.mdx": "6b0800941af869cef695024e316e539a97847ddf813665e1f2e04f6f5e87f777", "content/docs/install.zh.mdx": "f207607f0cfceef2012a4e772fc347ef254486327584289fee1c85414dffc907", - "content/docs/configure.mdx": "ec84259c766872eada11c1b0d9d9e3ec19acd8c867c6048cc0890a32b82d65f0", - "content/docs/configure.zh.mdx": "a0c3a1df3e9f75e672e1d0a8c68c82c24ac36a8479d10a951de441960d07927f", - "content/docs/bootstrap-projects-keys.mdx": "fc2da0aa255278281d8a57c664d7d30e5a458add4e58402386e0374f23973da4", + "content/docs/configure.mdx": "fcadee7c570c271299da340be0c82c1220500a33a8e6f4ea511e7ceabf4e7100", + "content/docs/configure.zh.mdx": "1c4b149c332ade6f2eeca52319e3e7ccfaac3213db2fd084d66e2da517b6bbd6", + "content/docs/bootstrap-projects-keys.mdx": "d0f7e3bd226dd5ead429f17e2bbb6335b2ee29f3bbe4e0cb59ce3cc48fedb045", "content/docs/bootstrap-projects-keys.zh.mdx": "509b4dcea4c865d6834bcaef78baa681180d48274931230f958a39e46011c88f", - "content/docs/quickstart.mdx": "4ebc8ac16df66042e816ff51cf6672d8a85f44e119211efba584025666dce845", + "content/docs/quickstart.mdx": "65236a534fe288fa8a9b002f542d448f972259bd006f847ac9762c8d0da4d6e2", "content/docs/quickstart.zh.mdx": "c5f119b100b102918a45763834d0ed7fd436479fb411e16a62a408411dbcb3e1", - "content/docs/public-api.mdx": "12d4fdbe6291c7d74af591369ac1edd2eca2940b93d156612497cc52b933740d", + "content/docs/public-api.mdx": "0fb8676848006aceaabab2833da50f0a1bd581911db026b1cbb124ed0ae58f2d", "content/docs/public-api.zh.mdx": "fb0efb9be2fba3f4a3dc71bf053d2c7eddffbfe010f59fa5a1ae60a3f068545b", - "content/docs/agents-and-tools.mdx": "b8cc50792e579cbf66bcc597685b745d8721266970b75e928d8edee2ce11b08b", + "content/docs/agents-and-tools.mdx": "fb6bd148a0828203ef007e44d2bfec143af7ba9f9a0d3737d8e57a10c1a124fb", "content/docs/agents-and-tools.zh.mdx": "cc8d4e0ef0ea894f068f3bbd143d5ee70c6346bf98f425b1d5fe07ec3931f128", - "content/docs/sessions.mdx": "8dba8c2a94ef5e95fde6f53e7af3cc481e4d3c5db71815372f01ddd1bf729316", + "content/docs/sessions.mdx": "4a198e8c2a8a12ef2df4412d776e33522ca93ff94b83ddcda1047d0239d6a800", "content/docs/sessions.zh.mdx": "b1b1a7f8aacd9a902320f9692173ede24036124f85761cf02f23ef3ef2bcc4c1", - "content/docs/environments-and-files.mdx": "0fce7c8806d532d64eff6319bba7d0a8e44da46f888d224644b3ab5c23428c41", + "content/docs/environments-and-files.mdx": "5a15b298e6dc80324d01fdc2d35e8b3fc5f9fb7be30c895bab5095ccd4d11939", "content/docs/environments-and-files.zh.mdx": "ee64577220a798f023b330c65302c14438f25a34a78c4e36144bfdb4304a3ae5", - "content/docs/hosted-providers.mdx": "244d97e41fbbc3ee9d6905d055454c07f139659a2b39de81941867ddc37d5d20", + "content/docs/hosted-providers.mdx": "d5e1f5affbd43a1d21262a3a3390a62f4813a85b9ab8ad473bc6c44628ca4a6f", "content/docs/hosted-providers.zh.mdx": "d7f6115f052a09dc943f0ed516ff30a27da5847ed24d387d9d1c7e7faa006ab0", - "content/docs/self-hosted-execution.mdx": "34877624962b24c22e8371ebd2e2f83bbf5a0d004c74d5d9b5db57f5133b692c", + "content/docs/self-hosted-execution.mdx": "4c300708415b349799397222f0790acba240563d5cd5cbb54c59fc13b4430d98", "content/docs/self-hosted-execution.zh.mdx": "f11d34ce30d48039376256474bc9509a508a2f6b9906f49b5cf6e396707ae5c0", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", - "content/docs/console.mdx": "cf0e6a1f9dd690ffe74c5d83111bb18df7eebe287848497c01257692ab3edc6a", + "content/docs/console.mdx": "abefa9328aee86eb99357286a7efd90198c026833346c77a94ff4d8af72266b3", "content/docs/console.zh.mdx": "72f953ba46dcc95933773ac0c4dffdc0d0a499be9d9a1cb9467db2659f1fa10d", - "content/docs/admin-api.mdx": "22331a4aa1c720711861d93db7d207868bf815ccc32b900d07b3d48ef077afd9", + "content/docs/admin-api.mdx": "b140c4026926820dedb6a3d94cad29918e5d9a0e1e9093b942e911b96174904d", "content/docs/admin-api.zh.mdx": "07006de30df3080e54c3cd79ed0ea5337dce557aa2f11afea84edb7f92884d38", - "content/docs/observability.mdx": "c5e2963a4dd7abddc60869a3ecd3b6b32a97c2ba1ae28b88b7453136656c1201", + "content/docs/observability.mdx": "08f662d75a56934ca5096be6a0e73b29803f59b39ce1807426719bd5e6629166", "content/docs/observability.zh.mdx": "9574b3412a5754f41ab2d3a16062ac1cad06766be481a8e6bff1b84acbde7e7e", - "content/docs/troubleshooting.mdx": "cd81feacd1236603423a08ba941dd6bf5538da1a982ee73c171dfb49617d2e76", + "content/docs/troubleshooting.mdx": "906cfe5130b49384abdec6b312ff58a31acb093fd26e73e1a8a5a5740a49c4ed", "content/docs/troubleshooting.zh.mdx": "6d8b4668e8c371120a9275daabbebc0d1ecf21a42ba32beeb92853c14b5a459a" } } diff --git a/apps/docs/scripts/generate-guides.mjs b/apps/docs/scripts/generate-guides.mjs index 29cbb97b0..81d195247 100644 --- a/apps/docs/scripts/generate-guides.mjs +++ b/apps/docs/scripts/generate-guides.mjs @@ -8,7 +8,7 @@ const app = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const repo = path.resolve(app, '../..') const guides = JSON.parse(fs.readFileSync(path.join(app, 'scripts/guides.json'), 'utf8')) const routes = new Map(guides.map(g => [g.source, g.slug === 'index' ? '/' : '/' + g.slug])) -const sourceRevision = 'f36ac3592bfe9f77a2172680aa52b14c6f525062' +const sourceRevision = 'f9871472eb49e6f8f2d7fa61d4345354588751c7' const sourceURL = relative => `https://github.com/MiniMax-AI/parsar-core/blob/${sourceRevision}/${relative}` const digest = text => crypto.createHash('sha256').update(text).digest('hex') function mdx(text) { diff --git a/apps/docs/scripts/guides.json b/apps/docs/scripts/guides.json index a32d97136..0384a6a69 100644 --- a/apps/docs/scripts/guides.json +++ b/apps/docs/scripts/guides.json @@ -32,7 +32,7 @@ "source": "docs/configuration.md", "title": "Configuration reference", "description": "The operator config.json, generated files and database-owned runtime settings.", - "zh": "安装配置的唯一编辑入口是 config.json;运行 oac apply 应用变更。generated 目录由安装器生成。运行时后端、节点和默认模型由 Web 管理并保存在 PostgreSQL 中。" + "zh": "安装配置的唯一编辑入口是 config.json;运行 oac apply 应用变更。generated 目录由安装器生成。运行时后端、节点和默认模型由 Web 管理并保存在 PostgreSQL 中。System 的 Sandbox backend 页面可在线调整同一后端的资源与 Runtime 目标;节点独立准备,现有资源保留原 generation,已就绪的旧 generation 仍可承接新 Session。" }, { "slug": "bootstrap-projects-keys", diff --git a/deploy/install/config.schema.json b/deploy/install/config.schema.json index 77290f85a..184ea1caf 100644 --- a/deploy/install/config.schema.json +++ b/deploy/install/config.schema.json @@ -13,7 +13,7 @@ }, "format": { "const": 1, - "description": "Configuration format. Only an upgrade changes it.", + "description": "Configuration format for this release. Fixed after installation.", "x-oac": {"setting": false, "changeable": false} }, "mode": { diff --git a/docs/configuration.md b/docs/configuration.md index 1575d3b3b..a23b90fda 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -5,7 +5,7 @@ Every setting of a Core installation has exactly one home. There are two kinds: | Kind | Examples | Home | Change it with | Takes effect | | --- | --- | --- | --- | --- | | [Process settings](#process-settings-configjson) | Public URL, ports, logging, harnesses, execution concurrency, audit retention, OAuth origins, database pool, Runtime history export | `config.json` in the installation directory (default `~/.oac/core`) | Edit the file, then run `oac apply` | `oac apply` restarts the services that read the changed settings | -| [Runtime settings](#runtime-settings-web) | Sandbox backend and size, nodes, Projects and keys, default models, executor credentials | Core's PostgreSQL database | Web, or the Core API (`/core/v1`) with the Core key | At once, without a restart | +| [Runtime settings](#runtime-settings-web) | Sandbox backend and size, nodes, Projects and keys, default models, executor credentials | Core's PostgreSQL database | Web, or the Core API (`/core/v1`) with the Core key | Saved without a Core restart; node Runtime changes prepare asynchronously | Web's **System** page shows both: the installation's addresses, the process settings read-only under **Startup settings** with the path of `config.json` and the apply @@ -81,7 +81,7 @@ output or in Core's settings snapshot. Model providers are not process settings; | Key | Type | Default | Modes | Change | Restarts | Meaning | | --- | --- | --- | --- | --- | --- | --- | | `$schema` | string | none | all | any time | none | Editor hint that points at the installed copy of this schema. Ignored. | -| `format` | `1` | none | all | fixed | none | Configuration format. Only an upgrade changes it. | +| `format` | `1` | none | all | fixed | none | Configuration format for this release. Fixed after installation. | | `mode` | `"all"` \| `"core-only"` \| `"web-only"` | `"all"` | all | fixed | none | Which services this installation runs. Install flag: `--core-only` or `--web-only`. | | `native_core` | boolean | `false` | `all`, `core-only` | fixed | none | Run Core as a systemd user service instead of a container. Install flag: `--native-core`. | | `public_url` | string or null (canonical origin; HTTP only on loopback) | `null` | all | `oac apply` | core, web | Public origin of Core and Web behind your TLS reverse proxy, such as https://core.example. Nodes, sandboxes and self-hosted executors use it. null means local access only through http://127.0.0.1. Install flag: `--public-url`. | @@ -125,9 +125,9 @@ Core API with the Core key. | Setting | Where in Web | Core API | Notes | | --- | --- | --- | --- | -| Sandbox backend: Docker, microsandbox or E2B | **Nodes** (**Sandbox backend** with E2B): the setup wizard, ending with **Save configuration** | `/core/v1/sandbox/deployment` | One backend per deployment. `install.sh --sandbox` saves the first choice. Changing backend requires explicit reset and a new setup at the resulting generation | -| Sandbox size and Runtime release | **Nodes**: the setup wizard | `/core/v1/sandbox/deployment` | Every sandbox gets the same size. See [Sandbox deployment](#sandbox-deployment) | -| E2B API key and template build | **Nodes**: the setup wizard's **E2B cloud** (the page is then called **Sandbox backend**) | `/core/v1/sandbox/deployment` | The key is write-only and encrypted | +| Sandbox backend: Docker, microsandbox or E2B | **System** → **Sandbox backend**: the setup wizard, ending with **Save configuration** | `/core/v1/sandbox/deployment` | One backend per deployment. `install.sh --sandbox` saves the first choice. Changing backend requires explicit reset and a new setup at the resulting generation | +| Sandbox size and Runtime release | **System** → **Sandbox backend**: **Change resources** | `/core/v1/sandbox/deployment` | New targets use the saved size; retained generations keep their original specification. See [Sandbox deployment](#sandbox-deployment) | +| E2B API key and template build | **System** → **Sandbox backend**: the setup wizard's **E2B cloud** | `/core/v1/sandbox/deployment` | The key is write-only and encrypted | | Nodes and their capacity | **Nodes**: **Add node**, **Edit node**, **Remove node** on a node's page (**Remove** in its list row) | `/core/v1/sandbox/enrollment-tokens`, `/core/v1/sandbox/nodes` | See [Node capacity](#node-capacity) and the [nodes guide](getting-started/nodes.md) | | Projects and API keys | **Projects and keys**: **Create project**, **Rename**, **Issue key**, **Revoke**, **Archive** | `/core/v1/projects` | Keys are shown once; Core stores digests | | Default model per harness | **System**: **Default model** | `/core/v1/harnesses/{harness}/model-provider` | See [Default models](#default-models) | @@ -167,13 +167,21 @@ old key valid until the update succeeds. Initial setup requires a team-owned tem a legacy public-template configuration or an already revoked old key requires reset when Core cannot verify the committed ownership anchor. -Docker and microsandbox size/Runtime edits still require zero held resources and -retire the old nodes and enrollment commands. Add those hosts again after cleaning -up their retired node installations. Node online preparation and the corresponding -Web controls are separate follow-up work. History stays, and existing Sessions never -move between providers. See the +Docker and microsandbox size/Runtime edits advance the target generation online. +They require neither zero held resources nor node retirement or reenrollment. +Version 2 nodes prepare the target independently, while existing allocations and +suspended VMs retain their original generation. A qualified older serving generation +can still accept new Sessions when it has capacity, including while the target is +preparing or has failed. Target rollout and serving readiness are separate facts. +Use **System** → **Sandbox backend** → **Change resources** to edit the target; +**Nodes** owns node management and readiness. + +Current Runtime generation coexistence and ownership-scoped garbage collection +remain supported. They do not upgrade the installed node program or convert an old +installation. History stays, and existing Sessions never move between providers. +See the [nodes guide](getting-started/nodes.md#change-the-sandbox-backend-or-size), [operator reference](../services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) -and the [deployment contract](../contracts/agents-api/sandbox-deployment.md). +and [deployment contract](../contracts/agents-api/sandbox-deployment.md). ### Node capacity @@ -203,7 +211,7 @@ provider is refused with 400 `model_provider_required`. See [model execution contract](../contracts/agents-api/model-execution.md#deployment-defaults). The operator file `AGENTS_API_EXECUTION_OPTIONS_FILE` is retired; see -[Upgrade notes](getting-started/operations.md#upgrade-notes). +[installation version policy](getting-started/operations.md#installation-version-policy). ## Secrets and identity diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index f125aa956..b7bb5dc03 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -10,7 +10,7 @@ applications call Core with. | [Install Core and Web](install.md) | Administrators | Prerequisites, download, the installer and every option, HTTPS and the reverse proxy, a local trial, first sign-in, what is created on disk | | [Nodes](nodes.md) | Administrators | Adding a node with one command, sudo and no-sudo modes, removal, logs and troubleshooting | | [Self-hosted executors](self-hosted.md) | Administrators and application owners | Connecting an application's own machine to a `self_hosted` Session, rotating and revoking its credential | -| [Operations](operations.md) | Administrators | The `oac` command, the Core key, backups, upgrades and troubleshooting | +| [Operations](operations.md) | Administrators | The `oac` command, the Core key, backups, current-release repair and troubleshooting | | [Configuration reference](../configuration.md) | Administrators | Every `config.json` setting and every runtime setting in Web | | [Call the API](quickstart.md) | Application developers | `OPENAI_BASE_URL` and `OPENAI_API_KEY`, running a Session, model providers and `x_agents_core` | | [API reference](../api/README.md) | Developers | The `/v1`, `/core/v1` and `/api/v1` namespaces and their contracts | diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 888d4e2c8..057a10022 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -13,7 +13,8 @@ add nodes. Applications call Core's API with those keys. 6. [Add a node](nodes.md). These pages describe the current source. Every bundle carries the docs that match it -under `docs/`; if you install an older bundle, follow those. +under `docs/`. This private project supports fresh installation and repair of the +same release; historical-version upgrades and conversion are unsupported. ## Prerequisites @@ -486,7 +487,7 @@ at Add node. An existing pre-rename installation at `~/.parsar/core` blocks a new default install. Historical conversion is not supported. Preserve the existing installation and explicitly -choose another `--install-dir`. See [installation version policy](operations.md#convert-an-earlier-installation). +choose another `--install-dir`. See [installation version policy](operations.md#installation-version-policy). ## What the installer creates @@ -525,4 +526,4 @@ Rerunning `./install.sh` from the same bundle repairs an installation: it reload missing images, restores a missing `oac` command, copies node files newly placed in `artifacts/`, applies `config.json` and starts the services. It accepts only `--install-dir`. It refuses a bundle from another release; see -[Upgrades](operations.md#upgrade-an-installation). +[installation version policy](operations.md#installation-version-policy). diff --git a/docs/getting-started/nodes.md b/docs/getting-started/nodes.md index a0d0216e8..368e71047 100644 --- a/docs/getting-started/nodes.md +++ b/docs/getting-started/nodes.md @@ -292,33 +292,10 @@ describes reset and API behavior. Same-team E2B edits have a separate online Cor API path and do not involve node installation. -## Remove a node added before the rename - -A new installer refuses retained state for the same installation under the old -`parsar-node` names. It does not convert that state or remove nodes belonging to -another installation. Remove the node on its old Core's Nodes page, then run the -**previous release's** verified `node-install.pyz --uninstall --installation-id -`, with `sudo` only if that node was installed with sudo. Use -`--force` only when that Core no longer exists. - -If the previous installer is unavailable, inspect and remove only that -installation's resources: - -1. Stop and disable `parsar-node-.service` with `systemctl`, using - `sudo` for a system service or `--user` as the original installing user. -2. Remove its exact unit file or enablement link. System units live in - `/etc/systemd/system/`; user links live in `~/.config/systemd/user/`. Reload the - same systemd manager after removing the unit. -3. After stopping the node, remove its state directory as its owner: - `/var/lib/parsar-node/.parsar/nodes/` as `parsar-node` in sudo - mode, or `~/.parsar/nodes/` as the original user. Do not run - root cleanup through files or links controlled by the service account. -4. In sudo mode, remove only `/etc/parsar-node/.json`. Keep the - account record, the `parsar-node` user and group memberships while any other - installation or retained microsandbox store uses them. -5. For Docker, remove `parsar-node-` only after confirming that - its network has no attached containers. Keep Runtime images, volumes and - retained microsandbox stores until their owners have explicitly retired them. - -Then use the new Core's Add node command. The new account, service and state use -`oac-node` and `~/.oac/nodes`; historical Core Session records remain in Core. +## Historical node installations + +An older node installation is unsupported by the current installer. Retained state +under the old `parsar-node` names is a refusal condition, not an invitation to +convert or delete it. Preserve the old installation, Runtime resources and history; +provision a fresh node separately through **Add node**. The current installer does +not adopt historical resources or remove another installation's state. diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index 5fc9110ea..1a8975074 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -129,7 +129,7 @@ application, then **Revoke** the old key. **Archive** disables all of a Project' and keeps its assets and accepted work. Administrators can inspect and delete retained resources but can't execute with the Core key. -## Data and upgrades +## Data and current-release repair ### Back up @@ -159,7 +159,7 @@ and provider storage rather than registering over existing resources. Never prun Docker volumes or delete native history to make a retry pass. A deleted Session does not prove that all provider resources were reclaimed. -### Upgrade an installation +### Installation version policy In-place version upgrades, downgrades and historical conversions are not supported. Keep existing data and installations intact; install the new release into a new, @@ -171,49 +171,20 @@ and mutating `oac` commands use the same installation lock, including during rep and interrupted apply recovery. If another command owns it, retry after it finishes. Never remove or replace `.oac.lock` to bypass a busy installation. -### Convert an earlier installation - -Historical conversion is not supported. `--convert` refuses without changing the -installation. Preserve its files and data, and reinstall separately. - -### Upgrade notes - -Retired process settings remain rejected. Configure a fresh installation using the -current [configuration reference](../configuration.md); do not copy old generated -files into it. - -#### Node connections at /api/v1 - -Core and its nodes must come from the same distribution; the node installer refuses a -mismatched release. Current releases serve every machine connection at `/api/v1`, and -Web returns 404 there. When the new Core and Web go live, route `/api/v1/*`, including -WebSocket upgrades, to Core instead of Web -([HTTPS and the reverse proxy](install.md#https-and-the-reverse-proxy)); with the old -routing, node enrollment and every Runtime connection fail, for Docker, microsandbox, -E2B and self-hosted executors alike. - -Nodes from releases that used the removed `/core/v1/sandbox/enroll` and -`/core/v1/sandbox/node/*` paths can't connect to a current Core. For a Docker or -microsandbox deployment: - -1. Drain with the previous release while its nodes are connected: enter maintenance, - archive retained hosted Sessions and wait until nothing is retained using that - previous release's maintenance procedure. -2. Upgrade Core and Web, and change the routing as above. -3. Reset a drained selection that lacks a specification, then configure the new - release's Runtime and add nodes again. On each node host, - stop the old node service and move its state directory aside as a backup first. - -A Web-selected Docker or microsandbox deployment saved before deployment specifications -existed has an empty specification after migration. Draining it needs a Core that has -the pre-specification drain mode (pull request #114) but still serves the old node -paths. No such release was published: build a distribution from main commit -`7b66be236a627246c85658722314285e6b39d9b8`, or any commit that contains #114 but not the -move to `/api/v1/sandbox-node`. That Core loads the deployment only to drain; fresh -sandboxes, node configuration reads and enrollment are refused. Back up, replace Core -and Web with that build, drain as in step 1, then upgrade to the current release and -continue with steps 2 and 3. Node IDs change; Session history and persisted Files and -Artifacts remain. E2B deployments from that period are not covered. +Historical installations and removed CLI or machine-protocol variants have no +migration procedure. Preserve their files, database, Runtime resources and Session +history; use a fresh installation and separately provision its nodes. Reinstallation +does not authorize deleting the previous installation's data. Retired process +settings remain rejected; use the current +[configuration reference](../configuration.md). + +### Machine connections + +Core and its nodes must come from the same distribution; the node installer refuses +a mismatched release. Route `/api/v1/*`, including WebSocket upgrades, to Core; +Web returns 404 there. This applies to Docker and microsandbox nodes, E2B and +self-hosted executors. See +[HTTPS and the reverse proxy](install.md#https-and-the-reverse-proxy). ## Troubleshooting @@ -225,8 +196,7 @@ Artifacts remain. E2B deployments from that period are not covered. | `Port N is already in use; select another port` | Free the port, or install with `--core-port`/`--web-port` | | `Installation directory is not empty …` | Use an empty `--install-dir` | | `This installation is configured by …/config.json …` | Flags only seed a new installation: edit `config.json` and run `oac apply` | -| `This installation predates config.json …` | [Convert it](#convert-an-earlier-installation) | -| `This installation runs another release …` | See [Upgrade an installation](#upgrade-an-installation) | +| `This installation version or historical conversion is not supported …` | Preserve the installation and data; [install separately](#installation-version-policy) | | `generated/ was edited by hand` | Put the change in `config.json`, then `oac apply --discard-edits` | | `config.json has changes that are not applied` | Run `oac apply` | | `Core rejects secrets/core.key …` | Run `oac apply`, which restarts Core with the key's digest | diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md index d441f1a3e..004985864 100644 --- a/docs/getting-started/self-hosted.md +++ b/docs/getting-started/self-hosted.md @@ -144,21 +144,14 @@ credentials with `GET` before trying again. The has every rule and error. -## Executors installed before the rename - -An existing `parsar-selfhost-*` container keeps its own image, daemon and -`~/.parsar/self-hosted/` directory. It can keep serving its -Environment after Core is renamed; Core does not upgrade or manage this container. - -The new installer refuses when that exact Environment still has a directory under -`~/.parsar/self-hosted`, including when a different `--install-dir` is supplied. -Executors for other Environments are unaffected. To replace it, inspect the old -installation's `started.json` to identify its container, stop and remove that -exact container, and preserve any history you need before removing the old -Environment's installation directory. Then rerun the new verified installer. It -starts `oac-selfhost` with state under `~/.oac/self-hosted/`; -it does not reuse the old executor's volumes or native history. - -Credential replacement for executors created by the new installer still updates -and restarts the same owned container. The legacy refusal does not authorize -adopting an old container to replace its credential. +## Historical executor installations + +The current installer does not upgrade or adopt an executor from an older release. +It refuses when the same Environment retains state under the old +`~/.parsar/self-hosted` directory, including when another `--install-dir` is supplied. +Preserve the old container, files and native history; provision a fresh self-hosted +Session and connect its executor separately. There is no supported cross-version +continuation or automatic data deletion. + +Credential replacement for executors created by the current installer updates and +restarts the same owned container. It does not authorize adopting an older executor. diff --git a/services/agents-api/README.md b/services/agents-api/README.md index 5aae79371..9b53f6ea1 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -429,41 +429,18 @@ include real MiniMax execution through built API/daemon/Codex and Claude SDK, function success/error, cancellation and native continuation. Controlled fixtures remain useful but do not replace real-provider acceptance for execution changes. -## Upgrading archived Item history - -Migration 15 retires private journal-to-Item backfilling. It preserves existing -public Items and source journals, and refuses to apply if any Turn still has -`items_indexed=false`. Do not set this marker manually or replay native execution. - -For installations with pre-Items history: - -1. Back up the execution database and stop new execution/submission. Drain active - Turns before switching versions. Product storage is independent. -2. Run the previous service release `906069e` against the execution database with - its worker disabled (omit `AGENTS_API_DAEMON_WS_URL`). Using each tenant's API - credential, list every Session and request its Items once. The old service - prepares the complete index under the Session lock, even with `limit=1`. -3. Verify `SELECT count(*) FROM turns WHERE NOT items_indexed` returns zero. - A failed preparation must be resolved before upgrade; legacy journals cannot - recover fields they never recorded. Stop the previous service. -4. Upgrade the daemon first so it advertises `tool_observations`, then apply the - migrations and start the new service. No old/new service overlap is supported - across this migration. Devices without this capability are not dispatched. - -For step 2, use the pinned Python SDK and the usual private endpoint/key settings, -repeating with each existing Project and an authorized API key: - -```python -from openai import OpenAI - -client = OpenAI() # OPENAI_BASE_URL and OPENAI_API_KEY -for session in client.beta.agents.sessions.list(): - client.beta.agents.sessions.items.list(session.id, limit=1) -``` - -Fresh installations and already indexed history need no backfill. Recovery reads -continue to use Session/Turn/Items; this procedure is an upgrade operation, not -an official SSE replay mechanism. +## Historical Item storage + +Migration 15 records the retirement of private journal-to-Item backfilling. It +preserves public Items and source journals and refuses unindexed historical Turns. +This is historical schema evidence, not a supported upgrade procedure. Do not set +index markers manually, replay native execution, or run an older service to convert +a database for the current release. + +Historical installations are unsupported. Preserve their database and execution +history, and install the current release separately. Fresh database initialization +continues through the ordinary migration runner. Current recovery reads use +Session/Turn/Items; they do not provide SSE replay. ## User-managed Runtime enrollment From 5f43a1ab8cc7ad7a9a539453b7a0f57ebdd0460e Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 15:01:43 +0000 Subject: [PATCH 09/78] Retain Session executors behind a shared Harness contract --- CONTRIBUTING.md | 375 +++++++++--------- README.md | 1 + .../internal/agent/claudesdk/cancellation.go | 35 +- .../agent/claudesdk/cancellation_test.go | 17 +- .../agent/claudesdk/commands_session_test.go | 14 +- .../internal/agent/claudesdk/executor.go | 314 +++++++++++++++ .../agent/claudesdk/executor_fixture_test.go | 53 +++ .../claudesdk/executor_live_linux_test.go | 199 ++++++++++ .../internal/agent/claudesdk/executor_test.go | 285 +++++++++++++ .../internal/agent/claudesdk/executor_turn.go | 215 ++++++++++ .../internal/agent/claudesdk/functions.go | 9 +- .../agent/claudesdk/functions_test.go | 4 +- .../internal/agent/claudesdk/preparation.go | 193 +++------ .../claudesdk/preparation_fixture_test.go | 16 +- .../agent/claudesdk/preparation_test.go | 10 +- .../internal/agent/claudesdk/readiness.go | 2 +- .../agent/claudesdk/readiness_test.go | 6 +- .../internal/agent/claudesdk/runtime_cache.go | 52 +++ .../internal/agent/claudesdk/session.go | 261 ++---------- .../internal/agent/claudesdk/session_test.go | 15 +- .../internal/agent/claudesdk/steering.go | 12 +- .../internal/agent/claudesdk/steering_test.go | 13 +- .../agent/claudesdk/workspace_directory.go | 3 + .../claudesdk/workspace_directory_test.go | 12 +- .../agent/claudesdk/workspace_launch_test.go | 7 +- .../agent/claudesdk/workspace_read.go | 3 + .../agent/claudesdk/workspace_read_test.go | 14 +- .../claudesdk/workspace_structured_test.go | 2 +- .../internal/agent/codex/executor.go | 156 ++++++++ .../agent/codex/executor_native_test.go | 164 ++++++++ .../internal/agent/codex/executor_test.go | 187 +++++++++ .../internal/agent/codex/executor_turn.go | 163 ++++++++ .../internal/agent/codex/function_receipts.go | 8 + .../internal/agent/codex/preparation.go | 19 +- .../agent/codex/preparation_helpers_test.go | 34 ++ .../agent/codex/preparation_router_test.go | 13 +- .../internal/agent/codex/prepared.go | 4 +- .../parsar-daemon/internal/agent/codex/rpc.go | 39 +- .../internal/agent/codex/server_requests.go | 16 + .../internal/agent/codex/session.go | 30 +- .../internal/agent/codex/session_cancel.go | 3 + .../internal/agent/codex/session_run.go | 41 +- .../internal/agent/codex/session_steering.go | 6 +- .../internal/agent/codex/session_usage.go | 2 +- apps/parsar-daemon/internal/agent/executor.go | 61 +++ .../internal/agent/mcode/connection.go | 146 +++++++ .../agent/mcode/environment_mcp_test.go | 10 +- .../internal/agent/mcode/execution.go | 2 +- .../internal/agent/mcode/executor.go | 173 ++++++++ .../agent/mcode/executor_native_test.go | 176 ++++++++ .../internal/agent/mcode/executor_test.go | 243 ++++++++++++ .../internal/agent/mcode/executor_turn.go | 153 +++++++ .../internal/agent/mcode/preparation.go | 146 ++----- .../internal/agent/mcode/preparation_test.go | 2 +- .../internal/agent/mcode/session.go | 147 +++---- .../internal/agent/mcode/session_test.go | 34 +- .../internal/agent/mcode/steering.go | 37 +- .../internal/agent/mcode/subagent_cancel.go | 27 +- .../internal/agent/mcode/subagents.go | 8 +- .../internal/agent/mcode/subagents_test.go | 35 ++ apps/parsar-daemon/internal/agent/registry.go | 3 + .../internal/agent/registry_test.go | 21 + .../internal/cli/agent_registration.go | 6 + apps/parsar-daemon/internal/cli/authoring.go | 3 + .../internal/cli/capability_downloads.go | 62 ++- .../internal/cli/capability_downloads_test.go | 21 + apps/parsar-daemon/internal/cli/claude_sdk.go | 3 + .../internal/cli/claude_sdk_test.go | 3 + apps/parsar-daemon/internal/cli/connect.go | 9 +- .../internal/cli/connect_cleanup.go | 24 ++ .../internal/cli/connect_cleanup_test.go | 196 +++++++++ .../internal/cli/connect_suspend.go | 11 +- .../internal/cli/skill_upload.go | 25 +- .../internal/dispatch/executor.go | 373 +++++++++++++++++ .../internal/dispatch/executor_test.go | 312 +++++++++++++++ .../internal/dispatch/local_directory_test.go | 2 +- .../internal/dispatch/mcp_http_test.go | 2 +- .../internal/dispatch/preparation.go | 9 + .../dispatch/preparation_cancel_test.go | 34 +- .../internal/dispatch/preparation_cleanup.go | 3 + .../dispatch/preparation_cleanup_test.go | 97 +---- .../preparation_executor_fixture_test.go | 133 +++++++ .../internal/dispatch/preparation_start.go | 159 +++----- .../internal/dispatch/preparation_test.go | 113 ++---- .../internal/dispatch/prepared_handoff.go | 144 +++++-- .../dispatch/prepared_handoff_test.go | 10 +- .../parsar-daemon/internal/dispatch/router.go | 2 + .../internal/dispatch/shutdown.go | 7 + .../internal/dispatch/steering.go | 4 +- .../internal/dispatch/suspend.go | 15 + .../dispatch/workspace_directory_test.go | 2 +- .../internal/dispatch/workspace_read.go | 3 + .../internal/dispatch/workspace_write.go | 8 +- .../internal/localworkspace/binding.go | 2 +- .../internal/localworkspace/binding_test.go | 8 + .../parsar-daemon/testdata/onboarding/main.go | 87 +++- contracts/agents-api/harness-onboarding.md | 228 +++++++---- contracts/agents-api/harnesses.md | 25 +- contracts/agents-api/message-input.md | 2 +- internal/agentdaemon/proto/envelope_test.go | 4 +- internal/agentdaemon/proto/preparation.go | 16 +- internal/agentdaemon/proto/version.go | 2 +- packages/claude-sdk-adapter/src/adapter.ts | 107 +++-- .../src/executor_protocol.ts | 129 ++++++ packages/claude-sdk-adapter/src/inputs.ts | 5 + packages/claude-sdk-adapter/src/main.ts | 12 +- packages/claude-sdk-adapter/src/request.ts | 9 +- .../claude-sdk-adapter/src/runtime_check.ts | 2 +- packages/claude-sdk-adapter/src/subagents.ts | 5 + packages/claude-sdk-adapter/src/usage.ts | 11 + .../tests/executor.test.mjs | 143 +++++++ .../claude-sdk-adapter/tests/usage.test.mjs | 16 +- scripts/check-claude-sdk-runtime.mjs | 4 +- .../agents-api/internal/execution/delivery.go | 54 +++ .../execution/directory_preparation.go | 2 +- .../internal/execution/dispatcher.go | 10 +- .../execution/environment_admission.go | 6 +- .../execution/executor_observation.go | 41 ++ .../execution/executor_preparation.go | 63 +++ .../internal/execution/notifications.go | 50 +++ .../internal/execution/notifications_test.go | 50 +++ .../internal/execution/preparation.go | 42 +- .../internal/execution/prepared_dispatch.go | 7 +- .../agents-api/internal/execution/request.go | 2 +- .../agents-api/internal/execution/support.go | 3 + .../agents-api/internal/execution/worker.go | 119 ++++-- .../internal/execution/worker_schedule.go | 6 +- .../internal/execution/worker_wakeup.go | 25 ++ .../internal/execution/worker_wakeup_test.go | 35 ++ .../internal/store/claude_execution_test.go | 4 +- .../internal/store/claude_mcp_test.go | 6 +- .../internal/store/command_output_test.go | 2 +- .../internal/store/dispatch_test.go | 29 +- .../store/environment_expiry_dispatch_test.go | 6 +- .../store/environment_worker_helpers_test.go | 5 + .../store/environment_worker_scan_test.go | 4 +- .../internal/store/environment_worker_test.go | 6 +- .../internal/store/execution_events_test.go | 4 +- .../internal/store/execution_messages_test.go | 6 +- .../internal/store/execution_tools_test.go | 2 +- .../internal/store/executor_fixture_test.go | 63 +++ .../internal/store/executor_recovery_test.go | 58 +++ .../internal/store/function_execution_test.go | 10 +- .../store/function_input_execution_test.go | 2 +- .../internal/store/function_worker_test.go | 10 +- .../internal/store/harness_onboarding_test.go | 29 +- .../internal/store/public_execution_test.go | 6 +- .../store/session_deletion_execution_test.go | 8 +- .../internal/store/steering_receipts_test.go | 2 +- .../store/structured_output_dispatch_test.go | 2 +- .../internal/store/subagent_dispatch_test.go | 2 +- .../internal/store/worker_input_race_test.go | 2 +- .../internal/store/worker_lease_loss_test.go | 4 +- .../internal/store/worker_wakeup_test.go | 141 +++++++ .../agents-api/tests/official_user_runtime.py | 36 +- 155 files changed, 6522 insertions(+), 1505 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/executor.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/executor_fixture_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/executor_live_linux_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/executor_test.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/runtime_cache.go create mode 100644 apps/parsar-daemon/internal/agent/codex/executor.go create mode 100644 apps/parsar-daemon/internal/agent/codex/executor_native_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/executor_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/executor_turn.go create mode 100644 apps/parsar-daemon/internal/agent/executor.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/connection.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/executor.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/executor_native_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/executor_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/executor_turn.go create mode 100644 apps/parsar-daemon/internal/cli/connect_cleanup.go create mode 100644 apps/parsar-daemon/internal/cli/connect_cleanup_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/executor.go create mode 100644 apps/parsar-daemon/internal/dispatch/executor_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/preparation_executor_fixture_test.go create mode 100644 packages/claude-sdk-adapter/src/executor_protocol.ts create mode 100644 packages/claude-sdk-adapter/tests/executor.test.mjs create mode 100644 services/agents-api/internal/execution/executor_observation.go create mode 100644 services/agents-api/internal/execution/executor_preparation.go create mode 100644 services/agents-api/internal/execution/notifications.go create mode 100644 services/agents-api/internal/execution/notifications_test.go create mode 100644 services/agents-api/internal/execution/worker_wakeup.go create mode 100644 services/agents-api/internal/execution/worker_wakeup_test.go create mode 100644 services/agents-api/internal/store/executor_fixture_test.go create mode 100644 services/agents-api/internal/store/executor_recovery_test.go create mode 100644 services/agents-api/internal/store/worker_wakeup_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 80420dc72..769981bcf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -473,7 +473,7 @@ forwarding. The explicit daemon-executor decision supersedes the previous native executor interoperability requirement. The superseded execution route is removed; retain reusable filesystem helpers, necessary regressions and historical evidence without a compatibility layer. -The private daemon wire protocol is 0.5.0. Initial, prepared and active input use +The private daemon wire protocol is 0.6.0. Initial, prepared and active input use the same ordered MessageInput contract, replacing scalar prompts and attachments. User-message boundaries and text/image order remain intact through Core and the Runtime wire; adapters own native conversion and receipt aggregation. Text-only @@ -485,8 +485,8 @@ input list and uses blank-line separators between messages; this does not preser independent native user-message boundaries. No old wire fallback is maintained. Deploy Core and daemon together; the existing major/minor WebSocket check rejects older major/minor peers before dispatch rather than ignoring removed fields. -The independently packaged Claude bridge uses protocol 2 for ordered input; -readiness rejects packages reporting the old string-input protocol. +The independently packaged Claude bridge uses protocol 3 for a prepared Executor +and separately identified Turns; readiness rejects other protocol versions. Image-bearing messages require a qualified profile/placement before persistence and image support from the selected Runtime before native delivery. These checks apply to that operation only; ordinary text retains offline queueing. Initial, @@ -1032,7 +1032,7 @@ a chain of old writable disks across suspension cycles. No Kubernetes, distribut scheduler or snapshot replication belongs in this V1 profile. Queued work and live Environment file access request wake. History and published -artifact reads do not. Planned suspension uses private daemon wire 0.5.0 with an +artifact reads do not. Planned suspension uses private daemon wire 0.6.0 with an Environment and suspension token; a PID/start-time fenced local control signal wakes the parked daemon, which reauthenticates before admitting new work. A transient disconnect before confirmation retries the same armed suspension with @@ -1561,103 +1561,106 @@ reconciles old connection observations before admitting new ones. A connection o heartbeat does not prove native readiness or process quiescence. Failed or stale observations cannot establish a current connection. -Preparation resolves the immutable local binding and holds the existing native -resource without creating model work. Start transfers that resource once; failed -start and abandoned preparation retain the established cleanup rules. Strict resume -uses the bound native history and never falls back to a new Session. - -Every executable preparation must implement `PreparedCancellation`; read-only -preparations may implement only `Prepared`. The Router rejects and closes an -executable preparation before `ready` when that contract is missing. Codex fences -future Start under the transfer lock; unused resources use preparation teardown, -while transferred resources use the existing Session cancellation path inside the -adapter. `Close` remains inert after transfer. -`CancellationOutcome` exposes observed content, Usage and verified native identity; -an unstarted resource has no measured Usage or observed resume identity. This is -best-effort cancellation and an observed snapshot, not immutable final output, -notification drain, caller-deadline compliance or remote process quiescence. -From Start admission onward, the exact `PreparedCancellation` object is the sole -release target: a late Session never receives fallback `Cancel`, and the Router -never falls back to preparation `Close` or owner-context cancellation. Successful -`Cancel` means local cleanup is complete and no more output writes can occur. A -failed or timed-out call returns without waiting for output, retains execution -ownership, and permits only a later explicit serialized retry on that same object. -Before publication, failed cleanup also retains the preparation slot. Successful -publication permanently transfers resource tracking to the Run; subsequent release -does not restore preparation ownership or make its old handle cancel that Run. -Forwarded permission and user-choice observations from that cancelled handoff do -not register actionable interactions. Codex prepared cancellation also waits for -the transferred Session's local cleanup, which can finish after output closes. -Codex cancellation has one continuing native owner: caller deadlines bound only -their wait, leaving child observation and cleanup alive for an explicit retry. -Only observed child terminal facts can settle the child; actual observation -failures remain failures. Native process-exit confirmation remains retryable. -One prepared-output consumer starts before `Prepared.Start`, so native output beyond -the 64-frame channel capacity cannot deadlock Start. It retains the first terminal -frame, drains later output, and forwards accepted frames before the observed cancellation -outcome receipt. Missing capability, failed cancellation or failed forwarding cannot -produce an applied receipt. An unused resource may supply an empty observed outcome; -the Router never fabricates one. - -The returned Session remains private until the `started` status send succeeds. The -handoff has one permanent release claim, one current native attempt and one -success-only settlement. Function results, permission decisions, user-choice -decisions and steering admitted before the claim hold the same operation barrier -through native submission, replay bookkeeping and receipt delivery. Natural -completion waits for Start publication before cancellation; an abort wakes that -same attempt and may fence a concurrent Start. The initial started-status send is -bounded by the preparation deadline, which is rechecked at publication commit. -Delayed expiry callbacks cannot cancel a successfully published handoff. The successful attempt waits for Start and the sole output -consumer, then forwards the retained terminal frame and removes the Run. Internal -paths join the current attempt; only an explicit cancel, Release, expiry, device -shutdown or later Router Shutdown may retry a failed attempt. Workspace reads require -a published, open Session but keep their independent bounded lifecycle. - -Receipt settlement waits at most ten seconds, with a separate five-second send -budget and the existing gateway settlement deadline. A timeout does not free the -resource or stop tracking late cleanup. Shutdown cancels receipt waits while owned -Start/cleanup work remains tracked. This ordering covers cancellation received while -Start is pending; ordinary post-transfer cancellation, complete native output and -remote process exit retain their separate limitations. - -The private daemon preparation controls reuse execution configuration but reject -input, RunID, Conversation, attachments and product authoring. The local profile -requires an exact Environment binding, stable state key, strict resume and completion -release. Its separate capability is registered through an execution-only factory -and preserved through the product registry wrapper and heartbeat mapping. Native -details remain inside the adapter; this private profile does not narrow upstream. - -Preparation request IDs correlate only control responses. The daemon returns a -fresh opaque handle before slow work; Start supplies that handle and the actual -RunID/prompt. Handles belong to one daemon connection. Gateway preparation -subscriptions do not register Runs. Per-handle revisions order asynchronous status -snapshots; reject responses describe control errors without inventing run events. -At most four native preparations may be preparing, ready, starting or closing. -Start admission expires five minutes after acceptance; retries do not extend it. -Failed cleanup retains the native resource and its capacity until Close succeeds; -terminal handles with retained resources cannot be pruned or started again. -At most 64 request records are retained; retired request IDs may allocate a fresh -handle, while old handles cannot consume replacements. This is not durable -exactly-once preparation or cross-connection recovery. - -Preparation and Start execute outside the receive loop and router lock, with -tracked lifetime work. Start reserves the real RunID and fixed cancellation target -before native work; cancellation in that phase uses the adapter contract across -the transfer. A late result cannot resurrect released ownership. Shutdown claims -or retries every prepared release under the lock before closing its cancellation -signal. A failed native attempt returns Shutdown without waiting on an output -consumer that may still be blocked; a later Shutdown retries the same target. -Successful publication stops the preparation deadline and uses the same prepared -output consumer and completion release; -later preparation Release cannot cancel that Run. Released/expired status makes -the handle unusable; asynchronous native cleanup still counts toward capacity and -does not promise immediate OS quiescence. Release retries retained cleanup. -Concurrent Shutdown calls join one tracked attempt within their caller deadlines; -a later call retries failed preparation cleanup and reports any remaining error. -A caller timeout does not discard ownership or repeat in-flight cleanup. These -records remain connection-local, not a persistent remote retirement fence. -The public idle-text path uses this -admission/start wiring; complete Environment lifecycle remains required work. +A Session owns one reusable Executor in its connected Runtime. A Turn owns one +input execution, its output stream and its cancellation. `agent.ExecutorFactory` +prepares the fixed configuration; `Executor.StartTurn` creates a new `agent.Turn` +without replacing healthy native resources. Normal completion settles only the +Turn. `Executor.Close` releases native resources on idle expiry, environment +shutdown or confirmed invalidation. Core does not keep a second Executor cache. +The same lifecycle applies after managed or user-managed environments connect, +and to the qualified no-environment profiles. Resource management owns machine +selection, allocation and Environment creation/reclamation. Closing an Executor +does not release the Environment allocation or delete its workspace. Environment +reclamation explicitly coordinates with Runtime execution. Connection, installed +capability snapshot, Session Executor and Turn retain separate lifetimes. + +The Runtime binds its Executor record to Session, Environment, connection and +immutable execution configuration. Resume identity and prior-Turn recovery flags +are continuity assertions, not configuration changes. A supplied native identity +must match the retained owner; exact-history recovery never starts a new root +when existing history is required. A configuration conflict is an error, not a +hot switch. Lost connections retire their owners and handles. Old timers, output +and cancellation cannot affect replacements. + +Each Turn receives a fresh wrapper, output channel and receipt state. Optional +steering, functions, permissions and user-choice interfaces belong to that fixed +Turn. Native callbacks capture the originating Turn before asynchronous work; +late events cannot be assigned to whichever Turn happens to be active. Native +processes, query/transport connections, fixed capability configuration and native +Session identity belong to the Executor. Do not reset completed `sync.Once` +values or repurpose an old Turn object. + +`StartTurn` returning nil guarantees that no native input was submitted and the +output channel was not retained. The Runtime then closes that channel. Once input +may have been submitted, return a non-nil Turn even with an error: the Turn owns +exactly-once output closure and remains tracked until settlement. Unknown input +is never replayed. A definite `executor_unavailable` Start rejection permits one +common recovery attempt only after the previous Executor has been closed and no +input was submitted. Recheck the same physical peer and current authorization. + +`Turn.Cancel` targets only that Turn and does not close a healthy Executor. +`AwaitSettlement` applies after both natural completion and cancellation. Success +means output can no longer be written and the Turn's native events, input, +functions, interactions and child work have settled. `Reusable=true` additionally +confirms that the native owner can accept the next Turn. `Reusable=false` requires +a reason and subsequent confirmed Executor close. An error means settlement is +unconfirmed; it cannot free ownership or capacity. Caller deadlines stop waiting, +not tracked cleanup. Retry the same cleanup target serially. Failed cleanup +blocks replacement and retains its resource slot. + +One output consumer starts before native Start, drains the bounded 64-frame +channel, and retains the terminal observation until Start publication, Turn +settlement and admitted operation receipts finish. Natural Done never calls +Cancel. Input, function and interaction admission close before settlement, and +operations already admitted hold their barrier through native receipts and +outbound acknowledgement. Only then forward Done or an applied cancellation +receipt. Preserve the ten-second settlement wait and separate five-second receipt +send budget; timeout is not proof of quiescence. The observed cancellation outcome +retains native identity, Usage and output without fabricating missing evidence. + +Private preparation controls reserve a per-Turn admission, not a new Executor. +They carry an explicit Session identity and immutable configuration without model +input or Run identity. A fresh request returns a connection-local admission handle +and the owning Executor ID. Start supplies both identities and its actual Run ID +and ordered MessageInput. Per-admission revisions order status observations; +rejections describe control errors without inventing Run events. A reused healthy +Executor returns ready without native preparation. An admission release abandons +that admission; it does not close the Session's healthy idle Executor or cancel a +later Turn. Cancellation uses the exact Run identity. + +Preparations and Start execute outside the receive loop and Router lock. Admission +expires after five minutes; retries do not extend that deadline. Bound active +preparation and execution separately from idle retained resources, and count +closing or uncertain resources until cleanup succeeds. At most 64 admission +records are retained; old handles never consume replacement admissions. Idle expiry +is a Runtime resource policy, not Core active-Turn concurrency. Shutdown tracks and +closes active and idle Executors, retains failed close targets, and allows a later +serialized retry. Ordinary disconnection closes the failed transport and keeps +the exact Router until shutdown succeeds. A wait timeout or failed cleanup cannot +authorize reconnect; process shutdown also keeps waiting rather than silently +discarding owned native resources. These records are connection-local, not durable +input replay. + +Read-only workspace preparations remain separate bounded filesystem operations; +they cannot start model work. Workspace operations retain exact binding and +settlement rules across Turn boundaries and Executor closure. + +Successful input commits send a coalesced hint to the existing Worker scheduler. +The scheduler keeps lease, capacity, cursor fairness and per-Session ownership +checks; a hint does not admit work itself. If capacity is occupied, preserve one +rescan for completion without turning failed preparation into a busy retry loop. +HTTP readiness waits and active input delivery subscribe before reading relevant +state and wake after committed promotion or input. They recheck storage after each +hint. Polling remains the fallback for external writers, expiry and lost hints; +notifications contain no execution authority and no durable input data. + +Core readiness, Start acknowledgement and input-to-first-text logs use a single +process monotonic clock. Start acknowledgement confirms adapter ownership, not +model input consumption. +Runtime logs identify Executor creation, reuse, idle and close independently of +Turn completion. Do not call these durations model-only latency or subtract clocks +from different machines. Native process creation and same-owner successive Turns, +real provider results and same-condition timings must substantiate reuse claims. The Dispatcher prepares pending Environment input only on its exact enrolled or managed device. Preserve the same physical peer and preparation handle through @@ -2579,31 +2582,28 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti require native steering receipts. Commit terminal outcome and native Session ID together under the admission lock; unapplied messages prevent successful completion. Resolve credentials separately from the immutable non-secret snapshot. -- Internal execution requires the advertised `durable_turns` engine capability, - strict resume, and optional cancellation receipts - and `release_on_completion` support. Reject unadvertised peers before claiming; - failed strict resumes must not fall back to a new native thread. Release the native writer before forwarding - completion, so the next Turn can resume its durable native ID. For - release_on_completion Runs, close new steering admission and finish all - existing steering receipt sends before releasing the executor and forwarding - Done. Cached input identities and conflicts remain readable while completing; +- Internal execution requires advertised durable Turns, strict resume, preparation, + applied input receipts and the qualified operation capabilities. Reject + unadvertised peers before claiming; failed strict resume cannot start unrelated + history. A completed Turn closes steering admission, settles existing native + operations and receipt sends, and closes its output before the retained Executor + can accept another Turn. Normal completion does not cancel the Executor. + Cached input identities and conflicts remain readable while completing; queue/write success is not consumption. The receipt worker stays busy through its send, and router shutdown cancels its native and transport waits. - `durable_input_receipts` is required before execution binding/claiming. The - per-input `durable_receipt` opt-in requires release-on-completion and a phased - adapter. Its ten-second transport timer stops only after a complete native write; - a separate `written` acknowledgement stops the API's thirty-second delivery timer. - Neither that phase nor legacy `in_flight` advances the input cursor. Await final - native acceptance/consumption under the Run lifetime without automatic redelivery. - Receipt sends retain a separate five-second shutdown-aware context, and Done - retains a fifteen-second final settlement bound. Once cancellation is sent, its - receipt owns the terminal outcome even if an input becomes unknown first. - Calls without the opt-in retain their existing response deadlines. Existing product - requests retain their default idle-process policy. Native history still requires - the device's persisted engine files; IDs alone cannot restore deleted history. - Cancellation receipts carry the stopped engine's continuity snapshot when no - Done is emitted. Preserve separately reported usage on failure; do not add the - same counters again when Done also includes them. + The per-input `durable_receipt` opt-in requires a phased adapter. Its ten-second + transport timer stops only after a complete native write; a separate `written` + acknowledgement stops the API's thirty-second delivery timer. Neither phase + advances the input cursor. Await final native acceptance/consumption under the + Turn lifetime without automatic redelivery. Receipt sends retain a separate + five-second shutdown-aware context, and Done retains a fifteen-second final + settlement bound. Once cancellation is sent, its receipt owns the terminal + outcome even if an input becomes unknown first. Calls without the opt-in retain + their existing response deadlines. Native history still requires the device's + persisted engine files; IDs alone cannot restore deleted history. Cancellation + receipts carry the stopped Turn's confirmed continuity snapshot when no Done is + emitted. Preserve separately reported usage on failure; do not add the same + counters again when Done also includes them. Usage frames carry cumulative snapshots for the current execution, not deltas. Adapters publish observed snapshots promptly through the same ordered stream; waiting for Done unnecessarily loses known measurements if the Runtime stops. @@ -2983,14 +2983,14 @@ Owned output pipes remain readable after the leader exits. Consumers must drain stdout and stderr before calling `Wait`, which joins the cached process result and closes the readers. `Done` reports leader reaping and group cleanup signals; it is not a native execution receipt or proof of persisted history. SDK adapters -must close their query, await their native child and drain observations before -publishing completion. Process groups are lifecycle supervision, not OS isolation +must settle each Turn and drain its observations before publishing completion. +Executor close additionally closes the query and awaits the native child. Process groups are lifecycle supervision, not OS isolation or containment of descendants that deliberately leave the group. ### Harness qualification and onboarding Codex, Claude and future harnesses have equal architectural status. The common -Runtime wire protocol and Factory/Session/Prepared interfaces own lifecycle, +Runtime wire protocol and Executor/Turn interfaces own lifecycle, input receipts, cancellation, recovery and resource access; each native adapter retains its implementation and model/tool loop. A new engine supplies an adapter, a qualified profile in `services/agents-api/internal/engine`, registration and @@ -3038,9 +3038,12 @@ workspace, functions or MCP. Enabled Subagents use the separately qualified common observation path below. Native configuration disables file/shell authority and external capability discovery; the child receives a private Session home and a restricted -environment. Active-input application requires a native ACP receipt, cancellation -settles the process and output, and continuation requires the exact owned native -history. Do not infer history IDs or qualify hosted execution from this text +environment. Active-input application requires a native ACP receipt. Normal +Turns retain one ACP connection and native Session. Cancellation requires native +root and child completion evidence before reuse. Without a qualified root-state +reader, the ACP cancellation response is insufficient: stop the invalid native +process and wait for its exit before settling the Turn as non-reusable. Common +Runtime recovery then loads the exact owned native history in a new Executor. Do not infer history IDs or qualify hosted execution from this text profile. See [deployment and acceptance](services/agents-api/deploy/mcode/README.md). The MiniMax workspace profile builds one CLI from the fixed upstream source and @@ -3190,11 +3193,11 @@ qualification limits are recorded in [the coverage note](contracts/agents-api/st ### Claude SDK adapter foundation `packages/claude-sdk-adapter` privately owns the pinned official TypeScript SDK -and native message translation. The Go `claudesdk.NewFactory` uses the shared +and native message translation. The Go `claudesdk.NewExecutorFactory` uses the shared owned process runner and emits the existing daemon delta/error/Done frames. -The SDK owns the model loop. Its narrow stdio protocol carries a start request, -text deltas, function calls/results/receipts, active text input/receipts, usage -snapshots and one terminal result/error; native translation stays inside the adapter. +The SDK owns the model loop. Its narrow stdio protocol carries Executor preparation and identified Turn starts, +text deltas, function calls/results/receipts, active input/receipts, usage snapshots +and terminal result/error plus settlement; native translation stays inside the adapter. With `observe_messages`, it also emits the existing neutral `output_message` start/completion snapshots and tags deltas with the native Messages API message ID, not the SDK event UUID. Text blocks in one native message share that identity. @@ -3202,7 +3205,8 @@ The SDK's per-block assistant snapshots replace draft block text; only native `message_stop` completes the message, without replaying its text as another delta. Thinking/tool-only messages produce no text Items; interrupted messages retain their streamed partial text. No phase is inferred from the final result. -SDK/native child release and output draining precede daemon completion. +Turn-owned native work and output draining precede reuse. Executor close releases +the SDK Query and native process. `claudesdk.Config.Workspace` is a private, trusted operator binding for one qualified placement. It enables native Bash/Read/Edit and declared host functions @@ -3237,50 +3241,39 @@ public preparation, shared placement quotas, command Items and Files ownership. `TestLiveClaudeWorkspaceFactory` is explicit real-provider acceptance inside a qualified placement, including effects, cancellation and same-history continuation. -The private workspace bridge also accepts `prepare` without a prompt. It freezes +The private bridge accepts `executor_prepare` without model input. It freezes validated configuration and resume identity, checks required history, and retains -one native process through the pinned SDK's `startup`/`query` API. Preparation -requires initialization and acknowledgement of the required hooks while the input -iterator remains empty. Its `prepared` receipt permits one later `start` containing -only the initial prompt; configuration replacement, premature or duplicate start -is rejected. Native Session identity and actual tool inventory are still checked -at execution initialization before `input_ready`. Existing direct execution uses -the same observation and completion path. - -Unused EOF, owner signals, invalid control input and native exit release owned -resources before a terminal event. The private `workspace_prepare` runtime feature -identifies this bridge contract only. It does not register daemon preparation, -enable public admission, or project public Environment readiness. Preparation may -write native runtime metadata outside the workspace and perform startup traffic; -it does not prove provider authentication, complete sandbox health or tenant -placement authorization. - -`claudesdk.NewPreparationFactory` privately binds that workspace bridge to -`agent.Prepared`; the existing workspace factory uses the same Prepare/Start path. -Preparation receives configuration and required resume identity without a RunID or -prompt, checks the installed `workspace_prepare` feature, and owns the process until -one successful Start transfers it. The selected configuration and environment are -fixed before returning; a later Start supplies only its actual RunID, prompt and -output channel. Early preparation failure returns without an executing Session or -fabricated completion. The non-workspace direct factory keeps its existing path. - -The preparation owner context spans the eventual Session. Start's context bounds -that operation only, and Close is inert after successful transfer. Abandoned or -failed preparation, owner cancellation and native exit release owned work. The -same output consumer and cancellation settlement follow the resource across Start; -an unstarted preparation has no measured Usage or observed native Session identity. -A cancellation deadline cannot establish cleanup completion while cleanup remains -pending. This adapter ownership seam does not register a daemon capability or -supply per-Session placement authorization, public admission or an idle Files owner. - -The optional private `agent.WorkspaceReader` on this preparation and transferred -Session requires the packaged `workspace_read` feature. It sends bounded relative +one native process and SDK Query across Turns. Preparation requires initialization +and acknowledgement of required hooks while the input iterator remains empty. +An `executor_ready` receipt permits later `turn_start` messages containing only +Turn identity and ordered input; configuration replacement and concurrent starts +are rejected. Every Turn event carries its originating `turn_id`. Native Session +identity and actual tool inventory are checked before `input_ready`. + +Each Turn ends with a result/error and `turn_settled`, independently of process +exit. The outer input iterator remains open for later Turns. `turn_cancel` invokes +the native interrupt control for that exact Turn. Unconfirmed input, native child +work or queue state invalidates the Executor and requires close before replacement. +EOF, owner signals and invalid control input close owned resources. Preparation +may write native metadata and perform startup traffic; readiness does not prove +provider authentication, complete sandbox health or placement authorization. + +`claudesdk.NewExecutorFactory` binds this bridge to `agent.Executor`. Direct-call +and read-only preparation wrappers delegate to the same implementation. Runtime +execution uses the Executor registry for both none and workspace configurations. +Its owner context spans all Turns; a Turn's caller cannot replace fixed resources. +A failed preparation returns its Executor when cleanup remains unconfirmed. +Installed runtime checks are cached by package/file identity, while capability +and request validation still run for each Executor configuration. + +The optional private `agent.WorkspaceReader` on this Executor and its delegated wrappers +requires the packaged `workspace_read` feature. It sends bounded relative paths to that same SDK Query's native `readFile` control. Only the adapter combines the path with the frozen workspace root; callers cannot replace the placement. The pinned native read handler awaits file-handle close before its successful base64 response. The adapter validates bytes and truncation, bounds each result to 1 MiB and each request to 8 KiB, and admits one read at a time. Its continuous -bridge output consumer retains read receipts during preparation and across Start. +bridge output consumer retains read receipts during preparation and across Turns. Caller cancellation detaches observation without cancelling the Run or discarding an admitted waiter; its original deadline still applies. Owner closure stops admission. Native null, malformed receipts, timeout and interrupted delivery remain @@ -3293,7 +3286,7 @@ before input and during real execution, plus effects before cancellation and rea on fresh-process history continuation. The optional private `agent.WorkspaceDirectoryLister` requires the Linux-only -`workspace_directory` bridge feature on the same preparation or transferred Session. +`workspace_directory` bridge feature on the same Executor. The pinned SDK has no directory-enumeration control; its fuzzy file suggestions are not an inventory. This narrow adapter operation therefore reads metadata inside the already qualified co-located mount/process boundary. It pins the configured workspace @@ -3310,7 +3303,7 @@ ordering, snapshots, recursion, or public pagination. Missing and permission err are returned only from distinguishable filesystem outcomes; unknown results stop the owner. Each operation closes its directory and intermediate descriptors before a successful receipt; the root descriptor remains owned until bridge release. -Caller cancellation, Start transfer and owner shutdown retain the existing workspace +Caller cancellation, Turn transitions and owner shutdown retain the existing workspace read settlement rules. This adapter gap fill alone does not enable public Claude Files; the dedicated Runtime integration supplies public placement and ownership. @@ -3516,9 +3509,10 @@ also retain all snapshots in order under `claude_sdk_results`. Main-loop `usage` is per native turn, while query-pipeline `modelUsage` and estimated `total_cost_usd` are cumulative within the query. Retain subtype/error provenance and earlier snapshots even when a later failure reports zero counters. Reuse the latest full -snapshot set in Usage and Done; never sum cumulative measurements. Each factory -invocation owns one SDK query, including cold resume, so no prior query counters -are carried forward. Missing native results do not imply zero consumption. SDK estimates stay +snapshot set in Usage and Done; never sum cumulative measurements. Each Executor owns one SDK query, including cold resume. Raw snapshots explicitly +identify per-native-turn usage versus query-cumulative model usage and cost. A new +Turn has a fresh snapshot list, but query totals may include earlier Turns; never +represent those totals as consumption by the current Turn. Missing native results do not imply zero consumption. SDK estimates stay in raw evidence, outside the billed cost field; do not select an arbitrary model or invent missing public token breakdowns. The API does not parse native counters. Precise public usage projection, unreported costs and crash/partial accounting @@ -3534,18 +3528,19 @@ confirm applied input. Typed mid-turn folds may appear only on the native result Preserve that receipt even when the result reports failure. Check pending functions after the query drains: the SDK may dispatch later-turn callbacks before the earlier result handler finishes. -Keep the iterator open until every submitted input has a consuming result, even -when an earlier result reports an empty native queue. Close admission before -releasing final receipt waiters; drain and release the SDK/native processes before -one daemon Done. Cancellation resolves unconfirmed pending receipts as unknown and ends the -owned execution. Successful private SDK Cancel waits for owned-process exit and -stdout/stderr drain, then exposes the same settled CancellationOutcome as Done. -It retains native identity verified at input readiness, partial text and observed -Usage even on cancellation/failure; requested resume identity alone is not evidence. -A caller deadline before settlement reports failure/unknown, while cleanup continues. -Settlement precedes terminal publication so router completion cleanup cannot wait -on its own Done consumer. Cancellation releases intermediate event backpressure; -the settled outcome remains readable even when connection loss prevents publication. +Keep Turn input admission open until every submitted input has a consuming result, +even when an earlier result reports an empty native queue. Close admission before +releasing final receipt waiters. The outer SDK iterator remains open across Turns. +Cancellation resolves unconfirmed pending receipts as unknown and interrupts the +exact native Turn. Successful Cancel requires confirmed Turn settlement; process +exit alone cannot establish a successful cancellation. Reuse also requires an +empty confirmed native queue and settled child work. Otherwise close the Executor. +The settled CancellationOutcome retains verified native identity, partial text +and observed Usage; a requested resume identity alone is not evidence. Caller +wait expiry reports failure/unknown while cleanup retains ownership. Output +backpressure cannot turn missing native facts into confirmed settlement. Closed +Turn output precedes successful AwaitSettlement; the settled outcome remains +readable when connection loss prevents publication. A receipt timeout after a full write preserves the process and pending identity without redelivery; a blocked write is cancelled and released. The private adapter permits one input awaiting consumption and at most 63 extra diff --git a/README.md b/README.md index fb6bd0525..116f30d7a 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ native differences. | [API reference](docs/api/README.md) | The `/v1`, `/core/v1` and `/api/v1` namespaces | | [Nodes and sandbox backends: operator reference](services/agents-api/HOSTED-SANDBOX-MANAGER.md) | Node protocol, manual registration, placement, maintenance | | [Protocol coverage](contracts/agents-api/README.md) and [harness selection](contracts/agents-api/harness-selection.md) | Supported operations and native differences | +| [Add a Harness](contracts/agents-api/harness-onboarding.md) | Required adapter interfaces, lifecycle, capabilities, registration and acceptance | | [Landing page source](site/index.html) | The public site | ### Maintainers and advanced deployments diff --git a/apps/parsar-daemon/internal/agent/claudesdk/cancellation.go b/apps/parsar-daemon/internal/agent/claudesdk/cancellation.go index b2c432aa8..972e8af08 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/cancellation.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/cancellation.go @@ -2,28 +2,51 @@ package claudesdk import ( "context" + "errors" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -// Cancel confirms process exit and output drain, independently of terminal delivery. +// Cancel addresses this fixed Turn. Settlement remains observable after the caller detaches. func (s *session) Cancel(ctx context.Context) error { if ctx == nil { ctx = context.Background() } select { case <-s.settled: - return nil + return s.settlementErr default: } - s.process.Cancel() + if err := ctx.Err(); err != nil { + return err + } + if s.owner == nil { + return errors.New("claudesdk: Turn owner is unavailable") + } + s.cancelOnce.Do(func() { + close(s.cancelOutput) + s.owner.mu.Lock() + current := s.owner.active == s && !s.owner.invalid + s.owner.mu.Unlock() + if current { + stopWrite := context.AfterFunc(ctx, s.invalidate) + defer stopWrite() + if err := s.owner.write(struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + }{"turn_cancel", s.runID}); err != nil { + s.invalidate() + } + } + }) select { case <-s.settled: - return nil + return s.settlementErr case <-ctx.Done(): select { case <-s.settled: - return nil + return s.settlementErr default: return ctx.Err() } @@ -40,3 +63,5 @@ func (s *session) CancellationOutcome() proto.DonePayload { return proto.DonePayload{} } } + +var _ agent.Turn = (*session)(nil) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/cancellation_test.go b/apps/parsar-daemon/internal/agent/claudesdk/cancellation_test.go index 0f94f90ad..b8e64ed3a 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/cancellation_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/cancellation_test.go @@ -3,16 +3,15 @@ package claudesdk import ( + "bufio" "bytes" "context" "encoding/json" "errors" "os" - "os/signal" "path/filepath" "reflect" "strings" - "syscall" "testing" "time" @@ -171,12 +170,16 @@ func cancellationRequest() proto.PromptRequestPayload { return proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("hello"), AgentSessionID: "native-session", AgentOptions: map[string]any{"model": "fake-model", "system_prompt": "instructions"}} } -func runCancellationHelper(request startRequest, mode string, emit func(bridgeEvent)) { +func runCancellationHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { if mode == "cancellation-wait" { - stopped := make(chan os.Signal, 1) - signal.Notify(stopped, syscall.SIGTERM) emit(bridgeEvent{Type: "delta", Delta: "partial"}) - <-stopped + if !scanner.Scan() { + return + } + var cancel map[string]any + if json.Unmarshal(scanner.Bytes(), &cancel) != nil || cancel["type"] != "turn_cancel" { + os.Exit(9) + } // These valid observations were in flight when cancellation started. emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) emit(bridgeEvent{Type: "usage", ResultID: "native-result", SessionID: request.Resume, Usage: json.RawMessage(usageFixture)}) @@ -189,6 +192,8 @@ func runCancellationHelper(request startRequest, mode string, emit func(bridgeEv } _, _ = os.Stderr.WriteString(strings.Repeat("x", 2*1024*1024)) emit(bridgeEvent{Type: "delta", Delta: "drained"}) + emit(bridgeEvent{Type: "input_closed", SessionID: request.Resume}) + emit(bridgeEvent{Type: "error", Code: "cancelled"}) return } if mode != "cancellation-before-identity" { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go b/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go index f87ed4969..5a2d6e41b 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go @@ -3,10 +3,10 @@ package claudesdk import ( + "bufio" "context" "encoding/json" "os" - "os/signal" "path/filepath" "strings" "syscall" @@ -155,13 +155,7 @@ func TestWorkspaceCommandCancellationAndBridgeFailuresCloseOnlyPendingCalls(t *t } } -func runCommandsHelper(request startRequest, mode string, emit func(bridgeEvent)) { - var stopping chan os.Signal - if mode == "commands-cancel" || mode == "commands-drained" { - stopping = make(chan os.Signal, 1) - signal.Notify(stopping, syscall.SIGTERM) - defer signal.Stop(stopping) - } +func runCommandsHelper(request startRequest, mode string, scanner *bufio.Scanner, emit func(bridgeEvent)) { if mode != "commands-before-ready" { emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) } @@ -181,7 +175,9 @@ func runCommandsHelper(request startRequest, mode string, emit func(bridgeEvent) } switch mode { case "commands-cancel", "commands-drained": - <-stopping + if !scanner.Scan() { + return + } if mode == "commands-drained" { interrupted := commandEvent("pending", "after", "incomplete", "sleep 30") interrupted.Observation.Output = json.RawMessage(`"observed while draining"`) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor.go b/apps/parsar-daemon/internal/agent/claudesdk/executor.go new file mode 100644 index 000000000..0abc0ca47 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor.go @@ -0,0 +1,314 @@ +package claudesdk + +import ( + "context" + "encoding/json" + "errors" + "io" + "slices" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type executor struct { + mu sync.Mutex + base *session + start startRequest + active *session + ready chan error + done chan struct{} + invalid bool + nativeID string +} + +func NewExecutorFactory(config Config) agent.ExecutorFactory { + config.Env = slices.Clone(config.Env) + if config.Workspace != nil { + workspace := *config.Workspace + workspace.ProtectedDirs = slices.Clone(workspace.ProtectedDirs) + config.Workspace = &workspace + } + checked := &runtimeCheckCache{} + return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + if ctx == nil { + ctx = context.Background() + } + if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + return nil, errors.New("claudesdk: Executor preparation cannot submit input") + } + start, env, err := prepareConfiguration(config, req) + if err != nil { + return nil, err + } + info, err := checked.check(ctx, config) + if err != nil { + return nil, err + } + if err = validateExecutorFeatures(info, start); err != nil { + return nil, err + } + start.Type = "executor_prepare" + base, err := launch(ctx, config, start, env) + if err != nil { + return nil, err + } + base.reads.supported = slices.Contains(info.Features, "workspace_read") + base.directories.supported = slices.Contains(info.Features, "workspace_directory") + e := &executor{base: base, start: start, ready: make(chan error, 1), done: make(chan struct{}), nativeID: start.Resume} + go e.read() + if err = e.write(start); err == nil { + select { + case err = <-e.ready: + case <-ctx.Done(): + err = ctx.Err() + } + } + if err != nil { + closeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if closeErr := e.Close(closeCtx); closeErr != nil { + return e, errors.Join(err, closeErr) + } + return nil, err + } + return e, nil + } +} + +func validateExecutorFeatures(info RuntimeInfo, start startRequest) error { + if info.Protocol != 3 { + return errors.New("claudesdk: Executor bridge protocol is unavailable") + } + if start.Workspace != nil && !info.supportsWorkspacePreparation() { + return errors.New("claudesdk: workspace preparation is unavailable") + } + if start.OutputFormat != nil && (!info.SupportsStructuredOutput() || start.Workspace != nil && !info.SupportsWorkspaceStructuredOutput()) { + return errors.New("claudesdk: packaged runtime does not support workspace structured output") + } + if start.Subagents != nil && !info.SupportsSubagents() { + return errors.New("claudesdk: subagent resources are unavailable") + } + if start.Workspace != nil && start.observeFunctions && !info.supportsWorkspaceCommands() { + return errors.New("claudesdk: packaged runtime does not support workspace command observations") + } + if start.Workspace != nil && len(start.Functions) > 0 && !info.SupportsWorkspaceFunctions() { + return errors.New("claudesdk: workspace functions are unavailable") + } + if start.MCPHTTPServers != nil { + if !info.SupportsHTTPMCP() { + return errors.New("claudesdk: packaged runtime does not support HTTP MCP") + } + for _, server := range *start.MCPHTTPServers { + if server.Required && !info.SupportsHTTPMCPRequired() { + return errors.New("claudesdk: packaged runtime does not support required HTTP MCP") + } + if server.BearerTokenEnvVar != "" && !info.SupportsHTTPMCPBearer() { + return errors.New("claudesdk: packaged runtime does not support authenticated HTTP MCP") + } + } + } + return nil +} + +func (e *executor) write(value any) error { + e.base.writeMu.Lock() + defer e.base.writeMu.Unlock() + return json.NewEncoder(e.base.process.Stdin).Encode(value) +} + +func (e *executor) read() { + stderrDone := make(chan struct{}) + go func() { _, _ = io.Copy(io.Discard, e.base.process.Stderr); close(stderrDone) }() + scanner := e.base.bridgeOutput() + ready := false + readyFailure := errors.New("claudesdk: Executor readiness failed") + for scanner.Scan() { + raw := append([]byte(nil), scanner.Bytes()...) + var event bridgeEvent + if json.Unmarshal(raw, &event) != nil { + e.base.process.Cancel() + break + } + if !ready { + if event.Type != "executor_ready" || event.Protocol != 3 || event.TurnID != "" { + if event.Type == "error" { + readyFailure = bridgeFailure(event.Code) + } + e.base.process.Cancel() + break + } + ready = true + e.ready <- nil + continue + } + e.mu.Lock() + turn := e.active + valid := turn != nil && !turn.nativeEnded && event.TurnID == turn.runID + if valid && event.Type == "turn_settled" { + turn.nativeEnded = true + } + e.mu.Unlock() + if !valid { + e.base.process.Cancel() + break + } + select { + case turn.frames <- raw: + case <-e.base.process.Context().Done(): + } + } + e.base.process.Cancel() + for scanner.Scan() { + } + <-stderrDone + _ = e.base.process.Wait() + e.base.stopWorkspaceReads() + e.base.stopWorkspaceDirectories() + e.mu.Lock() + e.invalid = true + if e.active != nil { + close(e.active.frames) + } + e.mu.Unlock() + if !ready { + e.ready <- readyFailure + } + close(e.done) +} + +func (e *executor) StartTurn(ctx context.Context, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + if ctx == nil { + ctx = context.Background() + } + if strings.TrimSpace(run) == "" || input.Validate() != nil || out == nil || ctx.Err() != nil { + return nil, errors.New("claudesdk: Turn requires live context, identity, input and output") + } + e.mu.Lock() + if e.invalid || e.active != nil || e.base.process.Context().Err() != nil { + e.mu.Unlock() + return nil, errors.New("claudesdk: Executor is unavailable") + } + s := &session{owner: e, runID: run, process: e.base.process, writeMu: e.base.writeMu, frames: make(chan []byte, 64), functions: functionState{calls: map[string]*pendingFunction{}}, settled: make(chan struct{}), outputDone: make(chan struct{}), cancelOutput: make(chan struct{})} + start := e.start + start.Resume = e.nativeID + e.active = s + e.mu.Unlock() + go s.runTurn(start, out) + // Once the write is attempted, a lost receipt has an unknown input outcome. + stopWrite := context.AfterFunc(ctx, s.invalidate) + err := e.write(struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + Input proto.MessageInput `json:"input"` + }{"turn_start", run, input}) + stopWrite() + if err != nil { + s.invalidate() + return s, errors.New("claudesdk: Turn input delivery is unknown") + } + return s, nil +} + +func (e *executor) finishTurn(s *session) { + e.mu.Lock() + if e.active != s { + e.mu.Unlock() + return + } + if native, _ := s.outcome.Metadata[proto.DoneMetaAgentSessionID].(string); native != "" { + if e.nativeID != "" && e.nativeID != native { + e.invalid = true + } else { + e.nativeID = native + } + } + if !s.turnSettlement.Reusable { + e.invalid = true + } + invalid := e.invalid + if invalid { + s.turnSettlement.Reusable = false + if s.turnSettlement.Reason == "" { + s.turnSettlement.Reason = "executor_invalidated" + } + } + e.active = nil + e.mu.Unlock() + if invalid { + e.base.process.Cancel() + <-e.done + } +} + +func (e *executor) retire() { + e.mu.Lock() + e.invalid = true + e.mu.Unlock() + e.base.process.Cancel() + <-e.done +} + +func (e *executor) Close(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + e.mu.Lock() + e.invalid = true + active := e.active + e.mu.Unlock() + e.base.process.Cancel() + select { + case <-e.done: + if active != nil { + select { + case <-active.outputDone: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + if ctx == nil { + ctx = context.Background() + } + select { + case <-s.outputDone: + return s.turnSettlement, errors.Join(s.settlementErr, s.outputErr) + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} + +var _ agent.Executor = (*executor)(nil) +var _ agent.Turn = (*session)(nil) + +// A timed-out operation retains its original Turn identity. Its callback cannot +// retire a healthy successor after that operation has otherwise settled. +func (s *session) invalidate() { + if s.owner == nil { + s.process.Cancel() + return + } + s.owner.mu.Lock() + defer s.owner.mu.Unlock() + if s.owner.active == s { + s.owner.invalid = true + s.process.Cancel() + } +} + +func (e *executor) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { + return e.base.ReadWorkspaceFile(ctx, path, maxBytes) +} +func (e *executor) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { + return e.base.ListWorkspaceDirectory(ctx, path, maxEntries) +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_fixture_test.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_fixture_test.go new file mode 100644 index 000000000..5b2c9244d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_fixture_test.go @@ -0,0 +1,53 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + "encoding/json" + "os" +) + +func helperTurn(scanner *bufio.Scanner, request *startRequest) (func(bridgeEvent), func()) { + _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "executor_ready", Protocol: 3}) + if !scanner.Scan() { + os.Exit(4) + } + var start struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + Input json.RawMessage `json:"input"` + } + if json.Unmarshal(scanner.Bytes(), &start) != nil || start.Type != "turn_start" || start.TurnID == "" || json.Unmarshal(start.Input, &request.Input) != nil { + os.Exit(5) + } + return helperTurnOutput(scanner, start.TurnID) +} + +func helperTurnOutput(scanner *bufio.Scanner, id string) (func(bridgeEvent), func()) { + terminal := false + reusable := true + encode := func(event bridgeEvent) { + event.TurnID = id + _ = json.NewEncoder(os.Stdout).Encode(event) + if event.Type == "result" || event.Type == "error" { + terminal = true + if event.Type == "error" && event.Code != "cancelled" { + reusable = false + } + } + } + encode(bridgeEvent{Type: "turn_started"}) + return encode, func() { + if !terminal { + return + } + reason := "" + if !reusable { + reason = "native_error" + } + encode(bridgeEvent{Type: "turn_settled", Reusable: &reusable, Reason: reason}) + for scanner.Scan() { + } + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_live_linux_test.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_live_linux_test.go new file mode 100644 index 000000000..a2ac5f536 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_live_linux_test.go @@ -0,0 +1,199 @@ +//go:build linux + +package claudesdk + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// Explicit opt-in acceptance; normal test runs never contact a provider. +func TestLiveClaudeExecutorReuseAndCancel(t *testing.T) { + entry, keyPath, proof := os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_ENTRYPOINT"), os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_KEY_FILE"), os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_PROOF_DIR") + if entry == "" || keyPath == "" || proof == "" { + t.Skip("explicit installed runtime, private key file and proof directory required") + } + if !filepath.IsAbs(proof) { + t.Fatal("absolute proof directory required") + } + if err := os.MkdirAll(proof, 0700); err != nil { + t.Fatal(err) + } + key, err := os.ReadFile(keyPath) + if err != nil { + t.Fatal("cannot read selected credential file") + } + t.Setenv("OAC_RUNTIME_HOME", proof) + config := Config{Node: os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_NODE"), Entrypoint: entry, StateDir: filepath.Join(proof, "state"), Env: []string{ + "ANTHROPIC_BASE_URL=https://api.moonshot.cn/anthropic", "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), "ANTHROPIC_API_KEY=", "CLAUDE_CODE_OAUTH_TOKEN=", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", + "ANTHROPIC_DEFAULT_SONNET_MODEL=kimi-k3", "ANTHROPIC_DEFAULT_OPUS_MODEL=kimi-k3", "ANTHROPIC_DEFAULT_HAIKU_MODEL=kimi-k3", + }} + if proxy := os.Getenv("OAC_TEST_CLAUDE_EXECUTOR_HTTP_PROXY"); proxy != "" { + config.Env = append(config.Env, "HTTP_PROXY="+proxy, "HTTPS_PROXY="+proxy, "http_proxy="+proxy, "https_proxy="+proxy) + } + ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) + defer cancel() + readiness, err := CheckRuntime(ctx, config) + if err != nil { + t.Fatal("installed runtime readiness failed", err) + } + type turnEvidence struct { + Run string `json:"run"` + NodePID int `json:"node_pid"` + NativePIDs []int `json:"native_pids"` + FirstTextMS int64 `json:"first_text_ms"` + ElapsedMS int64 `json:"elapsed_ms"` + CancelMS int64 `json:"cancel_ms,omitempty"` + Cancelled bool `json:"cancelled"` + CancellationError string `json:"cancellation_error,omitempty"` + Settlement agent.TurnSettlement `json:"settlement"` + SettlementError string `json:"settlement_error,omitempty"` + Done proto.DonePayload `json:"done"` + Errors []string `json:"errors,omitempty"` + } + evidence := struct { + Runtime RuntimeInfo `json:"runtime"` + PrepareMS int64 `json:"prepare_ms"` + Turns []turnEvidence `json:"turns"` + Recovered bool `json:"recovered_after_cancel"` + Closed bool `json:"closed"` + }{Runtime: readiness} + persist := func() { + raw, _ := json.MarshalIndent(evidence, "", " ") + _ = os.WriteFile(filepath.Join(proof, "executor-evidence.json"), raw, 0600) + } + defer persist() + request := proto.PromptRequestPayload{StrictResume: true, DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, AgentOptions: map[string]any{"model": "kimi-k3", "system_prompt": "Follow requested formats briefly. Remember the exact verification marker across the conversation. Use no tools."}} + factory := NewExecutorFactory(config) + prepared := time.Now() + owner, err := factory(ctx, request) + if err != nil { + t.Fatal("executor preparation failed", err) + } + evidence.PrepareMS = time.Since(prepared).Milliseconds() + defer func() { + closeCtx, stop := context.WithTimeout(context.Background(), 10*time.Second) + defer stop() + evidence.Closed = owner.Close(closeCtx) == nil + persist() + }() + nativeChildren := func(pid int) []int { + raw, _ := os.ReadFile(fmt.Sprintf("/proc/%d/task/%d/children", pid, pid)) + var ids []int + for _, value := range strings.Fields(string(raw)) { + child, _ := strconv.Atoi(value) + args, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", child)) + if bytes.Contains(args, []byte("\x00--input-format\x00stream-json\x00")) && bytes.Contains(args, []byte("\x00--output-format\x00stream-json\x00")) { + ids = append(ids, child) + } + } + slices.Sort(ids) + return ids + } + run := func(id, prompt string, cancelOnText bool) turnEvidence { + t.Helper() + started := time.Now() + record := turnEvidence{Run: id, NodePID: owner.(*executor).base.process.Cmd.Process.Pid} + output := make(chan proto.Envelope, 128) + turn, err := owner.StartTurn(ctx, id, proto.TextInput(prompt), output) + if err != nil || turn == nil { + t.Fatal("Turn start failed", err) + } + cancellation := make(chan error, 1) + var cancelAt time.Time + for event := range output { + if event.ID != id { + t.Fatal("output crossed Turn identity") + } + if event.Type == proto.TypeDelta && record.FirstTextMS == 0 { + record.FirstTextMS = time.Since(started).Milliseconds() + record.NativePIDs = nativeChildren(record.NodePID) + if cancelOnText { + record.Cancelled = true + cancelAt = time.Now() + go func() { + cancelCtx, stop := context.WithTimeout(ctx, 30*time.Second) + defer stop() + cancellation <- turn.Cancel(cancelCtx) + }() + } + } + if event.Type == proto.TypeError { + var failure proto.ErrorPayload + _ = event.DecodePayload(&failure) + record.Errors = append(record.Errors, failure.Error) + } + if event.Type == proto.TypeDone { + _ = event.DecodePayload(&record.Done) + } + } + if record.Cancelled { + err := <-cancellation + record.CancelMS = time.Since(cancelAt).Milliseconds() + if err != nil { + record.CancellationError = err.Error() + } + } + record.Settlement, err = turn.AwaitSettlement(ctx) + if err != nil { + record.SettlementError = err.Error() + } + record.ElapsedMS = time.Since(started).Milliseconds() + evidence.Turns = append(evidence.Turns, record) + persist() + t.Logf("turn=%s bridge_pid=%d native_pids=%v reusable=%t first_text_ms=%d total_ms=%d", id, record.NodePID, record.NativePIDs, record.Settlement.Reusable, record.FirstTextMS, record.ElapsedMS) + return record + } + marker := "REUSE-" + uuid.NewString() + first := run("first", "Remember this marker: "+marker+". Reply with exactly the marker.", false) + if first.SettlementError != "" || !first.Settlement.Reusable || !strings.Contains(first.Done.Content, marker) { + t.Fatal("first Turn failed or was not reusable") + } + second := run("second", "What exact marker did I give you? Reply with only that marker.", false) + if second.SettlementError != "" || !second.Settlement.Reusable || !strings.Contains(second.Done.Content, marker) || first.NodePID != second.NodePID || len(first.NativePIDs) == 0 || !slices.Equal(first.NativePIDs, second.NativePIDs) { + t.Fatal("ordinary Turns did not retain native execution and history") + } + interrupted := run("cancel", "List the numbers 1 through 10000, one number per line, without stopping early.", true) + if !interrupted.Cancelled { + t.Fatal("native output ended before cancellation boundary") + } + if interrupted.SettlementError != "" || !interrupted.Settlement.Reusable { + if interrupted.Settlement.Reason == "" && interrupted.SettlementError == "" { + t.Fatal("invalid executor omitted its reason") + } + if err := owner.Close(ctx); err != nil { + t.Fatal("invalid owner cleanup was not confirmed", err) + } + native, _ := interrupted.Done.Metadata[proto.DoneMetaAgentSessionID].(string) + if native == "" { + native, _ = second.Done.Metadata[proto.DoneMetaAgentSessionID].(string) + } + request.AgentSessionID = native + request.RequireExistingNativeSession = true + owner, err = factory(ctx, request) + if err != nil { + t.Fatal("history recovery failed", err) + } + evidence.Recovered = true + } + continued := run("continued", "What exact marker did I originally give you? Reply with only the marker.", false) + if continued.SettlementError != "" || !continued.Settlement.Reusable || !strings.Contains(continued.Done.Content, marker) { + t.Fatal("history did not continue after cancellation") + } + if !evidence.Recovered && (continued.NodePID != second.NodePID || !slices.Equal(continued.NativePIDs, second.NativePIDs)) { + t.Fatal("reusable cancellation replaced native execution") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go new file mode 100644 index 000000000..755a0bc53 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go @@ -0,0 +1,285 @@ +//go:build unix + +package claudesdk + +import ( + "bufio" + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func persistentConfig(t *testing.T, mode string) (Config, proto.PromptRequestPayload) { + t.Helper() + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + entry := filepath.Join(root, "worker") + if err := os.WriteFile(entry, []byte("version-one"), 0600); err != nil { + t.Fatal(err) + } + return Config{Node: os.Args[0], Entrypoint: entry, StateDir: filepath.Join(root, "state"), Env: []string{"GO_CLAUDE_EXECUTOR_HELPER=1", "SDK_EXECUTOR_DIR=" + root, "SDK_EXECUTOR_MODE=" + mode, "GORACE=atexit_sleep_ms=0"}}, proto.PromptRequestPayload{DisableExecutionEnvironment: true, AgentOptions: map[string]any{"model": "fixture"}} +} + +func runPersistentExecutorHelper() { + root := os.Getenv("SDK_EXECUTOR_DIR") + if len(os.Args) > 1 && strings.HasSuffix(os.Args[1], "runtime_check.js") { + file, _ := os.OpenFile(filepath.Join(root, "probes"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if file != nil { + _, _ = file.WriteString("probe\n") + _ = file.Close() + } + printlnReport := json.NewEncoder(os.Stdout) + _ = printlnReport.Encode(RuntimeInfo{Type: "runtime_ready", Protocol: 3, Node: "fixture", SDK: "fixture", MCP: "fixture", Native: "fixture"}) + return + } + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return + } + var prepare map[string]json.RawMessage + if json.Unmarshal(scanner.Bytes(), &prepare) != nil || string(prepare["type"]) != `"executor_prepare"` || prepare["input"] != nil || prepare["run_id"] != nil { + os.Exit(4) + } + _ = os.WriteFile(filepath.Join(root, "prepared"), []byte(strconv.Itoa(os.Getpid())), 0600) + encode := func(event bridgeEvent) { _ = json.NewEncoder(os.Stdout).Encode(event) } + encode(bridgeEvent{Type: "executor_ready", Protocol: 3}) + if os.Getenv("SDK_EXECUTOR_MODE") == "block" { + time.Sleep(time.Hour) + return + } + var active, old string + settle := func(cancel bool) { + encode(bridgeEvent{Type: "input_closed", TurnID: active, SessionID: "native-persistent"}) + if cancel { + encode(bridgeEvent{Type: "error", TurnID: active, Code: "cancelled"}) + } else { + encode(bridgeEvent{Type: "result", TurnID: active, SessionID: "native-persistent", Text: active}) + } + yes := true + encode(bridgeEvent{Type: "turn_settled", TurnID: active, Reusable: &yes}) + old, active = active, "" + } + for scanner.Scan() { + var command struct { + Type string `json:"type"` + TurnID string `json:"turn_id"` + Input proto.MessageInput `json:"input"` + } + if json.Unmarshal(scanner.Bytes(), &command) != nil { + return + } + switch command.Type { + case "turn_start": + if active != "" { + os.Exit(5) + } + active = command.TurnID + if os.Getenv("SDK_EXECUTOR_MODE") == "late" && old != "" { + encode(bridgeEvent{Type: "delta", TurnID: old, Delta: "late"}) + continue + } + encode(bridgeEvent{Type: "turn_started", TurnID: active}) + encode(bridgeEvent{Type: "input_ready", TurnID: active, SessionID: "native-persistent"}) + encode(bridgeEvent{Type: "delta", TurnID: active, Delta: "partial"}) + if len(command.Input) > 0 && len(command.Input[0].Content) > 0 && command.Input[0].Content[0].Text != nil && *command.Input[0].Content[0].Text == "wait" { + continue + } + settle(false) + case "turn_cancel": + if active == command.TurnID { + settle(true) + } + } + } +} + +func consumeExecutorTurn(t *testing.T, owner agent.Executor, id, input string) (agent.Turn, <-chan proto.Envelope) { + t.Helper() + out := make(chan proto.Envelope, 16) + turn, err := owner.StartTurn(t.Context(), id, proto.TextInput(input), out) + if err != nil || turn == nil { + t.Fatalf("StartTurn: %v", err) + } + return turn, out +} +func awaitExecutorTurn(t *testing.T, turn agent.Turn, out <-chan proto.Envelope, reusable bool) { + t.Helper() + for event := range out { + if event.Type == proto.TypeDone { + var done proto.DonePayload + _ = event.DecodePayload(&done) + if done.Metadata[proto.DoneMetaAgentSessionID] != "native-persistent" { + t.Fatal("native continuity missing", done) + } + } + } + settlement, err := turn.AwaitSettlement(t.Context()) + if err != nil || settlement.Reusable != reusable { + t.Fatalf("settlement: %+v %v", settlement, err) + } +} +func TestExecutorRetainsProcessAcrossTurnsAndCancellation(t *testing.T) { + config, req := persistentConfig(t, "") + owner, err := NewExecutorFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + defer owner.Close(context.Background()) + pid := owner.(*executor).base.process.Cmd.Process.Pid + first, out := consumeExecutorTurn(t, owner, "first", "hello") + awaitExecutorTurn(t, first, out, true) + next, out := consumeExecutorTurn(t, owner, "next", "wait") + if event := <-out; event.Type != proto.TypeDelta { + t.Fatal(event.Type) + } + if err := first.Cancel(t.Context()); err != nil { + t.Fatal(err) + } + if err := next.Cancel(t.Context()); err != nil { + t.Fatal(err) + } + awaitExecutorTurn(t, next, out, true) + third, out := consumeExecutorTurn(t, owner, "third", "hello") + awaitExecutorTurn(t, third, out, true) + if owner.(*executor).base.process.Cmd.Process.Pid != pid { + t.Fatal("native process was replaced") + } + select { + case <-owner.(*executor).base.process.Done(): + t.Fatal("native process exited between Turns") + default: + } +} +func TestExecutorLateTurnEventInvalidatesWithoutRetargeting(t *testing.T) { + config, req := persistentConfig(t, "late") + owner, err := NewExecutorFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + defer owner.Close(context.Background()) + first, out := consumeExecutorTurn(t, owner, "first", "hello") + awaitExecutorTurn(t, first, out, true) + second, out := consumeExecutorTurn(t, owner, "second", "hello") + for frame := range out { + if frame.Type == proto.TypeDelta { + t.Fatal("old Turn event reached successor") + } + } + if _, err := second.AwaitSettlement(t.Context()); err == nil { + t.Fatal("unknown native outcome marked settled") + } + next := make(chan proto.Envelope, 1) + if turn, err := owner.StartTurn(t.Context(), "third", proto.TextInput("hello"), next); turn != nil || err == nil { + t.Fatal("invalid executor accepted input") + } +} +func TestExecutorCachesReadinessUntilInstalledArtifactChanges(t *testing.T) { + config, req := persistentConfig(t, "") + factory := NewExecutorFactory(config) + for range 2 { + owner, err := factory(t.Context(), req) + if err != nil { + t.Fatal(err) + } + if err := owner.Close(t.Context()); err != nil { + t.Fatal(err) + } + } + count := func() int { + raw, _ := os.ReadFile(filepath.Join(filepath.Dir(config.Entrypoint), "probes")) + return strings.Count(string(raw), "probe\n") + } + if count() != 1 { + t.Fatal("unchanged registered runtime was reprobed") + } + time.Sleep(time.Millisecond) + if err := os.WriteFile(config.Entrypoint, []byte("version-two-changed"), 0600); err != nil { + t.Fatal(err) + } + owner, err := factory(t.Context(), req) + if err != nil { + t.Fatal(err) + } + defer owner.Close(context.Background()) + if count() != 2 { + t.Fatal("changed installation reused stale readiness") + } +} + +func TestExecutorSeparatesPreInputRejectionFromUnknownWrite(t *testing.T) { + config, req := persistentConfig(t, "block") + owner, err := NewExecutorFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + defer owner.Close(context.Background()) + rejected := make(chan proto.Envelope, 1) + cancelled, stop := context.WithCancel(t.Context()) + stop() + if turn, err := owner.StartTurn(cancelled, "not-written", proto.TextInput("hello"), rejected); turn != nil || err == nil { + t.Fatal("cancelled admission reached native input") + } + select { + case <-rejected: + t.Fatal("nil Turn consumed caller output") + default: + } + e := owner.(*executor) + if err := e.base.process.Stdin.Close(); err != nil { + t.Fatal(err) + } + out := make(chan proto.Envelope, 8) + turn, err := owner.StartTurn(t.Context(), "unknown-write", proto.TextInput("hello"), out) + if turn == nil || err == nil { + t.Fatalf("attempted write must preserve unknown Turn ownership: %T %v", turn, err) + } + for range out { + } + if _, err := turn.AwaitSettlement(t.Context()); err == nil { + t.Fatal("lost input outcome became confirmed") + } +} + +func TestExecutorCancellationDeadlineInterruptsBlockedTransport(t *testing.T) { + config, req := persistentConfig(t, "block") + owner, err := NewExecutorFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + defer owner.Close(context.Background()) + turn, out := consumeExecutorTurn(t, owner, "blocked", "hello") + e := owner.(*executor) + written := make(chan error, 1) + go func() { written <- e.write(map[string]string{"padding": strings.Repeat("x", 2*1024*1024)}) }() + deadline := time.Now().Add(time.Second) + for e.base.writeMu.TryLock() { + e.base.writeMu.Unlock() + if time.Now().After(deadline) { + t.Fatal("transport write never started") + } + time.Sleep(time.Millisecond) + } + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond) + defer cancel() + returned := make(chan error, 1) + go func() { returned <- turn.Cancel(ctx) }() + select { + case err := <-returned: + if err == nil { + t.Fatal("blocked cancellation invented settlement") + } + case <-time.After(time.Second): + t.Fatal("Cancel ignored its transport deadline") + } + <-written + for range out { + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go new file mode 100644 index 000000000..14f5067ac --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go @@ -0,0 +1,215 @@ +package claudesdk + +import ( + "encoding/json" + "fmt" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "strings" + "time" +) + +func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { + defer close(s.outputDone) + defer s.owner.finishTurn(s) + defer close(out) + defer s.stopFunctions() + defer s.stopSteering() + var failure error + outputLost := false + terminalLost := false + emit := func(kind string, payload any) { + event, err := proto.NewEnvelope(kind, s.runID, payload) + if err != nil { + return + } + select { + case out <- event: + return + default: + } + var cancelled <-chan struct{} + if kind != proto.TypeDone && kind != proto.TypeError { + cancelled = s.cancelOutput + } + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + select { + case out <- event: + case <-cancelled: + outputLost = true + case <-timer.C: + outputLost = true + if kind == proto.TypeDone || kind == proto.TypeError { + terminalLost = true + } + s.invalidate() + } + } + var content strings.Builder + var result *bridgeEvent + var usage proto.Usage + var usageSession string + usageIDs := map[string]bool{} + var sequence uint64 + terminal := false + mcp := mcpState{calls: map[string]proto.ToolObservation{}} + commands := commandState{calls: map[string]proto.ToolObservation{}} + settlementReceived := false + reusable := false + reason := "bridge_interrupted" + for raw := range s.frames { + var event bridgeEvent + if err := json.Unmarshal(raw, &event); err != nil || event.TurnID != s.runID { + failure = fmt.Errorf("claudesdk: invalid Turn event identity") + s.invalidate() + break + } + if event.Type == "turn_started" && !terminal { + continue + } + if event.Type == "turn_settled" { + if !terminal || event.Reusable == nil || (!*event.Reusable && event.Reason == "") { + failure = fmt.Errorf("claudesdk: invalid Turn settlement") + s.invalidate() + break + } + settlementReceived, reusable, reason = true, *event.Reusable, event.Reason + break + } + if terminal { + failure = fmt.Errorf("claudesdk: invalid SDK bridge output") + s.invalidate() + break + } + switch event.Type { + case "command_observation": + if err := commands.receive(event, start, s.inputSessionID(), emit); err != nil { + failure = err + s.invalidate() + } + case "mcp_observation": + if err := mcp.receive(event, start, emit); err != nil { + failure = err + s.invalidate() + } + case "delta": + if start.ObserveMessages && event.ItemID == "" || !start.ObserveMessages && event.ItemID != "" { + failure = fmt.Errorf("claudesdk: invalid message delta identity") + s.invalidate() + break + } + content.WriteString(event.Delta) + sequence++ + emit(proto.TypeDelta, proto.DeltaPayload{ItemID: event.ItemID, Delta: event.Delta, Sequence: sequence}) + case "output_message": + message := event.Message + if !start.ObserveMessages || message == nil || message.ID == "" || + (message.Status != "in_progress" && message.Status != "completed") || + (message.Status == "completed") != (message.Text != nil) { + failure = fmt.Errorf("claudesdk: invalid message observation") + s.invalidate() + break + } + emit(proto.TypeOutputMessage, message) + case "function_call", "function_applied": + if err := s.receiveFunction(event, start, emit); err != nil { + failure = err + s.invalidate() + } + case "input_ready", "input_closed", "input_applied", "input_rejected": + if err := s.receiveInput(event, start); err != nil { + failure = err + s.invalidate() + } + case proto.TypeSubagentIdentity, proto.TypeSubagentTurn, proto.TypeSubagentItem, proto.TypeSubagentCoordination: + if start.Subagents == nil || !json.Valid(event.Fact) { + failure = fmt.Errorf("claudesdk: unrequested native child observation") + s.invalidate() + break + } + emit(event.Type, event.Fact) + case "usage": + if event.ResultID == "" || !s.matchesInputSession(event.SessionID) || event.SessionID == "" || (start.Resume != "" && event.SessionID != start.Resume) || + (usageSession != "" && usageSession != event.SessionID) || usageIDs[event.ResultID] { + failure = fmt.Errorf("claudesdk: invalid usage identity or duplicate result") + s.invalidate() + break + } + nextUsage, err := appendNativeUsage(usage, event.Usage) + failure = err + if failure != nil { + s.invalidate() + break + } + usage = nextUsage + usageIDs[event.ResultID] = true + usageSession = event.SessionID + emit(proto.TypeUsage, proto.UsagePayload{Usage: usage}) + case "result": + if !s.matchesInputSession(event.SessionID) || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume || usageSession != "" && event.SessionID != usageSession || !s.functionsComplete() || !s.steeringComplete() || !mcp.complete() || !commands.complete() { + failure = fmt.Errorf("claudesdk: invalid native completion or unconfirmed input/result") + s.invalidate() + } else { + result = &event + } + terminal = true + case "error": + failure = bridgeFailure(event.Code) + terminal = true + default: + failure = fmt.Errorf("claudesdk: unknown SDK bridge event") + s.invalidate() + } + if failure != nil && !terminal { + break + } + } + if !settlementReceived && failure == nil { + failure = fmt.Errorf("claudesdk: Turn settlement is missing") + } + if result == nil && failure == nil { + failure = fmt.Errorf("claudesdk: SDK result is missing") + } + if !s.functionsComplete() || !s.steeringComplete() || !mcp.complete() || !commands.complete() { + reusable, reason = false, "unsettled_native_operations" + } + mcp.close(start, emit) + commands.close(start, emit) + s.stopFunctions() + s.stopSteering() + metadata := map[string]any{proto.DoneMetaAgentSessionType: "claude_session"} + if id := s.inputSessionID(); id != "" { + metadata[proto.DoneMetaAgentSessionID] = id + } + if failure == nil { + content.Reset() + content.WriteString(result.Text) + metadata[proto.DoneMetaAgentSessionID] = result.SessionID + } + s.outcome = proto.DonePayload{Content: content.String(), Usage: usage, Metadata: metadata} + // Publish the observed cancellation outcome before terminal delivery. + if !settlementReceived || !reusable || outputLost || s.process.Context().Err() != nil { + s.owner.retire() + reusable = false + if reason == "" { + reason = "bridge_interrupted" + } + } + if !settlementReceived { + s.settlementErr = fmt.Errorf("claudesdk: native Turn settlement is unconfirmed") + } + close(s.settled) + if failure != nil { + emit(proto.TypeError, proto.ErrorPayload{Error: failure.Error()}) + } + emit(proto.TypeDone, s.outcome) + if outputLost { + s.owner.retire() + reusable, reason = false, "output_delivery_interrupted" + } + if terminalLost { + s.outputErr = fmt.Errorf("claudesdk: terminal output delivery failed") + } + s.turnSettlement = agent.TurnSettlement{Reusable: reusable, Reason: reason} +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/functions.go b/apps/parsar-daemon/internal/agent/claudesdk/functions.go index b135f8ec5..74df35648 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/functions.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/functions.go @@ -115,9 +115,10 @@ func (s *session) SubmitFunctionResult(ctx context.Context, result proto.Functio return err } data, err := json.Marshal(struct { - Type string `json:"type"` + Type string `json:"type"` + TurnID string `json:"turn_id"` proto.FunctionResultPayload - }{Type: "function_result", FunctionResultPayload: result}) + }{Type: "function_result", TurnID: s.runID, FunctionResultPayload: result}) if err != nil { return err } @@ -139,7 +140,7 @@ func (s *session) SubmitFunctionResult(ctx context.Context, result proto.Functio ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() // A lost receipt has an unknown outcome; terminate this execution instead of resending. - stop := context.AfterFunc(ctx, s.process.Cancel) + stop := context.AfterFunc(ctx, s.invalidate) defer stop() s.writeMu.Lock() if err = ctx.Err(); err == nil { @@ -147,7 +148,7 @@ func (s *session) SubmitFunctionResult(ctx context.Context, result proto.Functio } s.writeMu.Unlock() if err != nil { - s.process.Cancel() + s.invalidate() return fmt.Errorf("claudesdk: function result transport failed") } select { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go b/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go index cc5c6a71a..f64954b70 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go @@ -127,7 +127,9 @@ func runFunctionHelper(request startRequest, mode string, scanner *bufio.Scanner } if mode == "functions-cancel" { emit(bridgeEvent{Type: "delta", Delta: "waiting for confirmation"}) - time.Sleep(time.Hour) + if scanner.Scan() { + emit(bridgeEvent{Type: "error", Code: "cancelled"}) + } return } if mode == "functions-no-receipt" { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation.go index cdd683865..36b228698 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation.go @@ -2,129 +2,66 @@ package claudesdk import ( "context" - "encoding/json" - "fmt" - "slices" - "strings" + "errors" "sync" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) +// prepared is a single admission for direct adapter callers. It uses the same +// Executor as pooled Runtime execution; workspace reads retain the owner. type prepared struct { - mu sync.Mutex - session *session - ready chan struct{} - started chan struct{} - binding *preparedStart - closed bool - failure error + mu sync.Mutex + executor *executor + session *session + binding *preparedStart + closed bool } type preparedStart struct { - runID string - prompt proto.MessageInput - out chan<- proto.Envelope + turn agent.Turn + done chan struct{} } -var _ agent.PreparedCancellation = (*prepared)(nil) - -// NewPreparationFactory retains a trusted workspace process without submitting model input. func NewPreparationFactory(config Config) agent.PreparationFactory { - return func(owner context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - if owner == nil { - owner = context.Background() - } - if config.Workspace == nil || req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { - return nil, fmt.Errorf("claudesdk: preparation requires workspace configuration without input or conversation") - } - snapshot := config - snapshot.Env = slices.Clone(config.Env) - workspace := *config.Workspace - workspace.ProtectedDirs = slices.Clone(workspace.ProtectedDirs) - snapshot.Workspace = &workspace - start, env, err := prepareConfiguration(snapshot, req) - if err != nil { - return nil, err - } - start.Type = "prepare" - info, err := CheckRuntime(owner, snapshot) - if err != nil || !info.supportsWorkspacePreparation() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace preparation") + factory := NewExecutorFactory(config) + return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { + if config.Workspace == nil { + return nil, errors.New("claudesdk: workspace preparation requires a bound workspace") } - if start.OutputFormat != nil && !info.SupportsWorkspaceStructuredOutput() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace structured output") - } - if start.Subagents != nil && !info.SupportsSubagents() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support subagent resources") - } - if start.observeFunctions && !info.supportsWorkspaceCommands() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace command observations") - } - if len(start.Functions) > 0 && !info.SupportsWorkspaceFunctions() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support workspace functions") - } - s, err := launch(owner, snapshot, start, env) - if err != nil { + resource, err := factory(ctx, req) + if resource == nil { return nil, err } - s.reads.supported = slices.Contains(info.Features, "workspace_read") - s.directories.supported = slices.Contains(info.Features, "workspace_directory") - p := &prepared{session: s, ready: make(chan struct{}), started: make(chan struct{})} - go s.run(owner, "", start, nil, p) - select { - case <-p.ready: - if owner.Err() == nil { - return p, nil - } - _ = p.Close() - return nil, owner.Err() - case <-s.settled: - return nil, p.failure - } + e := resource.(*executor) + return &prepared{executor: e, session: e.base}, err } } -// Start transfers ownership once; ctx bounds this operation, not the Session lifetime. -func (p *prepared) Start(ctx context.Context, runID string, prompt proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - if ctx == nil { - ctx = context.Background() - } +func (p *prepared) Start(ctx context.Context, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { p.mu.Lock() if p.closed || p.binding != nil { p.mu.Unlock() - return nil, fmt.Errorf("claudesdk: preparation is no longer available") - } - var err error - if strings.TrimSpace(runID) == "" || prompt.Validate() != nil || out == nil { - err = fmt.Errorf("claudesdk: start requires a run identity, prompt and output channel") - } else if ctx.Err() != nil { - err = ctx.Err() - } else if p.session.process.Context().Err() != nil { - err = p.session.process.Context().Err() - } else { - select { - case <-p.session.process.Done(): - err = fmt.Errorf("claudesdk: prepared process has exited") - case <-p.session.settled: - err = fmt.Errorf("claudesdk: preparation has ended") - default: - } + return nil, errors.New("claudesdk: admission is unavailable") } - if err != nil { - p.closed = true - p.mu.Unlock() - _ = p.session.Cancel(context.Background()) - return nil, err + binding := &preparedStart{done: make(chan struct{})} + p.binding = binding + p.mu.Unlock() + turn, err := p.executor.StartTurn(ctx, run, input, out) + p.mu.Lock() + binding.turn = turn + if turn == nil { + p.binding = nil } - p.binding = &preparedStart{runID: runID, prompt: prompt, out: out} - close(p.started) + close(binding.done) p.mu.Unlock() - return p.session, nil + if turn != nil { + go func() { _, _ = turn.AwaitSettlement(context.Background()); _ = p.executor.Close(context.Background()) }() + } + return turn, err } -// Close settles unused ownership and is inert after transfer. func (p *prepared) Close() error { p.mu.Lock() if p.binding != nil { @@ -133,51 +70,47 @@ func (p *prepared) Close() error { } p.closed = true p.mu.Unlock() - return p.session.Cancel(context.Background()) + return p.executor.Close(context.Background()) } -// Cancel follows the resource across transfer and waits for the existing output settlement. func (p *prepared) Cancel(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } p.mu.Lock() p.closed = true + binding := p.binding p.mu.Unlock() - return p.session.Cancel(ctx) -} - -func (p *prepared) CancellationOutcome() proto.DonePayload { - return p.session.CancellationOutcome() -} - -func (p *prepared) awaitStart(scanner *bridgeOutput, failure error) (*preparedStart, error) { - if failure == nil { - if scanner.Scan() { - var event bridgeEvent - if json.Unmarshal(scanner.Bytes(), &event) != nil { - failure = fmt.Errorf("claudesdk: invalid SDK bridge output") - } else if event.Type == "error" { - failure = bridgeFailure(event.Code) - } else if event.Type != "prepared" { - failure = fmt.Errorf("claudesdk: SDK preparation receipt is missing") - } - } else { - failure = fmt.Errorf("claudesdk: SDK preparation receipt is missing") + if binding == nil { + return p.executor.Close(ctx) + } + select { + case <-binding.done: + default: + if err := p.executor.Close(ctx); err != nil { + return err + } + select { + case <-binding.done: + case <-ctx.Done(): + return ctx.Err() } } - if failure != nil { - p.session.process.Cancel() - return nil, failure + if binding.turn == nil { + return p.executor.Close(ctx) } - close(p.ready) - select { - case <-p.started: - case <-p.session.process.Context().Done(): - case <-p.session.process.Done(): + if err := binding.turn.Cancel(ctx); err != nil { + return err } + p.executor.retire() + return nil +} + +func (p *prepared) CancellationOutcome() proto.DonePayload { p.mu.Lock() defer p.mu.Unlock() - if p.binding != nil { - return p.binding, nil + if p.binding == nil || p.binding.turn == nil { + return proto.DonePayload{} } - p.closed = true - return nil, fmt.Errorf("claudesdk: preparation ended before start") + return p.binding.turn.CancellationOutcome() } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go index 2c02bd316..a9e8e0af6 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation_fixture_test.go @@ -50,7 +50,7 @@ func runPreparationHelper() { features = append(features, "workspace_structured_output") } } - _ = json.NewEncoder(os.Stdout).Encode(RuntimeInfo{Type: "runtime_ready", Protocol: 2, Node: "fixture", SDK: "fixture", MCP: "fixture", Native: "fixture", Features: features}) + _ = json.NewEncoder(os.Stdout).Encode(RuntimeInfo{Type: "runtime_ready", Protocol: 3, Node: "fixture", SDK: "fixture", MCP: "fixture", Native: "fixture", Features: features}) return } state := os.Getenv("CLAUDE_CONFIG_DIR") @@ -63,7 +63,7 @@ func runPreparationHelper() { _ = os.WriteFile(filepath.Join(state, "prepare.json"), raw, 0o600) var fields map[string]json.RawMessage var request startRequest - if json.Unmarshal(raw, &fields) != nil || json.Unmarshal(raw, &request) != nil || request.Type != "prepare" || fields["input"] != nil || fields["run_id"] != nil || request.Workspace == nil { + if json.Unmarshal(raw, &fields) != nil || json.Unmarshal(raw, &request) != nil || request.Type != "executor_prepare" || fields["input"] != nil || fields["run_id"] != nil || request.Workspace == nil { os.Exit(3) } emit := func(event bridgeEvent) { _ = json.NewEncoder(os.Stdout).Encode(event) } @@ -84,7 +84,7 @@ func runPreparationHelper() { time.Sleep(time.Millisecond) } } - emit(bridgeEvent{Type: "prepared"}) + emit(bridgeEvent{Type: "executor_ready", Protocol: 3}) if strings.HasPrefix(mode, "directory-") { runWorkspaceDirectoryHelper(scanner, state, mode) return @@ -99,15 +99,19 @@ func runPreparationHelper() { raw = append([]byte{}, scanner.Bytes()...) _ = os.WriteFile(filepath.Join(state, "start.json"), raw, 0o600) fields = nil - if json.Unmarshal(raw, &fields) != nil || len(fields) != 2 || string(fields["type"]) != `"start"` || string(fields["input"]) != `[{"content":[{"type":"input_text","text":"hello"}]}]` { + if json.Unmarshal(raw, &fields) != nil || len(fields) != 3 || string(fields["type"]) != `"turn_start"` || string(fields["input"]) != `[{"content":[{"type":"input_text","text":"hello"}]}]` { os.Exit(4) } + var turnID string + _ = json.Unmarshal(fields["turn_id"], &turnID) + emit, finish := helperTurnOutput(scanner, turnID) + defer finish() if mode == "cancellation" { - runCancellationHelper(request, "cancellation-wait", emit) + runCancellationHelper(request, "cancellation-wait", scanner, emit) return } if strings.HasPrefix(mode, "commands") { - runCommandsHelper(request, mode, emit) + runCommandsHelper(request, mode, scanner, emit) return } emit(bridgeEvent{Type: "input_ready", SessionID: request.Resume}) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go index 610c9154f..a5e1406b6 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go @@ -78,7 +78,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { if err != nil { t.Fatal(err) } - if s != p.session || s.(*session).process.Cmd.Process.Pid != pid { + if s.(*session).owner != p.executor || s.(*session).process.Cmd.Process.Pid != pid { t.Fatal("Start replaced the prepared native process") } if err := p.Close(); err != nil { @@ -184,12 +184,13 @@ func TestPreparationFailureAndUnusedRelease(t *testing.T) { if startErr == nil { t.Fatal("invalid or cancelled Start succeeded") } + err = p.Close() } if err != nil { t.Fatal(err) } select { - case <-p.session.settled: + case <-p.executor.done: case <-time.After(5 * time.Second): t.Fatal("unused process was not settled") } @@ -280,7 +281,10 @@ func TestPreparedStartRacesCloseAndCancellation(t *testing.T) { } } if err := p.Cancel(t.Context()); err != nil { - t.Fatal(err) + // A racing Close can confirm resource cleanup without proving the Turn result. + if closeErr := p.executor.Close(t.Context()); closeErr != nil { + t.Fatal(closeErr) + } } select { case <-p.session.process.Done(): diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go index 878f3ed16..ac9a91908 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go @@ -132,7 +132,7 @@ func CheckRuntime(ctx context.Context, config Config) (RuntimeInfo, error) { return RuntimeInfo{}, fmt.Errorf("claudesdk: runtime check failed") } var info RuntimeInfo - if json.Unmarshal(raw, &info) != nil || info.Type != "runtime_ready" || info.Protocol != 2 || + if json.Unmarshal(raw, &info) != nil || info.Type != "runtime_ready" || info.Protocol != 3 || info.Node == "" || info.SDK == "" || info.MCP == "" || info.Native == "" { return RuntimeInfo{}, fmt.Errorf("claudesdk: invalid runtime readiness report") } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go index a4e14866c..2e4825cc0 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness_test.go @@ -15,7 +15,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -const readyReport = `{"type":"runtime_ready","protocol":2,"node":"22.22.2","sdk":"0.3.269","mcp":"1.30.0","native":"2.1.269 (Claude Code)"}` +const readyReport = `{"type":"runtime_ready","protocol":3,"node":"22.22.2","sdk":"0.3.269","mcp":"1.30.0","native":"2.1.269 (Claude Code)"}` func TestRequiredMCPNeedsQualifiedRuntime(t *testing.T) { root := t.TempDir() @@ -128,11 +128,11 @@ func runReadinessHelper() { os.Exit(5) } } - _, _ = fmt.Fprintln(os.Stdout, strings.Replace(readyReport, `"protocol":2`, `"protocol":2,"features":["mcp_http_tools"]`, 1)) + _, _ = fmt.Fprintln(os.Stdout, strings.Replace(readyReport, `"protocol":3`, `"protocol":3,"features":["mcp_http_tools"]`, 1)) case "malformed": _, _ = fmt.Fprintln(os.Stdout, "not-json") case "wrong-protocol": - _, _ = fmt.Fprintln(os.Stdout, strings.Replace(readyReport, `"protocol":2`, `"protocol":1`, 1)) + _, _ = fmt.Fprintln(os.Stdout, strings.Replace(readyReport, `"protocol":3`, `"protocol":1`, 1)) case "missing-version": _, _ = fmt.Fprintln(os.Stdout, strings.Replace(readyReport, `"mcp":"1.30.0"`, `"mcp":""`, 1)) case "multiple": diff --git a/apps/parsar-daemon/internal/agent/claudesdk/runtime_cache.go b/apps/parsar-daemon/internal/agent/claudesdk/runtime_cache.go new file mode 100644 index 000000000..82b07f95e --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/runtime_cache.go @@ -0,0 +1,52 @@ +package claudesdk + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sync" +) + +type runtimeCheckCache struct { + mu sync.Mutex + stamp string + info RuntimeInfo +} + +// Registration retains the package check. Replacing an installed entrypoint, +// readiness companion, package manifest or Node binary invalidates the result. +func (c *runtimeCheckCache) check(ctx context.Context, config Config) (RuntimeInfo, error) { + node := config.Node + if node == "" { + node = "node" + } + node, err := exec.LookPath(node) + if err != nil { + return RuntimeInfo{}, err + } + root := filepath.Dir(config.Entrypoint) + stamp := "" + for _, path := range []string{node, config.Entrypoint, filepath.Join(root, "runtime_check.js"), filepath.Join(root, "../package.json"), filepath.Join(root, "../runtime-manifest.json")} { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + stamp += path + ":missing;" + continue + } + return RuntimeInfo{}, err + } + stamp += fmt.Sprintf("%s:%d:%d;", path, info.Size(), info.ModTime().UnixNano()) + } + c.mu.Lock() + defer c.mu.Unlock() + if c.stamp == stamp && c.info.Protocol == 3 { + return c.info, nil + } + info, err := CheckRuntime(ctx, config) + if err == nil { + c.info, c.stamp = info, stamp + } + return info, err +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/session.go b/apps/parsar-daemon/internal/agent/claudesdk/session.go index 267811f80..dc612355f 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/session.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/session.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "fmt" - "io" - "strings" "sync" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" @@ -14,10 +12,21 @@ import ( ) type session struct { + owner *executor + runID string + frames chan []byte + outputDone chan struct{} + turnSettlement agent.TurnSettlement + settlementErr error + outputErr error + cancelOnce sync.Once + cancelOutput chan struct{} + nativeEnded bool + reads workspaceReadState directories workspaceDirectoryState process *clirunner.Process - writeMu sync.Mutex + writeMu *sync.Mutex functions functionState steering steeringState settled chan struct{} @@ -25,59 +34,32 @@ type session struct { } func NewFactory(config Config) agent.Factory { + prepareExecutor := NewExecutorFactory(config) return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - if ctx == nil { - ctx = context.Background() - } - if out == nil { - return nil, fmt.Errorf("claudesdk: output channel is required") - } - if config.Workspace != nil { - runID, prompt := req.RunID, req.Input - if strings.TrimSpace(runID) == "" || prompt.Validate() != nil { - return nil, fmt.Errorf("claudesdk: run id and prompt are required") - } - req.RunID, req.Input = "", nil - prepared, err := NewPreparationFactory(config)(ctx, req) - if err != nil { - return nil, err - } - defer prepared.Close() - return prepared.Start(ctx, runID, prompt, out) - } - start, env, err := prepare(config, req) + run, input := req.RunID, req.Input + req.RunID, req.ConversationID, req.Input = "", "", nil + resource, err := prepareExecutor(ctx, req) if err != nil { return nil, err } - if start.MCPHTTPServers != nil || start.Subagents != nil { - info, err := CheckRuntime(ctx, config) - if start.Subagents != nil && (err != nil || !info.SupportsSubagents()) { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support subagent resources") - } - if start.MCPHTTPServers != nil && (err != nil || !info.SupportsHTTPMCP()) { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support HTTP MCP") - } - if start.MCPHTTPServers != nil { - for _, server := range *start.MCPHTTPServers { - if server.Required && !info.SupportsHTTPMCPRequired() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support required HTTP MCP") - } - if server.BearerTokenEnvVar != "" && !info.SupportsHTTPMCPBearer() { - return nil, fmt.Errorf("claudesdk: packaged runtime does not support authenticated HTTP MCP") - } - } - } - } - s, err := launch(ctx, config, start, env) - if err != nil { + turn, err := resource.StartTurn(ctx, run, input, out) + if turn == nil { + _ = resource.Close(context.Background()) return nil, err } - go s.run(ctx, req.RunID, start, out, nil) - return s, nil + // Factory callers own one execution. Both entrypoints use the same + // Executor implementation; Runtime pooling uses NewExecutorFactory. + go func() { _, _ = turn.AwaitSettlement(context.Background()); _ = resource.Close(context.Background()) }() + return turn, err } } type bridgeEvent struct { + TurnID string `json:"turn_id"` + Reusable *bool `json:"reusable"` + Reason string `json:"reason"` + Protocol int `json:"protocol"` + Fact json.RawMessage `json:"fact"` InputID string `json:"input_id"` ResultID string `json:"result_id"` @@ -97,191 +79,6 @@ type bridgeEvent struct { Observation *proto.ToolObservation `json:"observation"` } -func (s *session) run(ctx context.Context, runID string, start startRequest, out chan<- proto.Envelope, prepared *prepared) { - defer func() { - if out != nil { - close(out) - } - }() - defer s.stopFunctions() - defer s.stopSteering() - defer s.stopWorkspaceReads() - defer s.stopWorkspaceDirectories() - emit := func(kind string, payload any) { - event, err := proto.NewEnvelope(kind, runID, payload) - if err != nil { - return - } - // Cancellation must drain native output even if the event consumer stops. - // Terminal publication follows settlement and cannot hold up Cancel. - var stopping <-chan struct{} - if kind != proto.TypeError && kind != proto.TypeDone { - stopping = s.process.Context().Done() - } - // Preserve drained observations when the consumer can accept them immediately. - select { - case out <- event: - return - default: - } - select { - case out <- event: - case <-ctx.Done(): - case <-stopping: - } - } - stderrDone := make(chan struct{}) - go func() { _, _ = io.Copy(io.Discard, s.process.Stderr); close(stderrDone) }() - var failure error - if err := json.NewEncoder(s.process.Stdin).Encode(start); err != nil { - failure = fmt.Errorf("claudesdk: cannot submit SDK input") - s.process.Cancel() - } - scanner := s.bridgeOutput() - if prepared != nil { - binding, err := prepared.awaitStart(scanner, failure) - if binding == nil { - prepared.failure = s.drain(scanner, stderrDone, err) - close(s.settled) - return - } - runID, out = binding.runID, binding.out - s.writeMu.Lock() - err = json.NewEncoder(s.process.Stdin).Encode(struct { - Type string `json:"type"` - Input proto.MessageInput `json:"input"` - }{Type: "start", Input: binding.prompt}) - s.writeMu.Unlock() - if err != nil { - failure = fmt.Errorf("claudesdk: cannot submit SDK input") - s.process.Cancel() - } - } - var content strings.Builder - var result *bridgeEvent - var usage proto.Usage - var usageSession string - usageIDs := map[string]bool{} - var sequence uint64 - terminal := false - mcp := mcpState{calls: map[string]proto.ToolObservation{}} - commands := commandState{calls: map[string]proto.ToolObservation{}} - for scanner.Scan() { - var event bridgeEvent - if err := json.Unmarshal(scanner.Bytes(), &event); err != nil || terminal { - failure = fmt.Errorf("claudesdk: invalid SDK bridge output") - s.process.Cancel() - break - } - switch event.Type { - case "command_observation": - if err := commands.receive(event, start, s.inputSessionID(), emit); err != nil { - failure = err - s.process.Cancel() - } - case "mcp_observation": - if err := mcp.receive(event, start, emit); err != nil { - failure = err - s.process.Cancel() - } - case "delta": - if start.ObserveMessages && event.ItemID == "" || !start.ObserveMessages && event.ItemID != "" { - failure = fmt.Errorf("claudesdk: invalid message delta identity") - s.process.Cancel() - break - } - content.WriteString(event.Delta) - sequence++ - emit(proto.TypeDelta, proto.DeltaPayload{ItemID: event.ItemID, Delta: event.Delta, Sequence: sequence}) - case "output_message": - message := event.Message - if !start.ObserveMessages || message == nil || message.ID == "" || - (message.Status != "in_progress" && message.Status != "completed") || - (message.Status == "completed") != (message.Text != nil) { - failure = fmt.Errorf("claudesdk: invalid message observation") - s.process.Cancel() - break - } - emit(proto.TypeOutputMessage, message) - case "function_call", "function_applied": - if err := s.receiveFunction(event, start, emit); err != nil { - failure = err - s.process.Cancel() - } - case "input_ready", "input_closed", "input_applied", "input_rejected": - if err := s.receiveInput(event, start); err != nil { - failure = err - s.process.Cancel() - } - case proto.TypeSubagentIdentity, proto.TypeSubagentTurn, proto.TypeSubagentItem, proto.TypeSubagentCoordination: - if start.Subagents == nil || !json.Valid(event.Fact) { - failure = fmt.Errorf("claudesdk: unrequested native child observation") - s.process.Cancel() - break - } - emit(event.Type, event.Fact) - case "usage": - if event.ResultID == "" || !s.matchesInputSession(event.SessionID) || event.SessionID == "" || (start.Resume != "" && event.SessionID != start.Resume) || - (usageSession != "" && usageSession != event.SessionID) || usageIDs[event.ResultID] { - failure = fmt.Errorf("claudesdk: invalid usage identity or duplicate result") - s.process.Cancel() - break - } - nextUsage, err := appendNativeUsage(usage, event.Usage) - failure = err - if failure != nil { - s.process.Cancel() - break - } - usage = nextUsage - usageIDs[event.ResultID] = true - usageSession = event.SessionID - emit(proto.TypeUsage, proto.UsagePayload{Usage: usage}) - case "result": - if !s.matchesInputSession(event.SessionID) || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume || usageSession != "" && event.SessionID != usageSession || !s.functionsComplete() || !s.steeringComplete() || !mcp.complete() || !commands.complete() { - failure = fmt.Errorf("claudesdk: invalid native completion or unconfirmed input/result") - s.process.Cancel() - } else { - result = &event - } - terminal = true - case "error": - failure = bridgeFailure(event.Code) - terminal = true - default: - failure = fmt.Errorf("claudesdk: unknown SDK bridge event") - s.process.Cancel() - } - if failure != nil && !terminal { - break - } - } - failure = s.drain(scanner, stderrDone, failure) - if result == nil && failure == nil { - failure = fmt.Errorf("claudesdk: SDK result is missing") - } - mcp.close(start, emit) - commands.close(start, emit) - s.stopFunctions() - s.stopSteering() - metadata := map[string]any{proto.DoneMetaAgentSessionType: "claude_session"} - if id := s.inputSessionID(); id != "" { - metadata[proto.DoneMetaAgentSessionID] = id - } - if failure == nil { - content.Reset() - content.WriteString(result.Text) - metadata[proto.DoneMetaAgentSessionID] = result.SessionID - } - s.outcome = proto.DonePayload{Content: content.String(), Usage: usage, Metadata: metadata} - // Router completion cleanup calls Cancel while consuming Done. Settle first. - close(s.settled) - if failure != nil { - emit(proto.TypeError, proto.ErrorPayload{Error: failure.Error()}) - } - emit(proto.TypeDone, s.outcome) -} - func launch(ctx context.Context, config Config, start startRequest, env []string) (*session, error) { binary := config.Node if binary == "" { @@ -291,7 +88,7 @@ func launch(ctx context.Context, config Config, start startRequest, env []string if err != nil { return nil, err } - return &session{process: process, functions: functionState{calls: map[string]*pendingFunction{}}, settled: make(chan struct{})}, nil + return &session{process: process, writeMu: &sync.Mutex{}, functions: functionState{calls: map[string]*pendingFunction{}}, settled: make(chan struct{})}, nil } func (s *session) drain(scanner *bridgeOutput, stderrDone <-chan struct{}, failure error) error { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/session_test.go b/apps/parsar-daemon/internal/agent/claudesdk/session_test.go index 2a736f7f2..eacdb8e16 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/session_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/session_test.go @@ -101,6 +101,10 @@ func TestTextFactoryRejectsUnsupportedInput(t *testing.T) { } func TestMain(m *testing.M) { + if os.Getenv("GO_CLAUDE_EXECUTOR_HELPER") == "1" { + runPersistentExecutorHelper() + os.Exit(0) + } if os.Getenv("GO_CLAUDE_PREPARATION_HELPER") == "1" { runPreparationHelper() os.Exit(0) @@ -117,18 +121,23 @@ func TestMain(m *testing.M) { } func runSDKHelper() { + if len(os.Args) > 1 && strings.HasSuffix(os.Args[1], "runtime_check.js") { + fmt.Fprintln(os.Stdout, strings.TrimSuffix(readyReport, "}")+`,"features":["structured_output","subagents"]}`) + return + } scanner := bufio.NewScanner(os.Stdin) if !scanner.Scan() { os.Exit(2) } var request startRequest - if json.Unmarshal(scanner.Bytes(), &request) != nil || request.Type != "start" || *request.Input[0].Content[0].Text != "hello" || request.Model != "fake-model" || request.SystemPrompt != "instructions" { + if json.Unmarshal(scanner.Bytes(), &request) != nil || request.Type != "executor_prepare" || request.Model != "fake-model" || request.SystemPrompt != "instructions" { os.Exit(3) } - encode := func(event bridgeEvent) { _ = json.NewEncoder(os.Stdout).Encode(event) } + encode, finish := helperTurn(scanner, &request) + defer finish() mode := os.Getenv("SDK_HELPER_MODE") if strings.HasPrefix(mode, "cancellation-") { - runCancellationHelper(request, mode, encode) + runCancellationHelper(request, mode, scanner, encode) return } if strings.HasPrefix(mode, "steering-") { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/steering.go b/apps/parsar-daemon/internal/agent/claudesdk/steering.go index b87b16f1d..c9d2c0eae 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/steering.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/steering.go @@ -34,6 +34,11 @@ func (s *session) Steer(ctx context.Context, input proto.PromptSteerPayload) err // SteerWithReceipt separates a complete bridge write from native consumption. func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { + select { + case <-s.cancelOutput: + return agent.ErrSteeringInactive + default: + } if ctx == nil { ctx = context.Background() } @@ -42,9 +47,10 @@ func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP } data, err := json.Marshal(struct { Type string `json:"type"` + TurnID string `json:"turn_id"` InputID string `json:"input_id"` Input proto.MessageInput `json:"input"` - }{Type: "steer", InputID: input.InputID, Input: input.Input}) + }{Type: "steer", TurnID: s.runID, InputID: input.InputID, Input: input.Input}) if err != nil || len(data) > 1024*1024 { return fmt.Errorf("%w: input exceeds bridge limit", agent.ErrSteeringRejected) } @@ -74,7 +80,7 @@ func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP s.steering.mu.Unlock() // Cancellation must release a blocked write, but a lost receipt after a full // write preserves the process and unknown outcome without automatic redelivery. - stop := context.AfterFunc(ctx, s.process.Cancel) + stop := context.AfterFunc(ctx, s.invalidate) s.writeMu.Lock() if err = ctx.Err(); err == nil { _, err = s.process.Stdin.Write(append(data, '\n')) @@ -82,7 +88,7 @@ func (s *session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP s.writeMu.Unlock() stop() if err != nil { - s.process.Cancel() + s.invalidate() return fmt.Errorf("claudesdk: input transport failed") } if written != nil { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/steering_test.go b/apps/parsar-daemon/internal/agent/claudesdk/steering_test.go index 256856efc..7bd7f68d3 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/steering_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/steering_test.go @@ -147,10 +147,11 @@ func TestSteeringReceiptsAndLifecycle(t *testing.T) { if err := s.Steer(ctx, input); !errors.Is(err, agent.ErrSteeringInactive) { t.Fatal("completed execution accepted input", err) } - select { - case <-s.process.Done(): - default: - t.Fatal("completion preceded process release") + if _, err := s.AwaitSettlement(ctx); err != nil && (mode == "success" || mode == "phased" || mode == "timeout") { + t.Fatal(err) + } + if err := s.owner.Close(ctx); err != nil { + t.Fatal(err) } }) } @@ -186,7 +187,9 @@ func runSteeringHelper(request startRequest, mode string, scanner *bufio.Scanner os.Exit(3) } if mode == "steering-cancel" { - time.Sleep(time.Hour) + if scanner.Scan() { + emit(bridgeEvent{Type: "error", Code: "cancelled"}) + } return } if mode == "steering-timeout" { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go index 14507ab80..4cbf89803 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory.go @@ -57,6 +57,9 @@ func (p *prepared) ListWorkspaceDirectory(ctx context.Context, path string, maxE } func (s *session) ListWorkspaceDirectory(ctx context.Context, path string, maxEntries int) (agent.WorkspaceDirectoryResult, error) { + if s.owner != nil { + return s.owner.base.ListWorkspaceDirectory(ctx, path, maxEntries) + } read, err := s.admitWorkspaceDirectory(ctx, path, maxEntries) if err != nil { return agent.WorkspaceDirectoryResult{}, err diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go index f31003a9c..2cb969cf2 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go @@ -20,14 +20,24 @@ import ( ) func runWorkspaceDirectoryHelper(scanner *bufio.Scanner, state, mode string) { + turnID := "" for scanner.Scan() { var req struct { Type, ID string + TurnID string `json:"turn_id"` Path string `json:"directory"` Max int `json:"max_entries"` } _ = json.Unmarshal(scanner.Bytes(), &req) - if req.Type == "start" { + if req.Type == "turn_cancel" { + reusable := false + _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "error", TurnID: turnID, Code: "cancelled"}) + _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_settled", TurnID: turnID, Reusable: &reusable, Reason: "fixture_closed"}) + return + } + if req.Type == "turn_start" { + turnID = req.TurnID + _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_started", TurnID: turnID}) _ = os.WriteFile(filepath.Join(state, "directory-started"), []byte("{}"), 0600) continue } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go index 832b1dcce..556db3e7f 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go @@ -24,12 +24,13 @@ test "$ANTHROPIC_AUTH_TOKEN" = selected-provider-fixture || exit 23 test "$TMPDIR" != "$CLAUDE_CONFIG_DIR/tmp" || exit 24 case "$1" in */runtime_check.js) - printf '%s\n' '{"type":"runtime_ready","protocol":2,"node":"fixture","sdk":"fixture","mcp":"fixture","native":"fixture","features":["workspace_tools","workspace_prepare"]}' ;; + printf '%s\n' '{"type":"runtime_ready","protocol":3,"node":"fixture","sdk":"fixture","mcp":"fixture","native":"fixture","features":["workspace_tools","workspace_prepare"]}' ;; *) IFS= read -r request - printf '%s\n' '{"type":"prepared"}' + printf '%s\n' '{"type":"executor_ready","protocol":3}' IFS= read -r request - printf '%s\n' '{"type":"result","session_id":"native","text":"completed"}' ;; + printf '%s\n' '{"type":"result","turn_id":"run","session_id":"native","text":"completed"}' + printf '%s\n' '{"type":"turn_settled","turn_id":"run","reusable":true,"reason":""}' ;; esac ` if err := os.WriteFile(config.Node, []byte(script), 0o700); err != nil { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go index 9ed4b64c7..5f0a4052d 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read.go @@ -57,6 +57,9 @@ func (p *prepared) ReadWorkspaceFile(ctx context.Context, path string, maxBytes } func (s *session) ReadWorkspaceFile(ctx context.Context, path string, maxBytes int) (agent.WorkspaceReadResult, error) { + if s.owner != nil { + return s.owner.base.ReadWorkspaceFile(ctx, path, maxBytes) + } read, err := s.admitWorkspaceRead(ctx, path, maxBytes) if err != nil { return agent.WorkspaceReadResult{}, err diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go index 61209b63b..ca46342ef 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go @@ -20,13 +20,23 @@ import ( ) func runWorkspaceReadHelper(scanner *bufio.Scanner, state, mode string) { + turnID := "" for scanner.Scan() { var req struct { Type, ID, Path string - Max int `json:"max_bytes"` + TurnID string `json:"turn_id"` + Max int `json:"max_bytes"` } _ = json.Unmarshal(scanner.Bytes(), &req) - if req.Type == "start" { + if req.Type == "turn_cancel" { + reusable := false + _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "error", TurnID: turnID, Code: "cancelled"}) + _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_settled", TurnID: turnID, Reusable: &reusable, Reason: "fixture_closed"}) + return + } + if req.Type == "turn_start" { + turnID = req.TurnID + _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_started", TurnID: turnID}) _ = os.WriteFile(filepath.Join(state, "read-started"), []byte("{}"), 0600) continue } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go index a9f7a8ebc..b8ad20f00 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_structured_test.go @@ -52,7 +52,7 @@ func TestWorkspaceStructuredPreparationQualificationAndFrozenSchema(t *testing.T } } var started map[string]json.RawMessage - if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "start.json")), &started); err != nil || len(started) != 2 || started["output_format"] != nil { + if err := json.Unmarshal(waitPreparationFile(t, filepath.Join(config.StateDir, "start.json")), &started); err != nil || len(started) != 3 || started["output_format"] != nil { t.Fatal("Start replaced the prepared configuration", err) } }) diff --git a/apps/parsar-daemon/internal/agent/codex/executor.go b/apps/parsar-daemon/internal/agent/codex/executor.go new file mode 100644 index 000000000..954a29d0f --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/executor.go @@ -0,0 +1,156 @@ +package codex + +import ( + "context" + "errors" + "strings" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Executor owns the prepared process, fixed plan and native thread. Each StartTurn +// creates independent receipt, observation, cancellation and output ownership. +type Executor struct { + mu sync.Mutex + prepared *Prepared + active *Session + closed bool + closeMu sync.Mutex +} + +func PrepareExecutor(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + return newExecutor(ctx, req, defaultSessionConfig()) +} + +func newExecutor(ctx context.Context, req proto.PromptRequestPayload, cfg sessionConfig) (*Executor, error) { + p, err := newPreparation(ctx, req, cfg) + if err != nil { + if p != nil { + return &Executor{prepared: p}, err + } + return nil, err + } + p.mu.Lock() + p.started = true + close(p.transferred) + p.mu.Unlock() + return &Executor{prepared: p}, nil +} + +func (e *Executor) StartTurn(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + if out == nil || strings.TrimSpace(runID) == "" || input.Validate() != nil { + return nil, errors.New("codex: start requires a run identity, input and output") + } + if err := ctx.Err(); err != nil { + return nil, err + } + e.mu.Lock() + base := e.prepared.session + if e.closed || base.cancelCtx.Err() != nil || !base.rpc.Alive() { + e.mu.Unlock() + return nil, errors.New("codex: executor unavailable") + } + previous := e.active + if previous != nil { + select { + case <-previous.waitDone: + default: + e.mu.Unlock() + return nil, errors.New("codex: previous turn has not settled") + } + if previous.settlementErr != nil || !previous.settlement.Reusable { + e.mu.Unlock() + return nil, errors.New("codex: executor requires retirement") + } + } + functions := &functionCalls{definitions: base.functions.definitions, names: base.functions.names, pending: map[string]*pendingFunction{}} + turnCtx, cancel := context.WithCancel(base.cancelCtx) + s := &Session{executor: e, nativeHome: base.nativeHome, toolEnvironment: base.toolEnvironment, + functions: functions, observeMessages: base.observeMessages, observeTools: base.observeTools, + observeToolObservations: base.observeToolObservations, observeSubagentIdentities: base.observeSubagentIdentities, + cfg: base.cfg, rpc: base.rpc, cancelCtx: turnCtx, cancelFn: cancel, + waitDone: make(chan struct{}), outputDone: make(chan struct{}), cleanup: func() {}, + bufs: NewItemBuffers(), resolvedModel: base.resolvedModel, interactions: newPendingCodexInteractions(), runID: runID, out: out} + if previous != nil { + s.threadID = previous.currentThreadID() + s.retiredTurns = make(map[string]bool, len(previous.retiredTurns)+1) + for id := range previous.retiredTurns { + s.retiredTurns[id] = true + } + previous.steering.mu.Lock() + s.retiredTurns[previous.steering.id] = true + previous.steering.mu.Unlock() + s.resolvedModel = previous.resolvedModel + previous.usageMu.Lock() + s.usageTotal = previous.usageTotal + previous.usageMu.Unlock() + } + e.active = s + if s.observeSubagentIdentities { + s.startSubagentObservations() + } + s.registerHandlers() + e.mu.Unlock() + req := proto.PromptRequestPayload{RunID: runID, Input: input, AgentSessionID: e.prepared.resumeID, StrictResume: e.prepared.strictResume, RequireExistingNativeSession: e.prepared.requireExistingNativeSession} + // Ownership precedes any native submission. Even an uncertain start returns the + // exact Turn so its caller can await settlement without replaying the input. + err := s.startNative(ctx, e.prepared.plan, req) + if err != nil { + s.emitTerminal("codex: native start failed", true) + s.finishAfterTerminal() + } + go s.settleExecutorTurn(err) + return s, err +} + +func (e *Executor) Close(ctx context.Context) error { + e.mu.Lock() + e.closed = true + e.mu.Unlock() + done := make(chan error, 1) + go func() { + e.closeMu.Lock() + defer e.closeMu.Unlock() + base := e.prepared.session + e.mu.Lock() + running := e.active + e.mu.Unlock() + if running != nil { + select { + case <-running.waitDone: + default: + if err := running.Cancel(ctx); err != nil { + done <- err + return + } + } + } + base.cancelFn() + err := base.rpc.Close() + e.mu.Lock() + active := e.active + e.mu.Unlock() + if err == nil && active != nil { + _, err = active.AwaitSettlement(ctx) + } + if err == nil { + err = base.rpc.awaitReaders(ctx) + } + if err == nil { + e.prepared.plan.Cleanup() + } + done <- err + }() + select { + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +var _ agent.Executor = (*Executor)(nil) + +func NewExecutorFactory() agent.ExecutorFactory { return PrepareExecutor } diff --git a/apps/parsar-daemon/internal/agent/codex/executor_native_test.go b/apps/parsar-daemon/internal/agent/codex/executor_native_test.go new file mode 100644 index 000000000..5d4ecbc97 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/executor_native_test.go @@ -0,0 +1,164 @@ +package codex + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// This opt-in test uses the real pinned harness and an explicitly configured +// Responses provider. Credentials are read from a private file, never arguments. +func TestExecutorNativeReuse(t *testing.T) { + if os.Getenv("OAC_TEST_CODEX_EXECUTOR_NATIVE") != "1" { + t.Skip("requires an authorized native model acceptance environment") + } + binary, root := os.Getenv("OAC_TEST_CODEX_BINARY"), os.Getenv("OAC_TEST_CODEX_LIVE_ROOT") + model, endpoint := os.Getenv("OAC_TEST_CODEX_MODEL"), os.Getenv("OAC_TEST_CODEX_BASE_URL") + if binary == "" || root == "" || model == "" || endpoint == "" { + t.Fatal("missing explicit native acceptance configuration") + } + version, err := exec.Command(binary, "--version").Output() + if err != nil || strings.TrimSpace(string(version)) != "codex-cli 0.153.4" { + t.Fatal("native artifact is not Codex 0.153.4") + } + key, err := os.ReadFile(os.Getenv("OAC_TEST_CODEX_KEY_FILE")) + if err != nil || strings.TrimSpace(string(key)) == "" { + t.Fatal("private provider credential unavailable") + } + if err = os.MkdirAll(root, 0700); err != nil { + t.Fatal("cannot create isolated acceptance root") + } + isolated, err := os.MkdirTemp(root, "run-") + if err != nil { + t.Fatal("cannot create isolated acceptance workspace") + } + t.Cleanup(func() { _ = os.RemoveAll(isolated) }) + t.Setenv("OAC_RUNTIME_HOME", isolated) + for _, name := range []string{"CODEX_EXEC_SERVER_URL", "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID", "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN"} { + t.Setenv(name, "") + } + cfg := defaultSessionConfig() + cfg.codexBinary = binary + cfg.logger = obslog.Discard() + req := proto.PromptRequestPayload{ + AgentKind: "codex", AgentStateKey: "executor-native", WorkDir: filepath.Join(isolated, "workspace"), + StrictResume: true, DisableExecutionEnvironment: true, DisableSubagents: true, ObserveMessages: true, + AgentOptions: map[string]any{"model": model, "codex_provider": map[string]any{"base_url": endpoint, "wire_api": "responses", "bearer_token": strings.TrimSpace(string(key))}}, + FunctionTools: []proto.FunctionTool{{Name: "hold", Description: "Wait until the host supplies a result.", Parameters: json.RawMessage("{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}")}}, + } + ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) + defer cancel() + began := time.Now() + e, err := newExecutor(ctx, req, cfg) + if err != nil { + t.Fatalf("native prepare failed (%T)", err) + } + t.Cleanup(func() { + c, stop := context.WithTimeout(context.Background(), 20*time.Second) + defer stop() + if err := e.Close(c); err != nil { + t.Errorf("native owner cleanup failed (%T)", err) + } + }) + t.Logf("native_version=0.153.4 prepare_ms=%d", time.Since(began).Milliseconds()) + pid := e.prepared.session.rpc.cmd.Process.Pid + var thread string + run := func(id, prompt string, old agent.Turn, interrupt bool) (agent.Turn, string) { + t.Helper() + output := make(chan proto.Envelope, 512) + started := time.Now() + turn, err := e.StartTurn(ctx, id, proto.TextInput(prompt), output) + if err != nil { + t.Fatalf("%s start failed (%T)", id, err) + } + if old != nil { + if err := old.Cancel(ctx); err != nil { + t.Fatalf("%s stale cancellation failed", id) + } + } + done := make(chan struct{}) + called := make(chan struct{}, 1) + var text string + var terminal int + var first time.Duration + go func() { + defer close(done) + for event := range output { + if first == 0 { + first = time.Since(started) + } + if event.Type == proto.TypeFunctionCall { + select { + case called <- struct{}{}: + default: + } + } + if event.Type == proto.TypeDone { + var result proto.DonePayload + _ = event.DecodePayload(&result) + text = result.Content + terminal++ + } + } + }() + if interrupt { + select { + case <-called: + case <-done: + t.Fatal("native cancellation did not reach its function") + case <-ctx.Done(): + t.Fatal("native function admission timed out") + } + cancelStarted := time.Now() + if err := turn.Cancel(ctx); err != nil { + t.Fatalf("native interrupt failed (%T)", err) + } + t.Logf("turn=%s cancel_settled_ms=%d", id, time.Since(cancelStarted).Milliseconds()) + } + settlement, err := turn.AwaitSettlement(ctx) + if err != nil || !settlement.Reusable { + t.Fatalf("%s native settlement not reusable (error=%t reason=%s)", id, err != nil, settlement.Reason) + } + <-done + if terminal != 1 { + t.Fatalf("%s emitted %d terminals", id, terminal) + } + s := turn.(*Session) + if thread == "" { + thread = s.currentThreadID() + } + if thread == "" || s.currentThreadID() != thread || !s.rpc.Alive() || s.rpc.cmd.Process.Pid != pid { + t.Fatal("native owner/thread changed") + } + t.Logf("turn=%s first_event_ms=%d settled_ms=%d same_process=true same_thread=true", id, first.Milliseconds(), time.Since(started).Milliseconds()) + return turn, text + } + first, _ := run("cold", "Remember the exact code CEDAR-4729 for this conversation. Reply only SAVED. Do not call tools.", nil, false) + _, answer := run("warm", "What is the exact code I asked you to remember? Reply only the code. Do not call tools.", nil, false) + if strings.TrimSpace(answer) != "CEDAR-4729" { + t.Fatal("warm native Turn did not retain conversation memory") + } + cancelled, _ := run("cancel", "Call the hold function now with an empty object. Wait for its result before responding.", nil, true) + _, answer = run("followup", "What is the exact code I asked you to remember? Reply only the code. Do not call tools.", cancelled, false) + if strings.TrimSpace(answer) != "CEDAR-4729" { + t.Fatal("post-cancellation native Turn lost conversation memory") + } + if err := first.Cancel(ctx); err != nil { + t.Fatal("retired first Turn cancellation failed") + } + if err := e.Close(ctx); err != nil { + t.Fatal("native final cleanup failed") + } + if e.prepared.session.rpc.Alive() { + t.Fatal("native child remained alive after owner close") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/executor_test.go b/apps/parsar-daemon/internal/agent/codex/executor_test.go new file mode 100644 index 000000000..39413ecfa --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/executor_test.go @@ -0,0 +1,187 @@ +package codex + +import ( + "context" + "errors" + "os" + "os/exec" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func executorFixture(t *testing.T, mode string) (*Executor, string) { + t.Helper() + req, cfg, root := preparationFixture(t) + t.Setenv("OAC_TEST_EXECUTOR_MODE", mode) + e, err := newExecutor(t.Context(), req, cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := e.Close(ctx); err != nil { + t.Error(err) + } + }) + return e, root +} +func awaitExecutorTurn(t *testing.T, turn agent.Turn, out <-chan proto.Envelope) agent.TurnSettlement { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + settlement, err := turn.AwaitSettlement(ctx) + if err != nil { + t.Fatal(err) + } + count := 0 + for frame := range out { + if frame.Type == proto.TypeDone { + count++ + } + } + if count != 1 { + t.Fatalf("terminal count %d", count) + } + return settlement +} +func TestExecutorNormalTurnsKeepProcessAndThread(t *testing.T) { + e, root := executorFixture(t, "complete") + var previous agent.Turn + for _, id := range []string{"one", "two"} { + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), id, proto.TextInput("answer"), out) + if err != nil { + t.Fatal(err) + } + if !awaitExecutorTurn(t, turn, out).Reusable { + t.Fatal("healthy turn was not reusable") + } + if previous != nil { + if err := previous.Cancel(t.Context()); err != nil { + t.Fatal(err) + } + } + previous = turn + } + counts := map[string]int{} + pids := map[int]bool{} + for _, f := range preparationFrames(t, root) { + counts[f.Method]++ + pids[f.PID] = true + } + if counts["initialize"] != 1 || counts["thread/start"] != 1 || counts["turn/start"] != 2 || counts["turn/interrupt"] != 0 || len(pids) != 1 { + t.Fatal(counts, pids) + } + if !e.prepared.session.rpc.Alive() { + t.Fatal("normal completion closed executor") + } +} +func TestExecutorCancellationSettlesThenReuses(t *testing.T) { + e, root := executorFixture(t, "complete") + out := make(chan proto.Envelope, 20) + first, err := e.StartTurn(t.Context(), "cancel", proto.TextInput("hold"), out) + if err != nil { + t.Fatal(err) + } + if _, err := e.StartTurn(t.Context(), "overlap", proto.TextInput("answer"), make(chan proto.Envelope, 20)); err == nil { + t.Fatal("overlapping turn admitted") + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if err := first.Cancel(ctx); err != nil { + t.Fatal(err) + } + if !awaitExecutorTurn(t, first, out).Reusable { + t.Fatal("cancelled healthy executor unavailable") + } + secondOut := make(chan proto.Envelope, 20) + second, err := e.StartTurn(t.Context(), "next", proto.TextInput("hold"), secondOut) + if err != nil { + t.Fatal(err) + } + if err := first.Cancel(ctx); err != nil { + t.Fatal(err) + } + // A stale handle cannot interrupt its successor. + interrupts := 0 + for _, f := range preparationFrames(t, root) { + if f.Method == "turn/interrupt" { + interrupts++ + } + } + if interrupts != 1 { + t.Fatalf("stale cancellation sent %d interrupts", interrupts) + } + if err := second.Cancel(ctx); err != nil { + t.Fatal(err) + } + if !awaitExecutorTurn(t, second, secondOut).Reusable { + t.Fatal("second cancellation did not settle") + } +} +func TestExecutorStartErrorsRetainExactOwnership(t *testing.T) { + for _, mode := range []string{"start-error", "disconnect"} { + t.Run(mode, func(t *testing.T) { + e, _ := executorFixture(t, mode) + out := make(chan proto.Envelope, 20) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + turn, err := e.StartTurn(ctx, "before", proto.TextInput("answer"), out) + if turn != nil || !errors.Is(err, context.Canceled) { + t.Fatal(turn, err) + } + select { + case <-out: + t.Fatal("pre-admission output was retained or closed") + default: + } + turn, err = e.StartTurn(t.Context(), "after", proto.TextInput("answer"), out) + if turn == nil || err == nil { + t.Fatal("uncertain submission lost owner", err) + } + if awaitExecutorTurn(t, turn, out).Reusable { + t.Fatal("failed submission was reusable") + } + }) + } +} + +func TestExecutorCloseRetainsPlanUntilReaped(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=^TestJSONRPCClientFakeCodexProcess$", "--") + cmd.Env = append(os.Environ(), "CODEX_RPC_FAKE_PROCESS=1", "GORACE=atexit_sleep_ms=0") + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + rpc := NewJSONRPCClient(JSONRPCConfig{}) + rpc.cmd, rpc.stdin, rpc.alive = cmd, stdin, true + var reap sync.Once + t.Cleanup(func() { _ = cmd.Process.Kill(); reap.Do(rpc.waitChild) }) + _, cancel := context.WithCancel(t.Context()) + var cleaned atomic.Bool + e := &Executor{prepared: &Prepared{session: &Session{rpc: rpc, cancelFn: cancel}, plan: SessionPlan{Cleanup: func() { cleaned.Store(true) }}}} + ctx, stop := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer stop() + if err = e.Close(ctx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("unreaped close", err) + } + if cleaned.Load() { + t.Fatal("plan released before cleanup settled") + } + reap.Do(rpc.waitChild) + if err = e.Close(t.Context()); err != nil { + t.Fatal(err) + } + if !cleaned.Load() { + t.Fatal("cleanup retry did not release plan") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/executor_turn.go b/apps/parsar-daemon/internal/agent/codex/executor_turn.go new file mode 100644 index 000000000..216d26bbd --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/executor_turn.go @@ -0,0 +1,163 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" +) + +func (s *Session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + select { + case <-s.waitDone: + return s.settlement, s.settlementErr + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} + +func (s *Session) settleExecutorTurn(startErr error) { + defer close(s.waitDone) + if startErr == nil { + select { + case <-s.outputDone: + case <-s.rpc.Done(): + s.emitTerminal("codex: connection closed before settlement", true) + case <-s.cancelCtx.Done(): + s.emitTerminal("codex: execution owner closed", true) + } + } + // Detach the old callbacks before waiting for their captured Turn and native + // replies. Later frames cannot acquire this Turn or redirect to a successor. + if !s.nativeSettled.Load() || startErr != nil || !s.rpc.Alive() { + s.cancelFn() + s.settlementErr = s.rpc.Close() + } + s.rpc.detachHandlers() + s.operationMu.Lock() + s.operationsClosed = true + s.operationMu.Unlock() + s.stopSteering() + s.stopFunctionCalls() + s.stopCodexInteractionTimers() + if !s.nativeSettled.Load() || startErr != nil || !s.rpc.Alive() { + s.cancelFn() + s.settlementErr = s.rpc.Close() + s.settlement = agent.TurnSettlement{Reason: "native_execution_unavailable"} + } else { + s.settlement = agent.TurnSettlement{Reusable: true} + } + s.closeRunOutput() + s.operations.Wait() + if s.subagents != nil { + s.subagents.mu.Lock() + err := s.subagents.cancelResult + s.subagents.mu.Unlock() + if err != nil { + s.settlement = agent.TurnSettlement{Reason: "child_settlement_unconfirmed"} + s.settlementErr = errors.Join(err, s.rpc.Close()) + } + } + // The terminal callback has returned before detachHandlers completes. A + // concurrent cancellation owns only this Turn and finishes before reuse. + s.operationMu.Lock() + ready := s.cancelReady + s.operationMu.Unlock() + if ready != nil { + <-ready + if s.cancelErr != nil { + s.settlement.Reusable = false + s.settlement.Reason = "cancellation_unconfirmed" + s.settlementErr = errors.Join(s.cancelErr, s.rpc.Close()) + } + } + s.cancelFn() +} + +func (s *Session) beginOperation() bool { + if s.executor == nil { + return true + } + s.operationMu.Lock() + defer s.operationMu.Unlock() + if s.operationsClosed { + return false + } + s.operations.Add(1) + return true +} +func (s *Session) endOperation() { + if s.executor != nil { + s.operations.Done() + } +} + +func (s *Session) cancelExecutorTurn(ctx context.Context) error { + s.operationMu.Lock() + if s.operationsClosed { + s.operationMu.Unlock() + _, err := s.AwaitSettlement(ctx) + return err + } + s.cancelOnce.Do(func() { + s.cancelled.Store(true) + s.cancelReady = make(chan struct{}) + go func() { + defer close(s.cancelReady) + s.stopFunctionCalls() + turnID, active := s.stopSteering() + s.stopCodexInteractionTimers() + if !s.terminal.Load() && active { + if turnID == "" { + s.cancelErr = errors.New("codex: cancellation has no native turn identity") + s.cancelFn() + _ = s.rpc.Close() + return + } + interruptCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _, err := s.rpc.request(interruptCtx, "turn/interrupt", TurnInterruptParams{ThreadID: s.currentThreadID(), TurnID: turnID}, func(frame any) error { return s.rpc.writeFrameContext(interruptCtx, frame) }) + cancel() + if err != nil { + s.cancelErr = err + s.cancelFn() + _ = s.rpc.Close() + return + } + } + if s.subagents != nil { + s.cancelErr = s.cancelSubagentWork(s.cancelCtx) + } + }() + }) + s.operationMu.Unlock() + _, err := s.AwaitSettlement(ctx) + return err +} + +var _ agent.Turn = (*Session)(nil) + +// Server callbacks retain their originating Turn. MCP elicitation may be a +// standalone thread-scoped request in the native protocol; a supplied Turn ID +// must still match exactly. +func (s *Session) onServerRequest(method string, handler ServerRequestHandler) { + s.rpc.OnServerRequest(method, func(raw json.RawMessage, id any) (any, error) { + if s.executor != nil { + var scope struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + } + if json.Unmarshal(raw, &scope) != nil || !s.isRootThread(scope.ThreadID) || s.terminal.Load() || + (scope.TurnID != "" && !s.isRootTurn(scope.ThreadID, scope.TurnID)) || + (scope.TurnID == "" && method != "mcpServer/elicitation/request") { + return nil, errors.New("codex: request does not belong to the active turn") + } + } + if !s.beginOperation() { + return nil, errors.New("codex: turn has settled") + } + defer s.endOperation() + return handler(raw, id) + }) +} diff --git a/apps/parsar-daemon/internal/agent/codex/function_receipts.go b/apps/parsar-daemon/internal/agent/codex/function_receipts.go index 7a70518d4..8c594209d 100644 --- a/apps/parsar-daemon/internal/agent/codex/function_receipts.go +++ b/apps/parsar-daemon/internal/agent/codex/function_receipts.go @@ -26,6 +26,10 @@ type pendingFunction struct { } func (s *Session) SubmitFunctionResult(ctx context.Context, result proto.FunctionResultPayload) error { + if !s.beginOperation() { + return agent.ErrSteeringInactive + } + defer s.endOperation() if s.functions == nil { return agent.ErrUnknownFunctionCall } @@ -59,7 +63,11 @@ func (s *Session) SubmitFunctionResult(ctx context.Context, result proto.Functio // This operation owns both transport failure and native application. A generic // write deadline must not close the process after a native receipt has won. written := make(chan error, 1) + if !s.beginOperation() { + return agent.ErrUnknownFunctionCall + } go func() { + defer s.endOperation() if err := ctx.Err(); err != nil { written <- err return diff --git a/apps/parsar-daemon/internal/agent/codex/preparation.go b/apps/parsar-daemon/internal/agent/codex/preparation.go index 3c22dcb04..2d143f97b 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation.go @@ -130,10 +130,7 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg } if req.DisableExecutionEnvironment { if err := verifyNoExecutionEnvironment(cancelCtx, rpc); err != nil { - cancelFn() - _ = rpc.Close() - plan.Cleanup() - return nil, err + return p.preparationFailed(err) } } if s.observeSubagentIdentities { @@ -148,18 +145,12 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg } if plan.mcpServers != nil { if err := verifyMCPConfig(cancelCtx, rpc, plan); err != nil { - cancelFn() - _ = rpc.Close() - plan.Cleanup() - return nil, err + return p.preparationFailed(err) } } if len(skillRoots) > 0 { if err := setSkillExtraRoots(cancelCtx, rpc, skillRoots); err != nil { - cancelFn() - _ = rpc.Close() - plan.Cleanup() - return nil, fmt.Errorf("codex: register skill root: %w", err) + return p.preparationFailed(fmt.Errorf("codex: register skill root: %w", err)) } } @@ -168,6 +159,8 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg } func (p *Prepared) preparationFailed(cause error) (*Prepared, error) { - _ = p.Close() + if err := p.Close(); err != nil { + return p, errors.Join(cause, err) + } return nil, cause } diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go b/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go index d1b5a7267..237d4f329 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go @@ -3,6 +3,7 @@ package codex import ( "bufio" "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -139,6 +140,9 @@ func TestPreparationFakeCodexProcess(t *testing.T) { frames := json.NewEncoder(log) output := json.NewEncoder(os.Stdout) scanner := bufio.NewScanner(os.Stdin) + turnNumber := 0 + currentTurn := "" + executorMode := os.Getenv("OAC_TEST_EXECUTOR_MODE") for scanner.Scan() { var frame preparationFrame if json.Unmarshal(scanner.Bytes(), &frame) != nil { @@ -182,11 +186,41 @@ func TestPreparationFakeCodexProcess(t *testing.T) { } result = map[string]any{"thread": map[string]string{"id": "fixture-native-thread"}, "model": "fixture-model"} case "turn/start": + if executorMode != "" { + turnNumber++ + currentTurn = fmt.Sprintf("fixture-turn-%d", turnNumber) + if turnNumber > 1 { + _ = output.Encode(map[string]any{"method": "turn/started", "params": map[string]any{"threadId": "fixture-native-thread", "turn": map[string]string{"id": fmt.Sprintf("fixture-turn-%d", turnNumber-1)}}}) + } + if executorMode == "disconnect" { + os.Exit(0) + } + if executorMode == "start-error" { + _ = output.Encode(map[string]any{"id": frame.ID, "error": map[string]any{"code": -32603, "message": "start rejected"}}) + continue + } + result = map[string]any{"turn": map[string]string{"id": currentTurn}} + break + } result = map[string]any{"turn": map[string]string{"id": "fixture-native-turn"}} } if output.Encode(map[string]any{"jsonrpc": "2.0", "id": frame.ID, "result": result}) != nil { os.Exit(5) } + + if executorMode != "" && (frame.Method == "turn/start" || frame.Method == "turn/interrupt") { + if frame.Method == "turn/start" { + _ = output.Encode(map[string]any{"method": "turn/started", "params": map[string]any{"threadId": "fixture-native-thread", "turn": map[string]string{"id": currentTurn}}}) + } + if frame.Method == "turn/interrupt" || !strings.Contains(string(frame.Params), "hold") { + status := "completed" + if frame.Method == "turn/interrupt" { + status = "interrupted" + } + _ = output.Encode(map[string]any{"method": "turn/completed", "params": map[string]any{"threadId": "fixture-native-thread", "turn": map[string]string{"id": currentTurn, "status": status}}}) + } + continue + } if frame.Method == "turn/start" { _ = output.Encode(map[string]any{"jsonrpc": "2.0", "method": "turn/started", "params": map[string]any{"threadId": "fixture-native-thread", "turn": map[string]string{"id": "fixture-native-turn"}}}) if os.Getenv("OAC_TEST_PREPARATION_OBSERVE") == "1" { diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go index 15bf5d432..45eb0e8a3 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go @@ -30,6 +30,7 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { for _, start := range []bool{false, true} { t.Run(map[bool]string{false: "disconnect-before-start", true: "transfer-and-cancel"}[start], func(t *testing.T) { req, cfg, root := preparationFixture(t) + t.Setenv("OAC_TEST_EXECUTOR_MODE", "complete") environment, session := uuid.NewString(), uuid.NewString() if err := os.MkdirAll(req.WorkDir, 0700); err != nil { t.Fatal(err) @@ -58,13 +59,13 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { return nil, errors.New("ordinary Factory must not run") }) prepared := make(chan *Prepared, 1) - registry.RegisterPreparation("codex", false, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - p, err := newPreparation(ctx, req, cfg) + registry.RegisterExecutor("codex", func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + e, err := newExecutor(ctx, req, cfg) if err != nil { return nil, err } - prepared <- p - return p, nil + prepared <- e.prepared + return e, nil }) sender := make(preparationWireSender, 64) r, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender, LocalWorkspace: binding}) @@ -110,7 +111,7 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { } } } - send(proto.TypeExecutionPrepare, "prepare-request", proto.ExecutionPreparePayload{Configuration: req}) + send(proto.TypeExecutionPrepare, "prepare-request", proto.ExecutionPreparePayload{SessionID: session, Configuration: req}) ready := await("ready") p := <-prepared assertPreparationOnly(t, root) @@ -119,7 +120,7 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { t.Fatal("preparation became a Run") } if start { - input := proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "actual-run", Input: proto.TextInput("actual input")} + input := proto.ExecutionStartPayload{ExecutorID: ready.ExecutorID, Handle: ready.Handle, RunID: "actual-run", Input: proto.TextInput("hold")} send(proto.TypeExecutionStart, "prepare-request", input) await("started") frames := waitPreparationMethod(t, root, "turn/start") diff --git a/apps/parsar-daemon/internal/agent/codex/prepared.go b/apps/parsar-daemon/internal/agent/codex/prepared.go index a83cf6ffe..b4e3a0b78 100644 --- a/apps/parsar-daemon/internal/agent/codex/prepared.go +++ b/apps/parsar-daemon/internal/agent/codex/prepared.go @@ -88,7 +88,9 @@ func (p *Prepared) Close() error { p.mu.Unlock() p.session.cancelFn() err := p.session.rpc.Close() - p.plan.Cleanup() + if err == nil { + p.plan.Cleanup() + } return err } diff --git a/apps/parsar-daemon/internal/agent/codex/rpc.go b/apps/parsar-daemon/internal/agent/codex/rpc.go index ec52afebb..d078c4588 100644 --- a/apps/parsar-daemon/internal/agent/codex/rpc.go +++ b/apps/parsar-daemon/internal/agent/codex/rpc.go @@ -91,11 +91,13 @@ type JSONRPCClient struct { pendingMu sync.Mutex pending map[string]*pendingRequest + handlerWork sync.WaitGroup handlersMu sync.RWMutex notifHandlers map[string][]NotificationHandler serverReqHandlers map[string]ServerRequestHandler anyNotifHandler NotificationHandler + readers sync.WaitGroup closeOnce sync.Once doneCh chan struct{} } @@ -206,8 +208,9 @@ func (c *JSONRPCClient) Start(ctx context.Context, init InitializeParams) (Initi c.alive = true c.mu.Unlock() - go c.readStdoutLoop() - go c.pumpStderr() + c.readers.Add(2) + go func() { defer c.readers.Done(); c.readStdoutLoop() }() + go func() { defer c.readers.Done(); c.pumpStderr() }() go c.waitChild() initCtx, cancel := context.WithTimeout(ctx, rpcInitTimeout) @@ -415,7 +418,13 @@ func (c *JSONRPCClient) handleNotification(method string, rawFrame []byte) { c.handlersMu.RLock() handlers := append([]NotificationHandler{}, c.notifHandlers[method]...) any := c.anyNotifHandler + if len(handlers) > 0 || any != nil { + c.handlerWork.Add(1) + } c.handlersMu.RUnlock() + if len(handlers) > 0 || any != nil { + defer c.handlerWork.Done() + } if len(handlers) == 0 && any == nil { c.cfg.Logger.Debug("codex rpc unhandled notification", "tag", c.cfg.LogTag, "method", method) return @@ -438,6 +447,9 @@ func (c *JSONRPCClient) handleServerRequest(rawFrame []byte, rawID json.RawMessa c.handlersMu.RLock() h, ok := c.serverReqHandlers[method] + if ok { + c.handlerWork.Add(1) + } c.handlersMu.RUnlock() if !ok { // Method-not-found per JSON-RPC 2.0 — codex hangs if we drop it. @@ -445,6 +457,7 @@ func (c *JSONRPCClient) handleServerRequest(rawFrame []byte, rawID json.RawMessa return } go func() { + defer c.handlerWork.Done() defer func() { if r := recover(); r != nil { _ = c.SendServerError(id, -32603, fmt.Sprintf("panic in %s handler: %v", method, r), nil) @@ -525,3 +538,25 @@ func truncate(b []byte, n int) []byte { } return b[:n] } + +// detachHandlers fences registration before joining captured callbacks, including +// asynchronous server replies. New notifications have no Turn destination. +func (c *JSONRPCClient) detachHandlers() { + c.handlersMu.Lock() + clear(c.notifHandlers) + clear(c.serverReqHandlers) + c.anyNotifHandler = nil + c.handlersMu.Unlock() + c.handlerWork.Wait() +} + +func (c *JSONRPCClient) awaitReaders(ctx context.Context) error { + done := make(chan struct{}) + go func() { c.readers.Wait(); close(done) }() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/server_requests.go b/apps/parsar-daemon/internal/agent/codex/server_requests.go index 69aa69d3c..656ac6ca7 100644 --- a/apps/parsar-daemon/internal/agent/codex/server_requests.go +++ b/apps/parsar-daemon/internal/agent/codex/server_requests.go @@ -195,6 +195,10 @@ func (s *Session) handleCodexUserInput(raw json.RawMessage, rpcID any) (any, err } func (s *Session) submitCodexPermission(requestID string, decision proto.PermissionDecisionPayload) error { + if !s.beginOperation() { + return agent.ErrSteeringInactive + } + defer s.endOperation() s.interactions.mu.Lock() pending, ok := s.interactions.permissions[requestID] if ok { @@ -243,6 +247,10 @@ func (s *Session) sendCodexPermissionReply(pending pendingCodexPermission, appro } func (s *Session) submitCodexUserInput(askID string, decision proto.PromptForUserChoiceDecisionPayload) error { + if !s.beginOperation() { + return agent.ErrSteeringInactive + } + defer s.endOperation() s.interactions.mu.Lock() pending, ok := s.interactions.asks[askID] if ok { @@ -311,6 +319,10 @@ func (s *Session) submitCodexUserInput(askID string, decision proto.PromptForUse } func (s *Session) expireCodexPermission(requestID string) { + if !s.beginOperation() { + return + } + defer s.endOperation() s.interactions.mu.Lock() pending, ok := s.interactions.permissions[requestID] if ok { @@ -326,6 +338,10 @@ func (s *Session) expireCodexPermission(requestID string) { } func (s *Session) expireCodexAsk(askID string) { + if !s.beginOperation() { + return + } + defer s.endOperation() s.interactions.mu.Lock() pending, ok := s.interactions.asks[askID] if ok { diff --git a/apps/parsar-daemon/internal/agent/codex/session.go b/apps/parsar-daemon/internal/agent/codex/session.go index f9f624f1d..bbd808693 100644 --- a/apps/parsar-daemon/internal/agent/codex/session.go +++ b/apps/parsar-daemon/internal/agent/codex/session.go @@ -63,6 +63,15 @@ func Factory(ctx context.Context, req proto.PromptRequestPayload, out chan<- pro // 5. turn/completed emits TypeDone + closes out. Cancel can short-cut // this by killing the child early. type Session struct { + retiredTurns map[string]bool + executor *Executor + outputDone chan struct{} + nativeSettled atomic.Bool + settlement agent.TurnSettlement + settlementErr error + operationMu sync.Mutex + operations sync.WaitGroup + operationsClosed bool toolEnvironment bool nativeHome string subagents *subagentObservations @@ -152,14 +161,14 @@ func (s *Session) registerHandlers() { rpc.OnNotification("error", s.onErrorNotif) rpc.OnNotification("hook/completed", s.onToolEnvironmentHook) - rpc.OnServerRequest("item/commandExecution/requestApproval", s.handleCodexCommandApproval) - rpc.OnServerRequest("item/fileChange/requestApproval", s.handleCodexFileApproval) - rpc.OnServerRequest("item/permissions/requestApproval", s.handleCodexPermissionsApproval) + s.onServerRequest("item/commandExecution/requestApproval", s.handleCodexCommandApproval) + s.onServerRequest("item/fileChange/requestApproval", s.handleCodexFileApproval) + s.onServerRequest("item/permissions/requestApproval", s.handleCodexPermissionsApproval) // Older app-server releases used this unseparated method name. - rpc.OnServerRequest("item/permissionsRequestApproval", s.handleCodexPermissionsApproval) - rpc.OnServerRequest("item/tool/requestUserInput", s.handleCodexUserInput) - rpc.OnServerRequest("item/tool/call", s.handleFunctionCall) - rpc.OnServerRequest("mcpServer/elicitation/request", s.handleCodexMCPElicitation) + s.onServerRequest("item/permissionsRequestApproval", s.handleCodexPermissionsApproval) + s.onServerRequest("item/tool/requestUserInput", s.handleCodexUserInput) + s.onServerRequest("item/tool/call", s.handleFunctionCall) + s.onServerRequest("mcpServer/elicitation/request", s.handleCodexMCPElicitation) } func (s *Session) onTurnStarted(raw json.RawMessage) { @@ -193,6 +202,7 @@ func (s *Session) onTurnCompleted(raw json.RawMessage) { if json.Unmarshal(raw, &p) != nil || !s.isRootTurn(p.ThreadID, p.Turn.ID) { return } + s.nativeSettled.Store(true) s.stopSteering() usage := p.Turn.Usage @@ -392,6 +402,9 @@ func (s *Session) emitUsage(u TurnUsage) { } func (s *Session) emitTerminal(message string, asError bool) { + if s.executor != nil { + defer s.finishAfterTerminal() + } if !s.terminal.CompareAndSwap(false, true) { return } @@ -442,6 +455,9 @@ func (s *Session) closeOut() { s.outMu.Lock() s.outClosed = true close(s.out) + if s.outputDone != nil { + close(s.outputDone) + } s.outMu.Unlock() }) } diff --git a/apps/parsar-daemon/internal/agent/codex/session_cancel.go b/apps/parsar-daemon/internal/agent/codex/session_cancel.go index 2b1fe0080..25024d5b5 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_cancel.go +++ b/apps/parsar-daemon/internal/agent/codex/session_cancel.go @@ -7,6 +7,9 @@ import ( ) func (s *Session) Cancel(ctx context.Context) error { + if s.executor != nil { + return s.cancelExecutorTurn(ctx) + } s.cancelled.Store(true) s.cancelOnce.Do(func() { s.cancelReady = make(chan struct{}) diff --git a/apps/parsar-daemon/internal/agent/codex/session_run.go b/apps/parsar-daemon/internal/agent/codex/session_run.go index 170708625..21736ba88 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_run.go +++ b/apps/parsar-daemon/internal/agent/codex/session_run.go @@ -16,15 +16,32 @@ func (s *Session) run(plan SessionPlan, req proto.PromptRequestPayload) { defer s.cleanup() defer s.closeRunOutput() - if err := s.resolveThread(req, plan); err != nil { + if err := s.startNative(s.cancelCtx, plan, req); err != nil { s.emitTerminal(err.Error(), true) return } + // Block until terminal handlers close the RPC child or cancellation arrives. + select { + case <-s.rpc.Done(): + if !s.cancelled.Load() && s.cancelCtx.Err() == nil { + s.emitTerminal("codex: connection closed before the run completed", true) + } + case <-s.cancelCtx.Done(): + _ = s.rpc.Close() + } +} + +func (s *Session) startNative(ctx context.Context, plan SessionPlan, req proto.PromptRequestPayload) error { + if s.currentThreadID() == "" { + if err := s.resolveThread(req, plan); err != nil { + return err + } + } + input, err := nativeInput(req.Input) if err != nil { - s.emitTerminal("codex: empty prompt", true) - return + return err } turnParams := TurnStartParams{ ThreadID: s.currentThreadID(), @@ -33,8 +50,7 @@ func (s *Session) run(plan SessionPlan, req proto.PromptRequestPayload) { if plan.CollaborationMode != "" { model := strings.TrimSpace(s.resolvedModel) if model == "" { - s.emitTerminal("codex: collaboration mode requires a resolved model", true) - return + return fmt.Errorf("codex: collaboration mode requires a resolved model") } var developerInstructions *string if plan.SystemPrompt != "" { @@ -48,22 +64,13 @@ func (s *Session) run(plan SessionPlan, req proto.PromptRequestPayload) { }, } } - turnCtx, turnCancel := context.WithTimeout(s.cancelCtx, 10*time.Second) + turnCtx, turnCancel := context.WithTimeout(ctx, 10*time.Second) _, ackErr := s.rpc.requestWithResult(turnCtx, "turn/start", turnParams, s.bindTurnResult) turnCancel() if ackErr != nil { s.cfg.logger.Warn("codex: turn/start ack failed", "run_id", s.runID, "err", ackErr) - s.emitTerminal(fmt.Sprintf("codex: turn/start: %v", ackErr), true) - return + return fmt.Errorf("codex: turn/start: %w", ackErr) } - // Block until terminal handlers close the RPC child or cancellation arrives. - select { - case <-s.rpc.Done(): - if !s.cancelled.Load() && s.cancelCtx.Err() == nil { - s.emitTerminal("codex: connection closed before the run completed", true) - } - case <-s.cancelCtx.Done(): - _ = s.rpc.Close() - } + return nil } diff --git a/apps/parsar-daemon/internal/agent/codex/session_steering.go b/apps/parsar-daemon/internal/agent/codex/session_steering.go index 4ad2716fe..f0d6be931 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_steering.go +++ b/apps/parsar-daemon/internal/agent/codex/session_steering.go @@ -38,6 +38,10 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP } func (s *Session) steer(ctx context.Context, input proto.PromptSteerPayload, written func()) error { + if !s.beginOperation() { + return agent.ErrSteeringInactive + } + defer s.endOperation() ctx, cancel := context.WithCancel(ctx) defer cancel() s.steering.mu.Lock() @@ -96,7 +100,7 @@ func (s *Session) steer(ctx context.Context, input proto.PromptSteerPayload, wri } func (s *Session) startSteering(threadID, turnID string) bool { - if !s.isRootThread(threadID) || turnID == "" { + if !s.isRootThread(threadID) || turnID == "" || s.retiredTurns[turnID] { return false } s.steering.mu.Lock() diff --git a/apps/parsar-daemon/internal/agent/codex/session_usage.go b/apps/parsar-daemon/internal/agent/codex/session_usage.go index 309d05b99..17e672284 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_usage.go +++ b/apps/parsar-daemon/internal/agent/codex/session_usage.go @@ -30,7 +30,7 @@ func (s *Session) onUsageUpdated(raw json.RawMessage) { s.usageMu.Unlock() return } - if s.terminal.Load() { + if s.terminal.Load() || s.retiredTurns[p.TurnID] { return } var observed *TurnUsage diff --git a/apps/parsar-daemon/internal/agent/executor.go b/apps/parsar-daemon/internal/agent/executor.go new file mode 100644 index 000000000..1e1346cf2 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/executor.go @@ -0,0 +1,61 @@ +package agent + +import ( + "context" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// TurnSettlement describes native readiness after this Turn has fully drained. +// A false result must carry a reason; it requires confirmed Executor.Close. +type TurnSettlement struct { + Reusable bool + Reason string +} + +// Turn owns one output stream and never retargets cancellation to a successor. +type Turn interface { + Session + CancellationOutcome() proto.DonePayload + // Success confirms closed output and settled native input, function, + // interaction and child-work obligations. Errors cannot prove cancellation. + AwaitSettlement(context.Context) (TurnSettlement, error) +} + +// Executor retains a fixed native configuration across independently owned Turns. +type Executor interface { + // A nil Turn guarantees no input was submitted or output retained and leaves + // out with the caller. A non-nil Turn owns out, including on error; input + // may have been submitted and must never be replayed automatically. + StartTurn(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (Turn, error) + // Success confirms all native work and owned output streams have stopped. + // It does not establish an otherwise unknown Turn result. An error retains + // resource ownership; another caller may await or retry Close. + Close(context.Context) error +} + +// A failed factory retains any unconfirmed cleanup in its non-nil Executor. +type ExecutorFactory func(context.Context, proto.PromptRequestPayload) (Executor, error) + +func (r *Registry) RegisterExecutor(kind string, factory ExecutorFactory) { + r.mu.Lock() + defer r.mu.Unlock() + info, exists := r.kinds[kind] + if !exists || factory == nil { + panic("agent.Registry.RegisterExecutor: registered kind and factory required") + } + r.executors[kind] = factory + info.Capabilities.Preparation = true + r.kinds[kind] = info +} + +func (r *Registry) ResolveExecutor(kind string) (ExecutorFactory, error) { + r.mu.RLock() + defer r.mu.RUnlock() + factory := r.executors[kind] + if factory == nil { + return nil, fmt.Errorf("agent: executor unavailable for %q", kind) + } + return factory, nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/connection.go b/apps/parsar-daemon/internal/agent/mcode/connection.go new file mode 100644 index 000000000..420155530 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/connection.go @@ -0,0 +1,146 @@ +package mcode + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" +) + +// connection is the process and ACP owner. It keeps draining while no Turn owns +// output; RPC identities remain unique across every Turn on this connection. +type connection struct { + process *clirunner.Process + exited chan struct{} + exitErr error + writeMu sync.Mutex + transportMu sync.Mutex + nextID int + responses map[string]chan rpcFrame + current *Session +} + +func (c *connection) read() { + defer close(c.exited) + stderrDone := make(chan struct{}) + go func() { defer close(stderrDone); _, _ = io.Copy(io.Discard, c.process.Stderr) }() + scanner := bufio.NewScanner(c.process.Stdout) + scanner.Buffer(make([]byte, 64*1024), 16*1024*1024) + var readErr error + for scanner.Scan() { + var frame rpcFrame + if json.Unmarshal(scanner.Bytes(), &frame) != nil { + readErr = fmt.Errorf("mcode: malformed ACP response") + c.process.Cancel() + break + } + c.transportMu.Lock() + response, owner := c.responses[string(frame.ID)], c.current + c.transportMu.Unlock() + if frame.Method == "" && response != nil { + select { + case response <- frame: + default: + } + continue + } + if owner == nil { + // A late request has no Turn authority. Never hold it for a successor. + if frame.Method != "" && len(frame.ID) > 0 { + if err := c.write(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32601, Message: "No active Turn"}}); err != nil { + readErr = err + c.process.Cancel() + break + } + } + continue + } + select { + case owner.frames <- frame: + case <-owner.finished: + case <-c.process.Context().Done(): + } + } + if scanner.Err() != nil { + readErr = fmt.Errorf("mcode: ACP stream read failed") + c.process.Cancel() + } + waitErr := c.process.Wait() + <-stderrDone + if readErr != nil { + c.exitErr = readErr + } else { + c.exitErr = waitErr + } +} + +func (c *connection) write(frame rpcFrame) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return json.NewEncoder(c.process.Stdin).Encode(frame) +} + +func (c *connection) reserveResponse() (string, chan rpcFrame) { + c.transportMu.Lock() + defer c.transportMu.Unlock() + c.nextID++ + id := strconv.Itoa(c.nextID) + reply := make(chan rpcFrame, 1) + c.responses[id] = reply + return id, reply +} + +func (c *connection) removeResponse(id string) { + c.transportMu.Lock() + delete(c.responses, id) + c.transportMu.Unlock() +} + +func (c *connection) setCurrent(s *Session) { + c.transportMu.Lock() + c.current = s + c.transportMu.Unlock() +} + +// An ordered ACP control response fences notifications left in the pipe by the +// preceding prompt. No Turn is attached while this barrier drains them. +func (c *connection) barrier(ctx context.Context, session, model string) error { + id, reply := c.reserveResponse() + defer c.removeResponse(id) + raw, _ := json.Marshal(map[string]string{"sessionId": session, "configId": "model", "value": model}) + err := c.writeContext(ctx, rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "session/set_config_option", Params: raw}) + if err != nil { + return err + } + select { + case frame := <-reply: + if frame.Error != nil { + return fmt.Errorf("mcode: native readiness barrier failed") + } + return nil + case <-c.exited: + return fmt.Errorf("mcode: native process exited") + case <-ctx.Done(): + return ctx.Err() + } +} + +// Drain the deadline callback before releasing the caller's Turn/start guard; +// an old writer deadline must never kill a subsequent owner. +func (c *connection) writeContext(ctx context.Context, frame rpcFrame) error { + callbackDone := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + defer close(callbackDone) + c.process.Cancel() + }) + err := c.write(frame) + if !stop() { + <-callbackDone + } + return err +} diff --git a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go index a47e4eb1c..e9cb6f18c 100644 --- a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go @@ -114,7 +114,7 @@ func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { if err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = session.Cancel(context.Background()) }) + t.Cleanup(func() { _ = resource.(*prepared).Cancel(context.Background()) }) select { case event := <-out: var call proto.ToolCallPayload @@ -124,8 +124,12 @@ func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { case <-ctx.Done(): t.Fatal("MCP start was not observed") } - if err := session.Cancel(ctx); err != nil { - t.Fatal(err) + if err := session.Cancel(ctx); err == nil { + t.Fatal("unconfirmed MCP completion authorized reusable cancellation") + } + settlement, err := session.(*Session).AwaitSettlement(ctx) + if err != nil || settlement.Reusable { + t.Fatal("pending MCP work retained a reusable owner", settlement, err) } closed, done := false, false for event := range out { diff --git a/apps/parsar-daemon/internal/agent/mcode/execution.go b/apps/parsar-daemon/internal/agent/mcode/execution.go index b8c94bbed..6a3eb80c4 100644 --- a/apps/parsar-daemon/internal/agent/mcode/execution.go +++ b/apps/parsar-daemon/internal/agent/mcode/execution.go @@ -13,7 +13,7 @@ func SupportsExecution(version string) bool { } func validateExecutionRequest(req proto.PromptRequestPayload) error { - if !req.ReleaseOnCompletion || !req.DisableExecutionEnvironment || req.WorkDir != "" || req.AgentStateKey == "" || req.LocalEnvironment != nil || req.RequireExistingNativeSession || len(req.FunctionTools) != 0 || (req.MCPHTTPServers != nil && len(*req.MCPHTTPServers) != 0) { + if !req.DisableExecutionEnvironment || req.WorkDir != "" || req.AgentStateKey == "" || req.LocalEnvironment != nil || req.RequireExistingNativeSession || len(req.FunctionTools) != 0 || (req.MCPHTTPServers != nil && len(*req.MCPHTTPServers) != 0) { return fmt.Errorf("mcode: unsupported execution configuration") } if req.ExecutionControls == nil || req.ExecutionControls.OutputFormat != nil || req.ExecutionControls.WebSearch != "disabled" || (req.ExecutionControls.TextVerbosity != "" && req.ExecutionControls.TextVerbosity != "medium") { diff --git a/apps/parsar-daemon/internal/agent/mcode/executor.go b/apps/parsar-daemon/internal/agent/mcode/executor.go new file mode 100644 index 000000000..632bb6a40 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/executor.go @@ -0,0 +1,173 @@ +package mcode + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +var errTurnCancelled = errors.New("mcode: Turn cancelled before submission") + +type executor struct { + mu sync.Mutex + connection *connection + req proto.PromptRequestPayload + opts launchOptions + nativeSession, model string + active *Session + starting, closed, invalid bool +} + +var _ agent.Executor = (*executor)(nil) +var _ agent.Turn = (*Session)(nil) + +// NewExecutorFactory fixes the deployment workspace once; nil selects none. +func NewExecutorFactory(config *WorkspaceConfig) agent.ExecutorFactory { + var frozen *WorkspaceConfig + if config != nil { + value := *config + value.ProtectedDirs = append([]string(nil), config.ProtectedDirs...) + value.AllowedDomains = append([]string(nil), config.AllowedDomains...) + frozen = &value + } + return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + if ctx == nil { + ctx = context.Background() + } + if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + return nil, fmt.Errorf("mcode: Executor configuration cannot contain Turn input") + } + var err error + var opts launchOptions + binary := defaultBinary() + if frozen == nil { + opts, err = prepareOptions(ctx, req) + } else { + binary = frozen.Binary + opts, err = prepareWorkspaceOptions(ctx, *frozen, req) + } + if err != nil { + return nil, err + } + return newExecutor(ctx, req, opts, binary) + } +} + +func newExecutor(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, binary string) (*executor, error) { + bootstrap, err := launch(ctx, req, opts, binary, nil) + if err != nil { + return nil, err + } + e := &executor{connection: bootstrap.connection, req: req, opts: opts} + err = bootstrap.prepareNative() + e.connection.setCurrent(nil) + close(bootstrap.finished) + if err == nil { + err = ctx.Err() + } + if err != nil { + cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if closeErr := e.Close(cleanup); closeErr != nil { + return e, err + } + return nil, err + } + e.nativeSession, e.model = bootstrap.sessionID, bootstrap.nativeModel + return e, nil +} + +func (e *executor) StartTurn(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + if ctx == nil { + ctx = context.Background() + } + if strings.TrimSpace(runID) == "" || input.Validate() != nil || out == nil || ctx.Err() != nil { + return nil, fmt.Errorf("mcode: Turn requires live context, identity, input and output") + } + text, err := input.TextOnly() + if err != nil { + return nil, err + } + e.mu.Lock() + if e.closed || e.invalid || e.starting || e.active != nil { + e.mu.Unlock() + return nil, fmt.Errorf("mcode: Executor is unavailable") + } + e.starting = true + e.mu.Unlock() + + // No model input is sent and no output channel retained until this fence. + barrier, cancel := context.WithTimeout(ctx, 60*time.Second) + err = e.connection.barrier(barrier, e.nativeSession, e.model) + cancel() + e.mu.Lock() + defer e.mu.Unlock() + e.starting = false + if err != nil || e.closed || ctx.Err() != nil { + if err != nil { + e.invalid = true + return nil, err + } + return nil, fmt.Errorf("mcode: Executor closed before input") + } + select { + case <-e.connection.exited: + e.invalid = true + return nil, fmt.Errorf("mcode: native process exited") + default: + } + req := e.req + req.RunID, req.Input = runID, proto.TextInput(text) + s := newTurnSession(e.connection.process.Context(), req, e.opts, e.connection, out) + s.executor, s.sessionID, s.nativeModel = e, e.nativeSession, e.model + s.settled, s.inputDone = make(chan struct{}), make(chan struct{}) + s.outputContext, s.outputCancel = context.WithCancel(context.Background()) + e.active = s + e.connection.setCurrent(s) + go s.runExecutorTurn() + go func() { + select { + case <-ctx.Done(): + cancellation, stop := context.WithTimeout(context.Background(), 10*time.Second) + defer stop() + _ = s.Cancel(cancellation) + case <-s.settled: + } + }() + return s, nil +} + +// Close keeps the native owner reachable until process, pipes and active Turn +// settlement all finish. Repeating it waits on those same owners. +func (e *executor) Close(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + e.mu.Lock() + e.closed = true + current := e.active + if current != nil { + current.outputCancel() + } + e.connection.process.Cancel() + e.mu.Unlock() + select { + case <-e.connection.exited: + case <-ctx.Done(): + return ctx.Err() + } + if current != nil { + select { + case <-current.settled: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/executor_native_test.go b/apps/parsar-daemon/internal/agent/mcode/executor_native_test.go new file mode 100644 index 000000000..4a1e23949 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/executor_native_test.go @@ -0,0 +1,176 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// This opt-in test makes real model calls and uses an isolated native home. +// Provider options are read from a private file and never included in failures. +func TestNativeMCodeExecutorReuse(t *testing.T) { + binary, options := os.Getenv("OAC_RUNTIME_MCODE_BIN"), os.Getenv("OAC_TEST_MCODE_REAL_OPTIONS") + if binary == "" || options == "" { + t.Skip("native executable and private provider options required") + } + ctx, cancel := context.WithTimeout(t.Context(), 8*time.Minute) + defer cancel() + if version, err := CheckCLIAvailable(ctx, binary); err != nil || version != SupportedVersion { + t.Fatal("pinned native version verification failed") + } + raw, err := os.ReadFile(options) + if err != nil { + t.Fatal("private provider options unavailable") + } + req := executionRequest(t) + req.ReleaseOnCompletion = false + req.RunID, req.Input, req.ConversationID = "", nil, "" + if json.Unmarshal(raw, &req.AgentOptions) != nil { + t.Fatal("invalid private provider options") + } + value, err := NewExecutorFactory(nil)(ctx, req) + if err != nil { + t.Fatal("native Executor preparation failed") + } + e := value.(*executor) + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 15*time.Second) + defer stop() + if e.Close(cleanup) != nil { + t.Error("native owner cleanup failed") + } + }() + pid, nativeID := e.connection.process.Cmd.Process.Pid, e.nativeSession + assertOwner := func() { + t.Helper() + if e.connection.process.Cmd.Process.Pid != pid || e.nativeSession != nativeID { + t.Fatal("native owner changed") + } + select { + case <-e.connection.exited: + t.Fatal("native process exited") + default: + } + } + run := func(id, prompt string) agent.Turn { + t.Helper() + out := make(chan proto.Envelope, 256) + turn, startErr := e.StartTurn(ctx, id, proto.TextInput(prompt), out) + if startErr != nil || turn == nil { + t.Fatal("native Turn admission failed") + } + content := "" + doneCount := 0 + for event := range out { + if event.ID != id { + t.Fatal("event crossed Turn boundary") + } + switch event.Type { + case proto.TypeError: + t.Fatal("native Turn reported an error") + case proto.TypeDone: + var done proto.DonePayload + if json.Unmarshal(event.Payload, &done) != nil { + t.Fatal("invalid completion") + } + content = done.Content + doneCount++ + } + } + settlement, settleErr := turn.AwaitSettlement(ctx) + if settleErr != nil || !settlement.Reusable { + t.Fatal("normal Turn did not establish reusable settlement") + } + if doneCount != 1 || !strings.Contains(content, "ORCHID-72-BLUE") { + t.Fatal("native conversation did not retain the private test marker") + } + assertOwner() + t.Logf("Verified marker recall and retained native owner for %s", id) + return turn + } + first := run("first", "Remember the exact marker ORCHID-72-BLUE for this conversation. Reply with only that marker.") + run("second", "What exact marker did I ask you to remember? Reply with only that marker.") + out := make(chan proto.Envelope, 256) + interrupted, err := e.StartTurn(ctx, "cancelled", proto.TextInput("Produce 2000 numbered lines now. Each line must contain a different sentence about rain. Start immediately at line 1 and continue without stopping or summarizing."), out) + if err != nil || interrupted == nil { + t.Fatal("cancellation Turn admission failed") + } + streaming := false + for !streaming { + select { + case event, ok := <-out: + if !ok || event.Type == proto.TypeDone || event.Type == proto.TypeError { + t.Fatal("Turn ended before live cancellation could be tested") + } + if event.Type == proto.TypeDelta { + streaming = true + } + case <-ctx.Done(): + t.Fatal("native stream did not begin before deadline") + } + } + select { + case <-interrupted.(*Session).finished: + t.Fatal("cancellation was not in flight") + default: + } + staleCtx, staleStop := context.WithTimeout(ctx, 5*time.Second) + if first.Cancel(staleCtx) != nil { + staleStop() + t.Fatal("stale cancellation failed") + } + staleStop() + interrupted.(*Session).mu.Lock() + wasCancelled := interrupted.(*Session).cancelled + interrupted.(*Session).mu.Unlock() + if wasCancelled { + t.Fatal("stale cancellation targeted current Turn") + } + stopCtx, stop := context.WithTimeout(ctx, 30*time.Second) + cancelErr := interrupted.Cancel(stopCtx) + settlement, settleErr := interrupted.AwaitSettlement(stopCtx) + stop() + if settleErr != nil || !settlement.Reusable { + if cancelErr == nil { + t.Fatal("uncertain cancellation reported success") + } + t.Log("Native cancellation cannot establish reusable settlement; successor must use recovery") + successor := make(chan proto.Envelope, 1) + next, nextErr := e.StartTurn(ctx, "unsafe-successor", proto.TextInput("must not execute"), successor) + if next != nil || nextErr == nil { + t.Fatal("unsettled native owner accepted a successor") + } + close(successor) + cleanup, cleanupStop := context.WithTimeout(ctx, 15*time.Second) + if e.Close(cleanup) != nil { + cleanupStop() + t.Fatal("invalid native owner did not close") + } + cleanupStop() + req.AgentSessionID = nativeID + recovered, recoverErr := NewExecutorFactory(nil)(ctx, req) + if recoverErr != nil || recovered == nil { + t.Fatal("exact native history recovery failed") + } + e = recovered.(*executor) + if e.nativeSession != nativeID || e.connection.process.Cmd.Process.Pid == pid { + t.Fatal("recovery did not replace the process while retaining native history") + } + pid = e.connection.process.Cmd.Process.Pid + run("recovery", "What exact marker did I ask you to remember at the beginning? Reply with only that marker.") + t.Log("Verified exact history continuation in a replacement native owner after uncertain cancellation") + return + } + if cancelErr != nil { + t.Fatal("settled cancellation reported an error") + } + assertOwner() + run("continuation", "What exact marker did I ask you to remember at the beginning? Reply with only that marker.") + t.Log("Verified native process and session reuse across two normal Turns, active cancellation, and continuation") +} diff --git a/apps/parsar-daemon/internal/agent/mcode/executor_test.go b/apps/parsar-daemon/internal/agent/mcode/executor_test.go new file mode 100644 index 000000000..17f532041 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/executor_test.go @@ -0,0 +1,243 @@ +package mcode + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func executorFixture(t *testing.T, scenario string, workspace bool) (*executor, string) { + t.Helper() + config, req, record := workspaceFixture(t) + req.ReleaseOnCompletion = false + script, err := os.ReadFile(config.Binary) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(config.Binary, []byte(strings.Replace(string(script), "HELPER=prepared", "HELPER="+scenario, 1)), 0700); err != nil { + t.Fatal(err) + } + var factory agent.ExecutorFactory + if workspace { + factory = NewExecutorFactory(&config) + } else { + req = executionRequest(t) + req.ReleaseOnCompletion = false + req.RunID, req.Input, req.ConversationID = "", nil, "" + t.Setenv("OAC_RUNTIME_MCODE_BIN", config.Binary) + factory = NewExecutorFactory(nil) + } + value, err := factory(t.Context(), req) + if err != nil { + t.Fatal(err) + } + e := value.(*executor) + t.Cleanup(func() { + cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := e.Close(cleanup); err != nil { + t.Error(err) + } + }) + return e, record +} + +func runExecutorFixtureTurn(t *testing.T, e *executor, id, text string) (*Session, []proto.Envelope) { + t.Helper() + out := make(chan proto.Envelope, 256) + turn, err := e.StartTurn(t.Context(), id, proto.TextInput(text), out) + if err != nil { + t.Fatal(err) + } + var events []proto.Envelope + for event := range out { + events = append(events, event) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + settlement, err := turn.AwaitSettlement(ctx) + if err != nil || !settlement.Reusable { + t.Fatalf("settlement = %+v, %v", settlement, err) + } + return turn.(*Session), events +} + +func TestExecutorReusesNativeOwnerWithFreshTurnsAndIdleDrain(t *testing.T) { + for _, workspace := range []bool{false, true} { + t.Run(map[bool]string{false: "none", true: "workspace"}[workspace], func(t *testing.T) { + e, record := executorFixture(t, "executor-reuse", workspace) + pid := e.connection.process.Cmd.Process.Pid + first, _ := runExecutorFixtureTurn(t, e, "first", "one") + second, events := runExecutorFixtureTurn(t, e, "second", "two") + if first == second || e.connection.process.Cmd.Process.Pid != pid { + t.Fatal("Turn reused mutable state or rebuilt the process") + } + text, tools, done := "", 0, 0 + for _, event := range events { + if event.ID != "second" { + t.Fatal("event escaped its Turn", event.ID) + } + switch event.Type { + case proto.TypeError: + t.Fatalf("execution failed: %s", event.Payload) + case proto.TypeDelta: + var delta proto.DeltaPayload + _ = json.Unmarshal(event.Payload, &delta) + text += delta.Delta + if delta.Sequence != 1 { + t.Fatal("sequence carried over from old Turn") + } + case proto.TypeToolCall: + tools++ + case proto.TypeDone: + done++ + } + } + if text != "two" || tools != 2 || done != 1 { + t.Fatalf("cross-Turn state: text=%q tools=%d done=%d", text, tools, done) + } + raw, _ := os.ReadFile(record) + if strings.Count(string(raw), "initialize\n") != 1 || strings.Count(string(raw), "session/new\n") != 1 || strings.Count(string(raw), "session/prompt\n") != 2 { + t.Fatalf("native owner was recreated: %s", raw) + } + select { + case <-e.connection.exited: + t.Fatal("completion killed native owner") + default: + } + }) + } +} + +func TestExecutorCancellationWithoutRootProofInvalidatesAndLateCancelCannotRetarget(t *testing.T) { + e, record := executorFixture(t, "executor-cancel", true) + first, _ := runExecutorFixtureTurn(t, e, "first", "one") + out := make(chan proto.Envelope, 32) + second, err := e.StartTurn(t.Context(), "second", proto.TextInput("wait"), out) + if err != nil { + t.Fatal(err) + } + select { + case event := <-out: + if event.Type != proto.TypeDelta { + t.Fatal(event.Type) + } + case <-time.After(3 * time.Second): + t.Fatal("second Turn did not start") + } + if err = first.Cancel(t.Context()); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(record) + if strings.Contains(string(raw), "session/cancel") { + t.Fatal("late cancellation targeted the new Turn") + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + if err = second.Cancel(ctx); err == nil { + t.Fatal("unproven root settlement reported reusable cancellation") + } + settlement, err := second.AwaitSettlement(ctx) + if err != nil || settlement.Reusable { + t.Fatal(settlement, err) + } + select { + case <-e.connection.exited: + default: + t.Fatal("nonreusable settlement preceded native process exit") + } + if got := second.CancellationOutcome(); got.Content != "ready" || got.Metadata[proto.DoneMetaAgentSessionID] != "native-1" { + t.Fatal("cancel lost settled outcome", got) + } + for range out { + } + thirdOut := make(chan proto.Envelope, 1) + third, err := e.StartTurn(t.Context(), "third", proto.TextInput("three"), thirdOut) + if third != nil || err == nil { + t.Fatal("unproven native owner admitted a successor") + } + close(thirdOut) + raw, _ = os.ReadFile(record) + if strings.Count(string(raw), "session/cancel\n") != 1 || strings.Count(string(raw), "initialize\n") != 1 { + t.Fatalf("cancellation replaced owner: %s", raw) + } +} + +func TestExecutorStartFailureOwnership(t *testing.T) { + t.Run("validation", func(t *testing.T) { + e, record := executorFixture(t, "executor-reuse", true) + out := make(chan proto.Envelope, 1) + turn, err := e.StartTurn(t.Context(), "", proto.TextInput("invalid"), out) + if err == nil || turn != nil { + t.Fatal("invalid Start acquired output") + } + close(out) + raw, _ := os.ReadFile(record) + if strings.Contains(string(raw), "session/prompt") { + t.Fatal("invalid input was sent") + } + runExecutorFixtureTurn(t, e, "healthy", "valid") + }) + t.Run("before input", func(t *testing.T) { + e, record := executorFixture(t, "executor-preexit", true) + out := make(chan proto.Envelope, 1) + turn, err := e.StartTurn(t.Context(), "run", proto.TextInput("input"), out) + if err == nil || turn != nil { + t.Fatal("failed readiness retained caller output") + } + close(out) + raw, _ := os.ReadFile(record) + if strings.Contains(string(raw), "session/prompt") { + t.Fatal("pre-input failure sent input") + } + }) + t.Run("after submission", func(t *testing.T) { + e, record := executorFixture(t, "executor-exit", true) + out := make(chan proto.Envelope, 32) + turn, err := e.StartTurn(t.Context(), "run", proto.TextInput("input"), out) + if err != nil || turn == nil { + t.Fatal("submitted Turn lost ownership", err) + } + for range out { + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + settlement, err := turn.AwaitSettlement(ctx) + if err != nil || settlement.Reusable { + t.Fatal("exited process remained reusable", settlement, err) + } + raw, _ := os.ReadFile(record) + if strings.Count(string(raw), "session/prompt\n") != 1 { + t.Fatal("uncertain input was replayed") + } + rejected := make(chan proto.Envelope) + again, err := e.StartTurn(t.Context(), "again", proto.TextInput("again"), rejected) + if again != nil || err == nil { + t.Fatal("invalid owner accepted another Turn") + } + close(rejected) + }) +} + +func TestExecutorCloseRetainsOwnerAfterDeadline(t *testing.T) { + e, _ := executorFixture(t, "executor-reuse", true) + cancelled, cancel := context.WithCancel(t.Context()) + cancel() + _ = e.Close(cancelled) + ctx, stop := context.WithTimeout(t.Context(), 5*time.Second) + defer stop() + if err := e.Close(ctx); err != nil { + t.Fatal(err) + } + select { + case <-e.connection.exited: + default: + t.Fatal("Close lost native cleanup ownership") + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/executor_turn.go b/apps/parsar-daemon/internal/agent/mcode/executor_turn.go new file mode 100644 index 000000000..540a6202b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/executor_turn.go @@ -0,0 +1,153 @@ +package mcode + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (s *Session) runExecutorTurn() { + err := s.captureSubagentBaseline() + if err == nil { + err = s.executePrompt() + } + if errors.Is(err, errTurnCancelled) { + err = nil + } + s.mu.Lock() + s.closing, s.steeringReady = true, false + close(s.inputDone) + s.mu.Unlock() + // Written steering requests retain their original receipt owner even after + // prompt completion. No successor starts until all callers have settled. + s.operations.Wait() + if s.req.StrictResume && !s.req.DisableSubagents && s.subagentHistoryReady { + childErr := s.settleSubagents() + s.mu.Lock() + s.subagentSettlementError = childErr + s.mu.Unlock() + if childErr != nil { + err = childErr + } + } + reusable := err == nil && s.process.Context().Err() == nil + if len(s.tools) > 0 { + reusable = false + } + s.finishEnvironmentMCP() + s.mu.Lock() + // ACP cancelled can precede native root Turn settlement in 0.4.12. + // Without the qualified history reader, retain no claim of safe reuse. + if (s.cancelled && s.req.DisableSubagents) || s.inputUncertain || len(s.permissions) != 0 || len(s.questions) != 0 { + reusable = false + } + metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode", proto.DoneMetaAgentSessionID: s.sessionID} + s.outcome = proto.DonePayload{Content: s.content.String(), Metadata: metadata, SourceCompletedAtMS: s.rootCompletedAtMS} + outcome := s.outcome + s.permissions, s.questions = map[string]pendingPermission{}, map[string]pendingQuestion{} + s.mu.Unlock() + if !reusable { + // Unknown quiescence invalidates this owner. A successful settlement + // still proves its native process group and pipes have stopped. + s.process.Cancel() + <-s.exited + } + if err != nil { + s.emit(proto.TypeError, proto.ErrorPayload{Error: err.Error()}) + } + s.emit(proto.TypeDone, outcome) + close(s.out) + close(s.finished) + s.executor.mu.Lock() + s.connection.setCurrent(nil) + if !reusable { + s.executor.invalid = true + } + s.executor.active = nil + s.settlement = agent.TurnSettlement{Reusable: reusable} + if !reusable { + s.settlement.Reason = "native Turn did not establish reusable settlement" + } + close(s.settled) + s.executor.mu.Unlock() + s.outputCancel() +} + +func (s *Session) captureSubagentBaseline() error { + if !s.req.StrictResume || s.req.DisableSubagents { + return nil + } + snapshot, err := s.readSubagents(s.ctx) + s.subagentHistoryReady = err == nil + s.previousNativeTurns = map[string]bool{} + for _, session := range snapshot.Sessions { + if session.ID == s.sessionID { + for _, turn := range session.Turns { + s.previousNativeTurns[turn.ID] = true + } + } + } + return err +} + +func (s *Session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + if s.settled == nil { + return agent.TurnSettlement{}, fmt.Errorf("mcode: Turn has no Executor owner") + } + select { + case <-s.settled: + return s.settlement, nil + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} + +func (s *Session) cancelTurn(ctx context.Context) error { + e := s.executor + e.mu.Lock() + if e.active != s { + e.mu.Unlock() + _, err := s.AwaitSettlement(ctx) + return err + } + s.mu.Lock() + first := !s.cancelled && !s.closing + s.cancelled = true + if first { + s.operations.Add(1) + } + s.mu.Unlock() + // Cancellation releases event backpressure but does not cancel native owner + // context. ACP prompt completion and child/tool settlement decide reuse. + s.outputCancel() + var err error + if first { + raw, _ := json.Marshal(map[string]string{"sessionId": s.sessionID}) + err = s.writeContext(ctx, rpcFrame{JSONRPC: "2.0", Method: "session/cancel", Params: raw}) + } + e.mu.Unlock() + if first { + if err == nil && s.req.StrictResume && !s.req.DisableSubagents { + err = s.stopSubagents(ctx) + } + if err != nil { + s.markInputUncertain() + } + s.operations.Done() + } + if err != nil { + return err + } + settlement, err := s.AwaitSettlement(ctx) + if err != nil { + return err + } + if !settlement.Reusable { + return fmt.Errorf("mcode: cancellation did not establish reusable settlement") + } + return nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/preparation.go b/apps/parsar-daemon/internal/agent/mcode/preparation.go index 97e614d82..8d9672548 100644 --- a/apps/parsar-daemon/internal/agent/mcode/preparation.go +++ b/apps/parsar-daemon/internal/agent/mcode/preparation.go @@ -3,145 +3,85 @@ package mcode import ( "context" "fmt" - "strings" "sync" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) +// Prepared retains its single admission API over the same native Executor. +// Runtime's Session lifecycle uses Executor directly. type prepared struct { - mu sync.Mutex - session *Session - ready chan struct{} - started chan struct{} - failure error - closed bool - binding *preparedStart -} - -type preparedStart struct { - runID string - prompt proto.MessageInput - out chan<- proto.Envelope + mu sync.Mutex + executor *executor + session *Session + started, closed bool } func NewPreparationFactory(config WorkspaceConfig) agent.PreparationFactory { + factory := NewExecutorFactory(&config) return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { - if ctx == nil { - ctx = context.Background() - } - if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { - return nil, fmt.Errorf("mcode: preparation cannot contain input or product context") - } - opts, err := prepareWorkspaceOptions(ctx, config, req) - if err != nil { - return nil, err - } - s, err := launch(ctx, req, opts, config.Binary, nil) + value, err := factory(ctx, req) if err != nil { + if value != nil { + return &prepared{executor: value.(*executor)}, err + } return nil, err } - p := &prepared{session: s, ready: make(chan struct{}), started: make(chan struct{})} - go s.run(p) - <-p.ready - if p.failure != nil { - <-s.finished - return nil, p.failure - } - if ctx.Err() != nil { - _ = p.Close() - return nil, ctx.Err() - } - return p, nil + e := value.(*executor) + return &prepared{executor: e, session: newTurnSession(ctx, req, e.opts, e.connection, nil)}, nil } } -func (p *prepared) Start(ctx context.Context, runID string, prompt proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { - if ctx == nil { - ctx = context.Background() - } +func (p *prepared) Start(ctx context.Context, runID string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { p.mu.Lock() defer p.mu.Unlock() - if p.closed || p.binding != nil { + if p.closed || p.started { return nil, fmt.Errorf("mcode: preparation is no longer available") } - if strings.TrimSpace(runID) == "" || prompt.Validate() != nil || out == nil || ctx.Err() != nil { - return nil, fmt.Errorf("mcode: start requires a live context, identity, prompt and output") - } - select { - case <-p.session.process.Context().Done(): - return nil, fmt.Errorf("mcode: prepared process ended") - case <-p.session.exited: - return nil, fmt.Errorf("mcode: prepared process exited") - default: + turn, err := p.executor.StartTurn(ctx, runID, input, out) + if turn != nil { + p.started, p.session = true, turn.(*Session) } - p.binding = &preparedStart{runID: runID, prompt: prompt, out: out} - close(p.started) - return p.session, nil + return turn, err } func (p *prepared) Close() error { p.mu.Lock() - if p.binding != nil { - p.mu.Unlock() - return nil + started := p.started + if !started { + p.closed = true } - p.closed = true p.mu.Unlock() - return p.closeUnused(context.Background()) + if started { + return nil + } + return p.executor.Close(context.Background()) } func (p *prepared) Cancel(ctx context.Context) error { p.mu.Lock() p.closed = true - started := p.binding != nil + current, started := p.session, p.started p.mu.Unlock() - if !started { - return p.closeUnused(ctx) - } - return p.session.Cancel(ctx) -} - -// An unconsumed preparation has no model input or child work to settle. -func (p *prepared) closeUnused(ctx context.Context) error { - p.session.process.Cancel() - select { - case <-p.session.finished: - return nil - case <-ctx.Done(): - return ctx.Err() + if started { + if err := current.Cancel(ctx); err != nil { + // The single-use Prepared owns disposal as well as cancellation. + if closeErr := p.executor.Close(ctx); closeErr != nil { + return closeErr + } + return nil + } } + return p.executor.Close(ctx) } -func (p *prepared) CancellationOutcome() proto.DonePayload { return p.session.CancellationOutcome() } - -// The Session goroutine owns preparation, input submission and terminal output. -func (p *prepared) awaitStart(err error) error { - p.failure = err - close(p.ready) - if err != nil { - return err - } - for { - var ended error - select { - case <-p.started: - case <-p.session.process.Context().Done(): - ended = p.session.process.Context().Err() - case _, ok := <-p.session.frames: - if ok { - continue - } - ended = fmt.Errorf("mcode: prepared process exited") - } - p.mu.Lock() - if b := p.binding; b != nil { - p.session.req.RunID, p.session.req.Input, p.session.out = b.runID, b.prompt, b.out - } else { - p.closed = true - } - p.mu.Unlock() - return ended +func (p *prepared) CancellationOutcome() proto.DonePayload { + p.mu.Lock() + s := p.session + p.mu.Unlock() + if s == nil { + return proto.DonePayload{} } + return s.CancellationOutcome() } diff --git a/apps/parsar-daemon/internal/agent/mcode/preparation_test.go b/apps/parsar-daemon/internal/agent/mcode/preparation_test.go index 9ae612c19..65ea1a87e 100644 --- a/apps/parsar-daemon/internal/agent/mcode/preparation_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/preparation_test.go @@ -88,8 +88,8 @@ func TestPreparedWorkspaceHasOneInputAndOutputOwner(t *testing.T) { done++ select { case <-p.session.exited: + t.Fatal("successful Turn disposed the reusable native owner") default: - t.Fatal("Done before native settlement") } var d proto.DonePayload _ = json.Unmarshal(e.Payload, &d) diff --git a/apps/parsar-daemon/internal/agent/mcode/session.go b/apps/parsar-daemon/internal/agent/mcode/session.go index 476ace40b..89f51251f 100644 --- a/apps/parsar-daemon/internal/agent/mcode/session.go +++ b/apps/parsar-daemon/internal/agent/mcode/session.go @@ -1,15 +1,12 @@ package mcode import ( - "bufio" "context" "encoding/json" "errors" "fmt" - "io" "net/url" "slices" - "strconv" "strings" "sync" "time" @@ -20,22 +17,29 @@ import ( ) type Session struct { - ctx context.Context - req proto.PromptRequestPayload - opts launchOptions - process *clirunner.Process + ctx context.Context + req proto.PromptRequestPayload + opts launchOptions + *connection + executor *executor + settlement agent.TurnSettlement + settled chan struct{} + inputDone chan struct{} + outputCancel context.CancelFunc + operations sync.WaitGroup + closing bool + cancelled bool + inputUncertain bool out chan<- proto.Envelope frames chan rpcFrame - exited chan struct{} finished chan struct{} - writeMu sync.Mutex mu sync.Mutex sessionID string + nativeModel string + outputContext context.Context + outcome proto.DonePayload permissions map[string]pendingPermission questions map[string]pendingQuestion - exitErr error - nextID int - responses map[string]chan rpcFrame steeringReady bool steeringTurn string sequence uint64 @@ -70,7 +74,7 @@ func newSession(ctx context.Context, req proto.PromptRequestPayload, out chan<- if err != nil { return nil, err } - go s.run(nil) + go s.run() return s, nil } @@ -79,56 +83,18 @@ func launch(ctx context.Context, req proto.PromptRequestPayload, opts launchOpti if err != nil { return nil, err } - s := &Session{ctx: ctx, req: req, opts: opts, process: process, out: out, frames: make(chan rpcFrame, 32), exited: make(chan struct{}), finished: make(chan struct{}), responses: map[string]chan rpcFrame{}, permissions: map[string]pendingPermission{}, questions: map[string]pendingQuestion{}, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} - go func() { _, _ = io.Copy(io.Discard, process.Stderr) }() - go s.read() + c := &connection{process: process, exited: make(chan struct{}), responses: map[string]chan rpcFrame{}} + s := newTurnSession(ctx, req, opts, c, out) + c.current = s + go c.read() return s, nil } -func (s *Session) read() { - defer close(s.exited) - defer close(s.frames) - scanner := bufio.NewScanner(s.process.Stdout) - scanner.Buffer(make([]byte, 64*1024), 16*1024*1024) - var readErr error - for scanner.Scan() { - var frame rpcFrame - if err := json.Unmarshal(scanner.Bytes(), &frame); err != nil { - readErr = fmt.Errorf("mcode: malformed ACP response") - s.process.Cancel() - break - } - if frame.Method == "" { - s.mu.Lock() - response := s.responses[string(frame.ID)] - s.mu.Unlock() - if response != nil { - select { - case response <- frame: - default: - } - continue - } - } - select { - case s.frames <- frame: - case <-s.finished: - case <-s.process.Context().Done(): - } - } - if scanner.Err() != nil { - readErr = fmt.Errorf("mcode: ACP stream read failed") - s.process.Cancel() - } - waitErr := s.process.Wait() - if readErr != nil { - s.exitErr = readErr - } else { - s.exitErr = waitErr - } +func newTurnSession(ctx context.Context, req proto.PromptRequestPayload, opts launchOptions, c *connection, out chan<- proto.Envelope) *Session { + return &Session{ctx: ctx, req: req, opts: opts, connection: c, out: out, frames: make(chan rpcFrame, 32), finished: make(chan struct{}), permissions: map[string]pendingPermission{}, questions: map[string]pendingQuestion{}, tools: map[string]toolUpdate{}, completedTools: map[string]bool{}} } -func (s *Session) run(p *prepared) { +func (s *Session) run() { defer func() { if s.out != nil { close(s.out) @@ -136,9 +102,6 @@ func (s *Session) run(p *prepared) { }() defer close(s.finished) err := s.prepareNative() - if p != nil { - err = p.awaitStart(err) - } if err == nil { if s.req.StrictResume && !s.req.DisableSubagents { var snapshot nativeSubagentSnapshot @@ -166,7 +129,7 @@ func (s *Session) run(p *prepared) { err = observationErr } } - if p != nil || err != nil { + if err != nil { s.process.Cancel() <-s.exited } @@ -241,6 +204,7 @@ func (s *Session) prepareNative() error { if err != nil { return err } + s.nativeModel = model if err := s.call("session/set_config_option", map[string]any{"sessionId": session.SessionID, "configId": "model", "value": model}, nil, false); err != nil { return err } @@ -264,6 +228,12 @@ func (s *Session) executePrompt() error { if err != nil { return err } + s.mu.Lock() + cancelled := s.cancelled + s.mu.Unlock() + if result.StopReason == "cancelled" && cancelled { + return nil + } if result.StopReason != "end_turn" { return fmt.Errorf("mcode: prompt stopped (%s)", result.StopReason) } @@ -304,7 +274,21 @@ func (s *Session) call(method string, params any, result any, prompt bool) error if err != nil { return err } - if err := s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: method, Params: raw}); err != nil { + if prompt && s.executor != nil { + s.executor.mu.Lock() + s.mu.Lock() + cancelled := s.cancelled + s.mu.Unlock() + if cancelled || s.executor.closed { + s.executor.mu.Unlock() + return errTurnCancelled + } + err = s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: method, Params: raw}) + s.executor.mu.Unlock() + } else { + err = s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: method, Params: raw}) + } + if err != nil { return err } ctx := s.process.Context() @@ -317,6 +301,8 @@ func (s *Session) call(method string, params any, result any, prompt bool) error select { case <-ctx.Done(): return fmt.Errorf("mcode: %s: %w", method, ctx.Err()) + case <-s.exited: + return fmt.Errorf("mcode: ACP process exited: %v", s.exitErr) case frame, ok := <-s.frames: if !ok { <-s.exited @@ -344,24 +330,30 @@ func (s *Session) call(method string, params any, result any, prompt bool) error } } -func (s *Session) write(frame rpcFrame) error { - s.writeMu.Lock() - defer s.writeMu.Unlock() - return json.NewEncoder(s.process.Stdin).Encode(frame) -} - func (s *Session) emit(kind string, payload any) { + ctx := s.ctx + if s.outputContext != nil { + ctx = s.outputContext + } env, err := proto.NewEnvelope(kind, s.req.RunID, payload) if err != nil { return } select { case s.out <- env: - case <-s.ctx.Done(): + return + default: + } + select { + case s.out <- env: + case <-ctx.Done(): } } func (s *Session) Cancel(ctx context.Context) error { + if s.executor != nil { + return s.cancelTurn(ctx) + } if s.req.StrictResume && !s.req.DisableSubagents { if err := s.cancelSubagents(ctx); err != nil { s.process.Cancel() @@ -409,18 +401,3 @@ func (s *Session) SubmitPermission(_ context.Context, id string, decision proto. delete(s.permissions, id) return nil } - -func (s *Session) reserveResponse() (string, chan rpcFrame) { - s.mu.Lock() - defer s.mu.Unlock() - s.nextID++ - id := strconv.Itoa(s.nextID) - reply := make(chan rpcFrame, 1) - s.responses[id] = reply - return id, reply -} -func (s *Session) removeResponse(id string) { - s.mu.Lock() - delete(s.responses, id) - s.mu.Unlock() -} diff --git a/apps/parsar-daemon/internal/agent/mcode/session_test.go b/apps/parsar-daemon/internal/agent/mcode/session_test.go index 2e347057a..641fe93a0 100644 --- a/apps/parsar-daemon/internal/agent/mcode/session_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/session_test.go @@ -198,6 +198,7 @@ func TestMCodeProcess(t *testing.T) { } scanner := bufio.NewScanner(os.Stdin) var promptID json.RawMessage + prompts, configurations := 0, 0 for scanner.Scan() { var frame rpcFrame if json.Unmarshal(scanner.Bytes(), &frame) != nil { @@ -248,6 +249,10 @@ func TestMCodeProcess(t *testing.T) { result = map[string]any{"configOptions": []map[string]any{{"id": "permissionMode"}}} } case "session/set_config_option": + configurations++ + if scenario == "executor-preexit" && configurations > 1 { + os.Exit(0) + } var params map[string]string _ = json.Unmarshal(frame.Params, ¶ms) if params["configId"] == "model" && params["value"] != "m:custom_provider%3Aoac:fixture:v:" { @@ -258,10 +263,33 @@ func TestMCodeProcess(t *testing.T) { Prompt []map[string]string `json:"prompt"` } _ = json.Unmarshal(frame.Params, &input) - if strict := scenario == "strict-cancel" || strings.HasPrefix(scenario, "prepared"); (strict && len(input.Prompt) != 2) || (!strict && len(input.Prompt) != 1) { + if strict := scenario == "strict-cancel" || (strings.HasPrefix(scenario, "prepared") || strings.HasPrefix(scenario, "executor")); (strict && len(input.Prompt) != 2) || (!strict && len(input.Prompt) != 1) { os.Exit(9) } + if strings.HasPrefix(scenario, "executor") { + prompts++ + promptID = frame.ID + if scenario == "executor-exit" { + os.Exit(0) + } + if input.Prompt[0]["text"] == "wait" { + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "ready"}}) + continue + } + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": input.Prompt[0]["text"]}}) + update("tool_call", map[string]any{"toolCallId": "repeated-call", "name": "Read", "status": "in_progress", "rawInput": map[string]any{}}) + update("tool_call_update", map[string]any{"toolCallId": "repeated-call", "status": "completed"}) + raw, _ := json.Marshal(map[string]string{"stopReason": "end_turn"}) + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: raw}) + // These frames precede the next control barrier on the wire and + // must never become the following Turn's output. + for range 100 { + update("agent_message_chunk", map[string]any{"content": map[string]string{"type": "text", "text": "OLD"}}) + } + continue + } if scenario == "prepared-mcp-cancel" { + promptID = frame.ID update("tool_call", map[string]any{"toolCallId": "native-call", "name": "mcp__proof_server__read_status", "status": "in_progress", "rawInput": map[string]any{}}) continue } @@ -294,6 +322,10 @@ func TestMCodeProcess(t *testing.T) { } update("usage_update", map[string]any{"used": 2000, "size": 64000, "cost": map[string]any{"amount": 2, "currency": "USD"}}) result = map[string]string{"stopReason": "end_turn"} + case "session/cancel": + raw, _ := json.Marshal(map[string]string{"stopReason": "cancelled"}) + send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) + continue case "mcode/session/steer": if scenario == "steer-lost" { os.Exit(0) diff --git a/apps/parsar-daemon/internal/agent/mcode/steering.go b/apps/parsar-daemon/internal/agent/mcode/steering.go index 7479b2964..cb16925bc 100644 --- a/apps/parsar-daemon/internal/agent/mcode/steering.go +++ b/apps/parsar-daemon/internal/agent/mcode/steering.go @@ -18,6 +18,16 @@ func (s *Session) Steer(ctx context.Context, input proto.PromptSteerPayload) err // Native acceptance belongs to the active ACP Turn; it does not promise model consumption. func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerPayload, written func()) error { + if s.executor != nil { + s.mu.Lock() + if s.closing || s.cancelled { + s.mu.Unlock() + return agent.ErrSteeringInactive + } + s.operations.Add(1) + s.mu.Unlock() + defer s.operations.Done() + } text, err := input.Input.TextOnly() if strings.TrimSpace(input.InputID) == "" || err != nil { return agent.ErrSteeringRejected @@ -40,10 +50,9 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP if err := ctx.Err(); err != nil { return err } - stop := context.AfterFunc(ctx, s.process.Cancel) - err = s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "mcode/session/steer", Params: raw}) - stop() + err = s.writeContext(ctx, rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "mcode/session/steer", Params: raw}) if err != nil { + s.markInputUncertain() return fmt.Errorf("mcode: input transport failed") } if written != nil { @@ -53,7 +62,7 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP var stopped error select { case frame = <-response: - case <-s.finished: + case <-s.inputSettlementDone(): stopped = fmt.Errorf("mcode: run ended with unknown input outcome") case <-s.exited: stopped = fmt.Errorf("mcode: input transport closed") @@ -65,6 +74,7 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP select { case frame = <-response: default: + s.markInputUncertain() return stopped } } @@ -72,6 +82,7 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP if frame.Error.Code == -32602 || frame.Error.Code == -32601 { return agent.ErrSteeringRejected } + s.markInputUncertain() return fmt.Errorf("mcode: native steering failed with unknown input outcome") } var result struct { @@ -79,23 +90,41 @@ func (s *Session) SteerWithReceipt(ctx context.Context, input proto.PromptSteerP Mode string `json:"mode"` } if json.Unmarshal(frame.Result, &result) != nil || result.TurnID == "" || (result.Mode != "steered" && result.Mode != "duplicate") { + s.markInputUncertain() return fmt.Errorf("mcode: invalid steering receipt") } s.mu.Lock() defer s.mu.Unlock() if s.steeringTurn != "" && s.steeringTurn != result.TurnID { + s.inputUncertain = true return fmt.Errorf("mcode: steering turn changed") } s.steeringTurn = result.TurnID return nil } +func (s *Session) inputSettlementDone() <-chan struct{} { + if s.inputDone != nil { + return s.inputDone + } + return s.finished +} + func (s *Session) CancellationOutcome() proto.DonePayload { s.mu.Lock() defer s.mu.Unlock() + if s.outcome.Metadata != nil { + return s.outcome + } metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode"} if s.sessionID != "" { metadata[proto.DoneMetaAgentSessionID] = s.sessionID } return proto.DonePayload{Metadata: metadata} } + +func (s *Session) markInputUncertain() { + s.mu.Lock() + s.inputUncertain = true + s.mu.Unlock() +} diff --git a/apps/parsar-daemon/internal/agent/mcode/subagent_cancel.go b/apps/parsar-daemon/internal/agent/mcode/subagent_cancel.go index 1469c1ff0..bd1b88e68 100644 --- a/apps/parsar-daemon/internal/agent/mcode/subagent_cancel.go +++ b/apps/parsar-daemon/internal/agent/mcode/subagent_cancel.go @@ -7,6 +7,21 @@ import ( ) func (s *Session) cancelSubagents(ctx context.Context) error { + if err := s.stopSubagents(ctx); err != nil { + return err + } + select { + case <-s.finished: + s.mu.Lock() + err := s.subagentSettlementError + s.mu.Unlock() + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (s *Session) stopSubagents(ctx context.Context) error { select { case <-s.finished: return nil @@ -21,7 +36,7 @@ func (s *Session) cancelSubagents(ctx context.Context) error { id, response := s.reserveResponse() defer s.removeResponse(id) raw, _ := json.Marshal(map[string]string{"sessionId": native}) - if err := s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "mcode/session/delegation/stop", Params: raw}); err != nil { + if err := s.writeContext(ctx, rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: "mcode/session/delegation/stop", Params: raw}); err != nil { return err } select { @@ -40,13 +55,5 @@ func (s *Session) cancelSubagents(ctx context.Context) error { case <-ctx.Done(): return ctx.Err() } - select { - case <-s.finished: - s.mu.Lock() - err := s.subagentSettlementError - s.mu.Unlock() - return err - case <-ctx.Done(): - return ctx.Err() - } + return nil } diff --git a/apps/parsar-daemon/internal/agent/mcode/subagents.go b/apps/parsar-daemon/internal/agent/mcode/subagents.go index 33fe2ed9e..645525f54 100644 --- a/apps/parsar-daemon/internal/agent/mcode/subagents.go +++ b/apps/parsar-daemon/internal/agent/mcode/subagents.go @@ -121,11 +121,9 @@ func (s *Session) settleSubagents() error { settled = false } } - if session.ID != snapshot.Root { - for _, turn := range session.Turns { - if turn.Status == "accepted" { - settled = false - } + for _, turn := range session.Turns { + if turn.Status == "accepted" { + settled = false } } } diff --git a/apps/parsar-daemon/internal/agent/mcode/subagents_test.go b/apps/parsar-daemon/internal/agent/mcode/subagents_test.go index de4b4b414..9268c5837 100644 --- a/apps/parsar-daemon/internal/agent/mcode/subagents_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/subagents_test.go @@ -3,8 +3,11 @@ package mcode import ( "context" "encoding/json" + "os" + "path/filepath" "reflect" "testing" + "time" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) @@ -65,3 +68,35 @@ func TestSubagentSnapshotRejectsMissingParentProvenance(t *testing.T) { t.Fatal("accepted child without original spawning provenance") } } + +// The ACP cancelled response may precede the root native Turn becoming terminal. +func TestSubagentSettlementIncludesActiveRootTurn(t *testing.T) { + dir := t.TempDir() + node, bridge := filepath.Join(dir, "node"), filepath.Join(dir, "bridge.mjs") + for name, data := range map[string]string{ + node: "#!/bin/sh\ncat \"$3/snapshot.json\"\n", + bridge: "", + filepath.Join(dir, "subagent-snapshot.mjs"): "", + } { + if err := os.WriteFile(name, []byte(data), 0700); err != nil { + t.Fatal(err) + } + } + t.Setenv("OAC_RUNTIME_MCODE_NODE", node) + t.Setenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE", bridge) + for _, status := range []string{"accepted", "aborted"} { + t.Run(status, func(t *testing.T) { + raw := []byte(`{"version":1,"complete":true,"rootSessionId":"root","sessions":[{"id":"root","turns":[{"id":"current","status":"` + status + `"}]}]}`) + if err := os.WriteFile(filepath.Join(dir, "snapshot.json"), raw, 0600); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + defer cancel() + s := &Session{ctx: ctx, sessionID: "root", opts: launchOptions{DataDir: dir}, frames: make(chan rpcFrame)} + err := s.settleSubagents() + if (err != nil) != (status == "accepted") { + t.Fatalf("root %s settlement error = %v", status, err) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/registry.go b/apps/parsar-daemon/internal/agent/registry.go index d9f6459f5..97d79a4c9 100644 --- a/apps/parsar-daemon/internal/agent/registry.go +++ b/apps/parsar-daemon/internal/agent/registry.go @@ -58,6 +58,7 @@ type Registry struct { mu sync.RWMutex factories map[string]Factory preparers map[string]PreparationFactory + executors map[string]ExecutorFactory kinds map[string]proto.SupportedAgentKind } @@ -65,6 +66,7 @@ func NewRegistry() *Registry { return &Registry{ factories: make(map[string]Factory), preparers: make(map[string]PreparationFactory), + executors: make(map[string]ExecutorFactory), kinds: make(map[string]proto.SupportedAgentKind), } } @@ -90,6 +92,7 @@ func (r *Registry) RegisterKind(info proto.SupportedAgentKind, f Factory) { defer r.mu.Unlock() r.factories[kind] = f delete(r.preparers, kind) + delete(r.executors, kind) info.Capabilities.Preparation = false info.Capabilities.WorkspaceReadPreparation = false r.kinds[kind] = info diff --git a/apps/parsar-daemon/internal/agent/registry_test.go b/apps/parsar-daemon/internal/agent/registry_test.go index 58c4488a4..e1ab3e8f7 100644 --- a/apps/parsar-daemon/internal/agent/registry_test.go +++ b/apps/parsar-daemon/internal/agent/registry_test.go @@ -136,3 +136,24 @@ func TestRegistrySupportedAgentKindsReportsDescriptors(t *testing.T) { t.Fatalf("opencode descriptor not preserved: %#v", got[1]) } } + +func TestRegistryExecutorRequiresExplicitRegistration(t *testing.T) { + registry := agent.NewRegistry() + registry.Register("native", stubFactory("native")) + if _, err := registry.ResolveExecutor("native"); err == nil { + t.Fatal("legacy factory implied reusable execution") + } + expected := errors.New("executor factory") + registry.RegisterExecutor("native", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return nil, expected }) + factory, err := registry.ResolveExecutor("native") + if err != nil { + t.Fatal(err) + } + if _, err := factory(t.Context(), proto.PromptRequestPayload{}); !errors.Is(err, expected) { + t.Fatal(err) + } + registry.Register("native", stubFactory("replacement")) + if _, err := registry.ResolveExecutor("native"); err == nil { + t.Fatal("replacing a kind retained its old executor capability") + } +} diff --git a/apps/parsar-daemon/internal/cli/agent_registration.go b/apps/parsar-daemon/internal/cli/agent_registration.go index 17126ff5a..ea93bbf30 100644 --- a/apps/parsar-daemon/internal/cli/agent_registration.go +++ b/apps/parsar-daemon/internal/cli/agent_registration.go @@ -16,6 +16,9 @@ func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery, s registerProductAgentKind(registry, agentCLIs.ClaudeCode, withSkillUploadServer(withCapabilityDownloads(claudecode.Factory, serverURL), serverURL)) registerProductAgentKind(registry, agentCLIs.OpenCode, withSkillUploadServer(withCapabilityDownloads(opencodeagent.Factory, serverURL), serverURL)) registerProductAgentKind(registry, agentCLIs.Codex, withSkillUploadServer(withCapabilityDownloads(codex.Factory, serverURL), serverURL)) + if agentCLIs.Codex.Available { + registry.RegisterExecutor("codex", withExecutorCapabilities(codex.NewExecutorFactory(), serverURL)) + } if agentCLIs.Codex.Available && agentCLIs.Codex.Capabilities.LocalEnvironment { registry.RegisterPreparation("codex", true, func(ctx context.Context, req proto.PromptRequestPayload) (agent.Prepared, error) { prepared, err := codex.Prepare(ctx, req) @@ -32,6 +35,9 @@ func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery, s } else { registerProductAgentKind(registry, agentCLIs.MCode, withSkillUploadServer(withCapabilityDownloads(mcode.Factory, serverURL), serverURL)) } + if agentCLIs.MCode.Available { + registry.RegisterExecutor("mcode", withExecutorCapabilities(mcode.NewExecutorFactory(agentCLIs.MCodeWorkspace), serverURL)) + } registerClaudeSDK(registry, agentCLIs.ClaudeSDK) } diff --git a/apps/parsar-daemon/internal/cli/authoring.go b/apps/parsar-daemon/internal/cli/authoring.go index b6b456906..e8fb938ce 100644 --- a/apps/parsar-daemon/internal/cli/authoring.go +++ b/apps/parsar-daemon/internal/cli/authoring.go @@ -62,6 +62,9 @@ func authoringRegistry(registry *agent.Registry, bridge *authoring.Bridge) *agen factory = withAuthoringBridge(factory, bridge) } wrapped.RegisterKind(info, factory) + if executor, err := registry.ResolveExecutor(info.Kind); err == nil { + wrapped.RegisterExecutor(info.Kind, executor) + } if prepare, err := registry.ResolvePreparation(info.Kind); err == nil { wrapped.RegisterPreparation(info.Kind, info.Capabilities.WorkspaceReadPreparation, prepare) } diff --git a/apps/parsar-daemon/internal/cli/capability_downloads.go b/apps/parsar-daemon/internal/cli/capability_downloads.go index 2f5e95819..e92fdfc0c 100644 --- a/apps/parsar-daemon/internal/cli/capability_downloads.go +++ b/apps/parsar-daemon/internal/cli/capability_downloads.go @@ -20,28 +20,7 @@ func withCapabilityDownloads(factory agent.Factory, serverURL string) agent.Fact return factory } return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - opts := maps.Clone(req.AgentOptions) - for _, key := range []string{"skills", "plugins"} { - items, ok := opts[key].([]any) - if !ok { - continue - } - items = slices.Clone(items) - for i, item := range items { - descriptor, ok := item.(map[string]any) - if !ok { - continue - } - raw, _ := descriptor["download_url"].(string) - if resolved := capabilityDownloadURL(raw, base); resolved != raw { - updated := maps.Clone(descriptor) - updated["download_url"] = resolved - items[i] = updated - } - } - opts[key] = items - } - req.AgentOptions = opts + req = capabilityDownloadRequest(req, base) return factory(ctx, req, out) } } @@ -65,3 +44,42 @@ func capabilityDownloadURL(raw string, base *url.URL) string { u.RawPath = "" return u.String() } + +func capabilityDownloadRequest(req proto.PromptRequestPayload, base *url.URL) proto.PromptRequestPayload { + opts := maps.Clone(req.AgentOptions) + for _, key := range []string{"skills", "plugins"} { + items, ok := opts[key].([]any) + if !ok { + continue + } + items = slices.Clone(items) + for i, item := range items { + descriptor, ok := item.(map[string]any) + if !ok { + continue + } + raw, _ := descriptor["download_url"].(string) + if resolved := capabilityDownloadURL(raw, base); resolved != raw { + updated := maps.Clone(descriptor) + updated["download_url"] = resolved + items[i] = updated + } + } + opts[key] = items + } + req.AgentOptions = opts + return req +} + +// Executor setup applies the same immutable capability snapshot as ordinary execution. +func withExecutorCapabilities(factory agent.ExecutorFactory, serverURL string) agent.ExecutorFactory { + base, err := url.Parse(serverURL) + valid := err == nil && base.Host != "" && (base.Scheme == "http" || base.Scheme == "https") + return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + if valid { + req = capabilityDownloadRequest(req, base) + } + req = skillUploadRequest(req, serverURL) + return factory(ctx, req) + } +} diff --git a/apps/parsar-daemon/internal/cli/capability_downloads_test.go b/apps/parsar-daemon/internal/cli/capability_downloads_test.go index 5dcaf97e8..fb63b1775 100644 --- a/apps/parsar-daemon/internal/cli/capability_downloads_test.go +++ b/apps/parsar-daemon/internal/cli/capability_downloads_test.go @@ -92,3 +92,24 @@ func TestCapabilityDownloadsInstallZIPThroughPairedServer(t *testing.T) { t.Fatal("rewrote the original request options") } } + +func TestExecutorPreparationUsesCapabilitySnapshotTransform(t *testing.T) { + originalURL := "http://localhost:8080/internal/blobs/pg:skill" + descriptor := map[string]any{"download_url": originalURL} + source := proto.PromptRequestPayload{AgentOptions: map[string]any{"skills": []any{descriptor}}} + calls := 0 + factory := withExecutorCapabilities(func(_ context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + calls++ + actual := req.AgentOptions["skills"].([]any)[0].(map[string]any)["download_url"] + if actual != "http://paired-core:8080/internal/blobs/pg:skill" { + t.Fatalf("executor capability URL = %v", actual) + } + return nil, nil + }, "http://paired-core:8080") + if _, err := factory(t.Context(), source); err != nil { + t.Fatal(err) + } + if calls != 1 || descriptor["download_url"] != originalURL { + t.Fatal("preparation changed the source snapshot or repeated setup") + } +} diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go index add58c1b1..746288935 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -128,6 +128,9 @@ func registerClaudeSDK(registry *agent.Registry, discovery *claudeSDKDiscovery) } } registry.RegisterKind(discovery.Info, factory) + if discovery.Info.Available { + registry.RegisterExecutor("claude_sdk", claudesdk.NewExecutorFactory(discovery.Config)) + } if discovery.Info.Available && discovery.Info.Capabilities.LocalEnvironment { registry.RegisterPreparation("claude_sdk", true, claudesdk.NewPreparationFactory(discovery.Config)) } diff --git a/apps/parsar-daemon/internal/cli/claude_sdk_test.go b/apps/parsar-daemon/internal/cli/claude_sdk_test.go index c691d9292..ce8eaa35f 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk_test.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk_test.go @@ -78,6 +78,9 @@ func TestClaudeSDKDiscoveryAndRegistration(t *testing.T) { t.Fatal(err) } info := discovery.ClaudeSDK.Info + if info.Available { + info.Capabilities.Preparation = true + } if info.Available != tc.ready { t.Fatal(info) } diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index 345ec2b48..61f0ebe4a 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -387,8 +387,8 @@ func mainLoopRemote(rc *runContext, profile string, prof auth.Profile, agentCLIs // pumpConn runs the per-connection workload: a dispatch.Router fed by // conn.Recv(), heartbeats every boot.HeartbeatInterval(), and a -// graceful router.Shutdown on exit so any in-flight subprocesses get -// SIGTERM. +// confirmed router.Shutdown before returning ownership to the reconnect loop. +// Failed cleanup keeps this exact Router alive, including after a shutdown signal. func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.Registry, boot *transport.BootstrapResponse, agentCLIs agentCLIDiscovery) error { local, err := localworkspace.Load() if err != nil { @@ -406,9 +406,8 @@ func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.R return fmt.Errorf("router init: %w", err) } defer func() { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - _ = router.Shutdown(shutdownCtx) - cancel() + _ = conn.Close() + shutdownRouterUntilConfirmed(router.Shutdown, time.Second) }() conn.StartHeartbeats(parentCtx, boot.HeartbeatInterval(), func() proto.HeartbeatPayload { diff --git a/apps/parsar-daemon/internal/cli/connect_cleanup.go b/apps/parsar-daemon/internal/cli/connect_cleanup.go new file mode 100644 index 000000000..7909d7347 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/connect_cleanup.go @@ -0,0 +1,24 @@ +package cli + +import ( + "context" + "time" + + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// A wait deadline does not revoke native ownership. Keep retrying the same +// Router until it confirms cleanup; reconnect and process exit both wait here. +// Router.Shutdown serializes attempts and retains resources after a failure. +func shutdownRouterUntilConfirmed(shutdown func(context.Context) error, retryDelay time.Duration) { + for { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err := shutdown(ctx) + cancel() + if err == nil { + return + } + obslog.Bg().Warn("router cleanup unconfirmed; reconnect remains blocked", "err", err) + time.Sleep(retryDelay) + } +} diff --git a/apps/parsar-daemon/internal/cli/connect_cleanup_test.go b/apps/parsar-daemon/internal/cli/connect_cleanup_test.go new file mode 100644 index 000000000..e5da165fd --- /dev/null +++ b/apps/parsar-daemon/internal/cli/connect_cleanup_test.go @@ -0,0 +1,196 @@ +package cli + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/gorilla/websocket" + "sync/atomic" + "testing" + "time" +) + +func TestReconnectWaitsForConfirmedRouterCleanup(t *testing.T) { + for _, failure := range []error{errors.New("native close failed"), context.DeadlineExceeded} { + t.Run(failure.Error(), func(t *testing.T) { + var calls atomic.Int32 + retry := make(chan struct{}) + confirm := make(chan struct{}) + returned := make(chan struct{}) + go func() { + shutdownRouterUntilConfirmed(func(ctx context.Context) error { + if ctx.Err() != nil { + t.Error("cleanup inherited a cancelled connection context") + } + if _, ok := ctx.Deadline(); !ok { + t.Error("cleanup wait has no deadline") + } + if calls.Add(1) == 1 { + return failure + } + close(retry) + <-confirm + return nil + }, time.Millisecond) + close(returned) + }() + select { + case <-retry: + case <-time.After(time.Second): + t.Fatal("same cleanup was not retried") + } + select { + case <-returned: + t.Fatal("reconnect released unconfirmed ownership") + default: + } + close(confirm) + select { + case <-returned: + case <-time.After(time.Second): + t.Fatal("confirmed cleanup did not release reconnect") + } + if calls.Load() != 2 { + t.Fatalf("cleanup attempts=%d", calls.Load()) + } + }) + } +} + +type cleanupExecutor struct { + closes atomic.Int32 + retry chan struct{} + confirm chan struct{} +} + +func (e *cleanupExecutor) StartTurn(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (agent.Turn, error) { + return nil, errors.New("unexpected Turn") +} +func (e *cleanupExecutor) Close(context.Context) error { + if e.closes.Add(1) == 1 { + return errors.New("native cleanup temporarily unavailable") + } + close(e.retry) + <-e.confirm + return nil +} + +func TestDisconnectedPumpRetainsExactExecutorUntilCleanup(t *testing.T) { + for _, suspend := range []bool{false, true} { + name := "ordinary" + if suspend { + name = "suspension-enabled" + } + t.Run(name, func(t *testing.T) { testDisconnectedPumpCleanup(t, suspend) }) + } +} + +func testDisconnectedPumpCleanup(t *testing.T, suspend bool) { + t.Setenv("OAC_RUNTIME_WORKSPACE", "") + peers := make(chan *websocket.Conn, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + peer, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err == nil { + peers <- peer + } + })) + defer server.Close() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + dial := func(ctx context.Context) (*transport.Conn, error) { + return transport.Dial(ctx, transport.DialOptions{ + WSURL: "ws" + strings.TrimPrefix(server.URL, "http"), DeviceID: "device", + Credential: "credential", DaemonVersion: proto.Version, + }) + } + owner := &cleanupExecutor{retry: make(chan struct{}), confirm: make(chan struct{})} + registry := agent.NewRegistry() + registry.RegisterKind(proto.SupportedAgentKind{Kind: "cleanup", Available: true, Capabilities: proto.AgentKindCapabilities{EnvironmentNone: true}}, + func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("unexpected legacy factory") + }) + var factories atomic.Int32 + registry.RegisterExecutor("cleanup", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + factories.Add(1) + return owner, nil + }) + finished := make(chan error, 1) + go func() { + boot := &transport.BootstrapResponse{HeartbeatSeconds: 60} + if suspend { + control := &suspendControl{signal: make(chan os.Signal, 1)} + finished <- runSuspendLoop(ctx, dial, registry, boot, agentCLIDiscovery{}, control) + return + } + conn, err := dial(ctx) + if err != nil { + finished <- err + return + } + defer conn.Close() + finished <- pumpConn(ctx, conn, registry, boot, agentCLIDiscovery{}) + }() + var peer *websocket.Conn + select { + case peer = <-peers: + case <-time.After(3 * time.Second): + t.Fatal("initial connection missing") + } + defer peer.Close() + env, err := proto.NewEnvelope(proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: "cleanup", + Configuration: proto.PromptRequestPayload{AgentKind: "cleanup", AgentStateKey: "agents-api-cleanup", StrictResume: true, DisableExecutionEnvironment: true}}) + if err != nil { + t.Fatal(err) + } + if err := peer.WriteJSON(env); err != nil { + t.Fatal(err) + } + _ = peer.SetReadDeadline(time.Now().Add(3 * time.Second)) + for { + var result proto.Envelope + if err := peer.ReadJSON(&result); err != nil { + t.Fatal(err) + } + if result.Type != proto.TypePreparationStatus { + continue + } + var status proto.PreparationStatusPayload + if err := result.DecodePayload(&status); err != nil { + t.Fatal(err) + } + if status.State == "ready" { + break + } + if status.State == "failed" { + t.Fatalf("preparation failed: %+v", status) + } + } + _ = peer.Close() + select { + case <-owner.retry: + case <-time.After(3 * time.Second): + t.Fatal("original executor cleanup was not retried") + } + select { + case err := <-finished: + t.Fatalf("pump discarded native ownership: %v", err) + default: + } + cancel() + close(owner.confirm) + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("pump did not return after confirmed cleanup") + } + if factories.Load() != 1 || owner.closes.Load() != 2 { + t.Fatalf("factory=%d close=%d", factories.Load(), owner.closes.Load()) + } +} diff --git a/apps/parsar-daemon/internal/cli/connect_suspend.go b/apps/parsar-daemon/internal/cli/connect_suspend.go index 38a391252..c471a12f0 100644 --- a/apps/parsar-daemon/internal/cli/connect_suspend.go +++ b/apps/parsar-daemon/internal/cli/connect_suspend.go @@ -55,10 +55,8 @@ func newSuspendedRouter(conn *transport.Conn, registry *agent.Registry) (*suspen return &suspendedRouter{router: router, bridge: bridge, sender: sender, registry: wrapped, local: local}, nil } -func (s *suspendedRouter) shutdown() error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return s.router.Shutdown(ctx) +func (s *suspendedRouter) shutdown() { + shutdownRouterUntilConfirmed(s.router.Shutdown, time.Second) } // runSuspendLoop uses the ordinary connection authentication and dispatch chain. @@ -78,10 +76,7 @@ func runSuspendLoop(ctx context.Context, dial transport.DialFn, registry *agent. return err } err = serveSuspendLifecycle(ctx, conn, dial, state, boot, discovery, control) - closeErr := state.shutdown() - if closeErr != nil { - return errors.Join(err, closeErr) - } + state.shutdown() if ctx.Err() != nil { return nil } diff --git a/apps/parsar-daemon/internal/cli/skill_upload.go b/apps/parsar-daemon/internal/cli/skill_upload.go index 935b35cd3..c77b0fefe 100644 --- a/apps/parsar-daemon/internal/cli/skill_upload.go +++ b/apps/parsar-daemon/internal/cli/skill_upload.go @@ -12,16 +12,7 @@ import ( func withSkillUploadServer(factory agent.Factory, serverURL string) agent.Factory { return func(ctx context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - env, _ := req.AgentOptions["env"].(map[string]any) - if token, _ := env["PARSAR_CAPABILITY_UPLOAD_TOKEN"].(string); token != "" { - env = maps.Clone(env) - env["PARSAR_SERVER_URL"] = serverURL - if executable, err := os.Executable(); err == nil { - addCompanionCLIPath(env, filepath.Dir(executable)) - } - req.AgentOptions = maps.Clone(req.AgentOptions) - req.AgentOptions["env"] = env - } + req = skillUploadRequest(req, serverURL) return factory(ctx, req, out) } } @@ -37,3 +28,17 @@ func addCompanionCLIPath(env map[string]any, dir string) { } env["PATH"] = dir + string(os.PathListSeparator) + path } + +func skillUploadRequest(req proto.PromptRequestPayload, serverURL string) proto.PromptRequestPayload { + env, _ := req.AgentOptions["env"].(map[string]any) + if token, _ := env["PARSAR_CAPABILITY_UPLOAD_TOKEN"].(string); token != "" { + env = maps.Clone(env) + env["PARSAR_SERVER_URL"] = serverURL + if executable, err := os.Executable(); err == nil { + addCompanionCLIPath(env, filepath.Dir(executable)) + } + req.AgentOptions = maps.Clone(req.AgentOptions) + req.AgentOptions["env"] = env + } + return req +} diff --git a/apps/parsar-daemon/internal/dispatch/executor.go b/apps/parsar-daemon/internal/dispatch/executor.go new file mode 100644 index 000000000..22fa3d708 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/executor.go @@ -0,0 +1,373 @@ +package dispatch + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +const executorIdleCapacity = 16 + +type executorState struct { + id, sessionID, environmentID, stateKey string + fingerprint [32]byte + native agent.Executor + nativeID string + ctx context.Context + cancel context.CancelFunc + admission *preparationState + run *sessionState + preparing bool + invalid bool + closeDone chan struct{} + closeReason string + closeErr error + timer *time.Timer + idleLease uint64 +} + +func executorFingerprint(req proto.PromptRequestPayload) ([32]byte, error) { + req.RunID, req.Input = "", nil + req.AgentSessionID = "" + req.RequireExistingNativeSession = false + req.ReleaseOnCompletion = false + data, err := json.Marshal(req) + return sha256.Sum256(data), err +} + +func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, input proto.ExecutionPreparePayload) error { + req := input.Configuration + if strings.TrimSpace(input.SessionID) == "" || req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" || req.WorkspaceAuthoring || req.AgentStateKey != "agents-api-"+input.SessionID || !req.StrictResume { + return r.rejectPreparation(env, "invalid_configuration") + } + caps := r.availableCapabilities(req.AgentKind) + factory, err := r.registry.ResolveExecutor(req.AgentKind) + if err != nil || !caps.Preparation { + return r.rejectPreparation(env, "unsupported_preparation") + } + req, err = r.localWorkspace.Configure(req) + if err != nil { + return r.rejectPreparation(env, "invalid_configuration") + } + if validateExecutionEnvironment(req, caps) != nil || len(req.FunctionTools) > 0 && !caps.FunctionTools { + return r.rejectPreparation(env, "unsupported_configuration") + } + fingerprint, err := executorFingerprint(req) + if err != nil { + return r.rejectPreparation(env, "invalid_configuration") + } + encoded, err := json.Marshal(input) + if err != nil { + return r.rejectPreparation(env, "invalid_configuration") + } + requestFingerprint := sha256.Sum256(encoded) + r.mu.Lock() + if r.closed || r.suspension != nil { + r.mu.Unlock() + return ErrRouterClosed + } + if r.workspaceWrite != nil || r.workspaceExport != nil { + r.mu.Unlock() + return r.rejectPreparation(env, "resource_unavailable") + } + r.prunePreparationsLocked() + if old := r.preparationRequests[env.ID]; old != nil { + matches, status := old.fingerprint == requestFingerprint, old.status + r.mu.Unlock() + if !matches { + return r.rejectPreparation(env, "request_conflict") + } + r.publishPreparation(old, status) + return nil + } + if len(r.preparations) >= preparationRecords { + r.mu.Unlock() + return r.rejectPreparation(env, "preparation_capacity") + } + active := 0 + for _, candidate := range r.executors { + if candidate.sessionID != input.SessionID && candidate.stateKey == req.AgentStateKey { + r.mu.Unlock() + return r.rejectPreparation(env, "session_binding_conflict") + } + } + for _, owner := range r.executors { + if owner.preparing || owner.admission != nil || owner.run != nil || owner.invalid { + active++ + } + } + if active >= preparationCapacity { + r.mu.Unlock() + return r.rejectPreparation(env, "preparation_capacity") + } + owner := r.executors[input.SessionID] + reused := owner != nil + if owner != nil { + code := "" + switch { + case owner.environmentID != req.EnvironmentID() || owner.fingerprint != fingerprint: + code = "configuration_conflict" + case owner.invalid: + code = "executor_cleanup_unconfirmed" + case owner.preparing || owner.admission != nil || owner.run != nil: + code = "resource_unavailable" + case req.AgentSessionID != owner.nativeID: + code = "native_session_conflict" + case req.RequireExistingNativeSession && owner.nativeID == "": + code = "history_unavailable" + } + if code != "" { + r.mu.Unlock() + return r.rejectPreparation(env, code) + } + if owner.timer != nil { + owner.timer.Stop() + } + owner.idleLease++ + } else { + if len(r.executors) >= executorIdleCapacity+preparationCapacity { + r.mu.Unlock() + return r.rejectPreparation(env, "executor_capacity") + } + ownerCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) + owner = &executorState{id: uuid.NewString(), sessionID: input.SessionID, environmentID: req.EnvironmentID(), stateKey: req.AgentStateKey, fingerprint: fingerprint, ctx: ownerCtx, cancel: cancel, preparing: true, nativeID: req.AgentSessionID} + r.executors[input.SessionID] = owner + r.log.Info("executor owner_created", "executor_id", owner.id, "session_id", owner.sessionID) + } + operation, cancel := context.WithCancel(context.WithoutCancel(ctx)) + p := &preparationState{requestID: env.ID, trace: env.Trace, fingerprint: requestFingerprint, ctx: operation, cancel: cancel, stateKey: req.AgentStateKey, environmentID: req.EnvironmentID(), executor: owner, owns: true, busy: !reused, deadline: time.Now().Add(r.preparationTimeout)} + state := "preparing" + if reused { + state = "ready" + } + p.status = proto.PreparationStatusPayload{Handle: uuid.NewString(), ExecutorID: owner.id, Revision: 1, State: state, Reused: reused, ExpiresAt: p.deadline.UnixMilli()} + owner.admission = p + r.preparations[p.status.Handle], r.preparationRequests[env.ID] = p, p + p.timer = time.AfterFunc(r.preparationTimeout, func() { r.releasePreparation(p, "expired", "", true, true) }) + if !reused { + r.shutdownWG.Add(1) + } + status := p.status + r.mu.Unlock() + if reused { + r.publishPreparation(p, status) + } else { + go r.prepareExecutor(p, req, factory) + } + return nil +} + +func (r *Router) prepareExecutor(p *preparationState, req proto.PromptRequestPayload, factory agent.ExecutorFactory) { + defer r.shutdownWG.Done() + owner := p.executor + started := time.Now() + r.mu.Lock() + initial := p.status + r.mu.Unlock() + if !r.sendPreparation(p.requestID, p.trace, initial) { + r.abandonExecutorAdmission(p, "failed", "status_delivery_failed", false) + } + var native agent.Executor + var err error + if owner.ctx.Err() == nil { + native, err = factory(owner.ctx, req) + } else { + err = owner.ctx.Err() + } + r.mu.Lock() + owner.native, owner.preparing = native, false + r.log.Info("executor native_prepare", "executor_id", owner.id, "session_id", owner.sessionID, "duration_ms", time.Since(started).Milliseconds(), "success", err == nil && native != nil) + ready := native != nil && err == nil && !owner.invalid && !r.closed && r.suspension == nil && p.status.State == "preparing" && p.ctx.Err() == nil + p.busy = false + if ready { + p.status.State, p.status.Revision = "ready", p.status.Revision+1 + } else { + owner.invalid = true + owner.closeReason = "preparation_failed" + if p.status.State == "preparing" { + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "preparation_failed", p.status.Revision+1 + } + p.timer.Stop() + } + status := p.status + r.mu.Unlock() + if !ready { + closeErr := r.closeExecutor(owner) + r.mu.Lock() + p.owns, p.closeErr = closeErr != nil, closeErr + if closeErr != nil { + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "executor_cleanup_unconfirmed", p.status.Revision+1 + } + status = p.status + r.mu.Unlock() + } + if !r.sendPreparation(p.requestID, p.trace, status) && ready { + r.abandonExecutorAdmission(p, "failed", "status_delivery_failed", false) + } +} + +func (r *Router) abandonExecutorAdmission(p *preparationState, state, code string, publish bool) { + r.mu.Lock() + owner := p.executor + if p.handoff != nil || p.status.State == "started" { + status := p.status + r.mu.Unlock() + if publish && state == "released" { + r.publishPreparation(p, status) + } + return + } + if p.status.State == "preparing" || p.status.State == "ready" { + p.status.State, p.status.ErrorCode, p.status.Revision = state, code, p.status.Revision+1 + p.timer.Stop() + p.cancel() + p.owns = false + if owner.admission == p { + owner.admission = nil + } + if owner.preparing { + owner.invalid = true + owner.cancel() + } else if !owner.invalid { + r.scheduleExecutorIdleLocked(owner) + } + } + status := p.status + r.mu.Unlock() + if publish { + r.publishPreparation(p, status) + } +} + +func (r *Router) scheduleExecutorIdleLocked(owner *executorState) { + if owner.invalid || owner.preparing || owner.run != nil || owner.admission != nil { + return + } + if owner.timer != nil { + owner.timer.Stop() + } + idle := 0 + for _, candidate := range r.executors { + if candidate != owner && candidate.run == nil && candidate.admission == nil && !candidate.preparing { + idle++ + } + } + if idle >= executorIdleCapacity { + owner.invalid = true + owner.closeReason = "idle_capacity" + r.shutdownWG.Add(1) + go func() { defer r.shutdownWG.Done(); _ = r.closeExecutor(owner) }() + return + } + owner.idleLease++ + lease := owner.idleLease + r.log.Info("executor owner_idle", "executor_id", owner.id, "session_id", owner.sessionID) + owner.timer = time.AfterFunc(r.idleTimeout, func() { + r.mu.Lock() + if r.executors[owner.sessionID] != owner || owner.idleLease != lease || owner.run != nil || owner.admission != nil || owner.invalid { + r.mu.Unlock() + return + } + owner.invalid = true + owner.closeReason = "idle_expired" + r.shutdownWG.Add(1) + r.mu.Unlock() + defer r.shutdownWG.Done() + _ = r.closeExecutor(owner) + }) +} + +// Close failures keep the exact owner; a later call retries only settled failures. +func (r *Router) closeExecutor(owner *executorState) error { + r.mu.Lock() + owner.invalid = true + if owner.closeReason == "" { + owner.closeReason = "invalidated" + } + if owner.timer != nil { + owner.timer.Stop() + } + if owner.preparing { + owner.cancel() + r.mu.Unlock() + return errors.New("executor preparation is still settling") + } + if done := owner.closeDone; done != nil { + select { + case <-done: + if owner.closeErr == nil { + r.mu.Unlock() + return nil + } + default: + r.mu.Unlock() + <-done + r.mu.Lock() + err := owner.closeErr + r.mu.Unlock() + return err + } + } + done := make(chan struct{}) + owner.closeDone = done + native := owner.native + r.mu.Unlock() + var err error + if native != nil { + ctx, cancel := context.WithTimeout(context.Background(), preparedCancelTimeout) + err = native.Close(ctx) + cancel() + } + r.mu.Lock() + owner.closeErr = err + if err == nil { + owner.cancel() + } + r.log.Info("executor owner_closed", "executor_id", owner.id, "session_id", owner.sessionID, "confirmed", err == nil, "reason", owner.closeReason) + if err == nil && owner.run == nil && r.executors[owner.sessionID] == owner { + delete(r.executors, owner.sessionID) + } + close(done) + r.mu.Unlock() + return err +} + +func (r *Router) closeIdleExecutorsLocked() []*executorState { + var owners []*executorState + for _, owner := range r.executors { + owner.invalid = true + owner.closeReason = "shutdown" + if owner.preparing { + owner.cancel() + } + if owner.timer != nil { + owner.timer.Stop() + } + if owner.admission != nil && owner.run == nil { + p := owner.admission + p.cancel() + p.timer.Stop() + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "connection_closed", p.status.Revision+1 + p.owns = false + owner.admission = nil + } + if !owner.preparing && owner.run == nil { + r.shutdownWG.Add(1) + owners = append(owners, owner) + } + } + return owners +} + +func (r *Router) closeIdleExecutors(owners []*executorState) { + for _, owner := range owners { + go func() { defer r.shutdownWG.Done(); _ = r.closeExecutor(owner) }() + } +} diff --git a/apps/parsar-daemon/internal/dispatch/executor_test.go b/apps/parsar-daemon/internal/dispatch/executor_test.go new file mode 100644 index 000000000..db02f9ff8 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/executor_test.go @@ -0,0 +1,312 @@ +package dispatch_test + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type reusableExecutor struct { + starts chan *reusableTurn + closes atomic.Int32 + mu sync.Mutex + closeErr error + startErr error +} + +func (e *reusableExecutor) Close(context.Context) error { + e.closes.Add(1) + e.mu.Lock() + defer e.mu.Unlock() + return e.closeErr +} +func (e *reusableExecutor) StartTurn(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + if e.startErr != nil { + return nil, e.startErr + } + t := &reusableTurn{id: id, out: out, settled: make(chan struct{})} + e.starts <- t + return t, nil +} + +type reusableTurn struct { + id string + out chan<- proto.Envelope + settled chan struct{} + once sync.Once + cancels atomic.Int32 +} + +func (t *reusableTurn) finish() { + t.once.Do(func() { + frame, _ := proto.NewEnvelope(proto.TypeDone, t.id, t.CancellationOutcome()) + t.out <- frame + close(t.out) + close(t.settled) + }) +} +func (t *reusableTurn) Cancel(context.Context) error { t.cancels.Add(1); t.finish(); return nil } +func (t *reusableTurn) CancellationOutcome() proto.DonePayload { + return proto.DonePayload{Content: t.id, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-session"}} +} +func (t *reusableTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + select { + case <-t.settled: + return agent.TurnSettlement{Reusable: true}, nil + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} + +func executorRequest() proto.ExecutionPreparePayload { + return proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "reusable", AgentStateKey: "agents-api-session", StrictResume: true, DisableExecutionEnvironment: true}} +} +func executorRouter(t *testing.T, owner *reusableExecutor, idle time.Duration) (*dispatch.Router, *recSender, *atomic.Int32) { + t.Helper() + calls := &atomic.Int32{} + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: proto.AgentKindCapabilities{EnvironmentNone: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("ordinary factory is forbidden") + }) + reg.RegisterExecutor("reusable", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + calls.Add(1) + return owner, nil + }) + sender := &recSender{} + router, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, IdleTimeout: idle}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + owner.mu.Lock() + owner.closeErr = nil + owner.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := router.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + return router, sender, calls +} +func executorAdmission(t *testing.T, r *dispatch.Router, s *recSender, key string, req proto.ExecutionPreparePayload) proto.PreparationStatusPayload { + t.Helper() + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, key, req)); err != nil { + t.Fatal(err) + } + return waitPreparationStatus(t, s, key, "ready", "") +} +func startExecutorTurn(t *testing.T, r *dispatch.Router, s *recSender, key, id string, p proto.PreparationStatusPayload) { + t.Helper() + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, key, proto.ExecutionStartPayload{Handle: p.Handle, ExecutorID: p.ExecutorID, RunID: id, Input: proto.TextInput("input")})); err != nil { + t.Fatal(err) + } + waitPreparationStatus(t, s, key, "started", "") +} +func TestExecutorRetainsOwnerAcrossIndependentTurns(t *testing.T) { + owner := &reusableExecutor{starts: make(chan *reusableTurn, 2)} + r, s, calls := executorRouter(t, owner, time.Minute) + first := executorAdmission(t, r, s, "first", executorRequest()) + startExecutorTurn(t, r, s, "first", "one", first) + turn := <-owner.starts + turn.finish() + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "first Turn settled") + if owner.closes.Load() != 0 || turn.cancels.Load() != 0 { + t.Fatal("normal completion tore down or cancelled the executor") + } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "first", proto.ExecutionReleasePayload{Handle: first.Handle})) + req := executorRequest() + req.Configuration.AgentSessionID = "native-session" + req.Configuration.RequireExistingNativeSession = true + second := executorAdmission(t, r, s, "second", req) + if second.Handle == first.Handle || second.ExecutorID != first.ExecutorID || !second.Reused || calls.Load() != 1 { + t.Fatalf("reuse identities: first=%+v second=%+v calls=%d", first, second, calls.Load()) + } + startExecutorTurn(t, r, s, "second", "two", second) + next := <-owner.starts + _ = turn.Cancel(t.Context()) + if next.cancels.Load() != 0 { + t.Fatal("old Turn cancellation reached its successor") + } + next.finish() + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "second Turn settled") + if owner.closes.Load() != 0 { + t.Fatal("executor closed between Turns") + } +} +func TestExecutorCancelSettlesTurnWithoutClosingOwner(t *testing.T) { + owner := &reusableExecutor{starts: make(chan *reusableTurn, 1)} + r, s, _ := executorRouter(t, owner, time.Minute) + p := executorAdmission(t, r, s, "first", executorRequest()) + startExecutorTurn(t, r, s, "first", "run", p) + turn := <-owner.starts + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})); err != nil { + t.Fatal(err) + } + waitFor(t, func() bool { return len(cancellationAcks(s)) == 1 }, "cancellation receipt") + ack := cancellationAcks(s)[0] + if !ack.Applied || ack.Outcome == nil || turn.cancels.Load() != 1 || owner.closes.Load() != 0 { + t.Fatalf("cancel=%+v closes=%d", ack, owner.closes.Load()) + } +} +func TestExecutorRejectsConfigurationAndNativeIdentityChanges(t *testing.T) { + owner := &reusableExecutor{starts: make(chan *reusableTurn, 1)} + r, s, calls := executorRouter(t, owner, time.Minute) + p := executorAdmission(t, r, s, "first", executorRequest()) + startExecutorTurn(t, r, s, "first", "one", p) + (<-owner.starts).finish() + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "Turn settlement") + for _, kind := range []string{"configuration", "native"} { + req := executorRequest() + if kind == "configuration" { + req.Configuration.AgentOptions = map[string]any{"model": "changed"} + } else { + req.Configuration.AgentSessionID = "other-native" + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, kind, req)); err == nil { + t.Fatalf("accepted changed %s", kind) + } + } + if calls.Load() != 1 || owner.closes.Load() != 0 { + t.Fatal("conflict replaced executor") + } +} +func TestExecutorIdleExpiryClosesRetainedOwner(t *testing.T) { + owner := &reusableExecutor{starts: make(chan *reusableTurn, 1)} + r, s, _ := executorRouter(t, owner, 30*time.Millisecond) + p := executorAdmission(t, r, s, "first", executorRequest()) + startExecutorTurn(t, r, s, "first", "one", p) + (<-owner.starts).finish() + waitFor(t, func() bool { return owner.closes.Load() == 1 }, "idle executor close") +} +func TestExecutorPreInputFailureConfirmsCloseBeforeRetrySignal(t *testing.T) { + for _, unconfirmed := range []bool{false, true} { + t.Run(map[bool]string{false: "closed", true: "unconfirmed"}[unconfirmed], func(t *testing.T) { + owner := &reusableExecutor{starts: make(chan *reusableTurn, 1), startErr: errors.New("native exited")} + if unconfirmed { + owner.closeErr = errors.New("cleanup unknown") + } + r, s, _ := executorRouter(t, owner, time.Minute) + p := executorAdmission(t, r, s, "first", executorRequest()) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "first", proto.ExecutionStartPayload{Handle: p.Handle, ExecutorID: p.ExecutorID, RunID: "run", Input: proto.TextInput("input")})) + status := waitPreparationStatus(t, s, "first", "rejected", "") + want := "executor_unavailable" + if unconfirmed { + want = "executor_cleanup_unconfirmed" + } + if status.ErrorCode != want || owner.closes.Load() == 0 { + t.Fatalf("status=%+v closes=%d", status, owner.closes.Load()) + } + if unconfirmed { + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "retry", executorRequest())); err == nil { + t.Fatal("replaced unconfirmed owner") + } + } + }) + } +} + +func poolRouter(t *testing.T, factory agent.ExecutorFactory) (*dispatch.Router, *recSender) { + t.Helper() + registry := agent.NewRegistry() + registry.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: proto.AgentKindCapabilities{EnvironmentNone: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("unexpected legacy factory") + }) + registry.RegisterExecutor("reusable", factory) + sender := &recSender{} + router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sender, IdleTimeout: time.Minute}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := router.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + return router, sender +} +func poolRequest(id string) proto.ExecutionPreparePayload { + req := executorRequest() + req.SessionID = id + req.Configuration.AgentStateKey = "agents-api-" + id + return req +} + +func TestExecutorIdleAndActiveCapacitiesAreIndependent(t *testing.T) { + var mu sync.Mutex + var owners []*reusableExecutor + r, s := poolRouter(t, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + e := &reusableExecutor{} + mu.Lock() + owners = append(owners, e) + mu.Unlock() + return e, nil + }) + for i := 0; i < 17; i++ { + id := fmt.Sprint("idle-", i) + ready := executorAdmission(t, r, s, id, poolRequest(id)) + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, id, proto.ExecutionReleasePayload{Handle: ready.Handle})); err != nil { + t.Fatal(err) + } + } + waitFor(t, func() bool { + mu.Lock() + defer mu.Unlock() + var closed int32 + for _, e := range owners { + closed += e.closes.Load() + } + return closed == 1 + }, "idle capacity eviction") + for i := 0; i < 4; i++ { + id := fmt.Sprint("active-", i) + executorAdmission(t, r, s, id, poolRequest(id)) + } + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "overflow", poolRequest("overflow"))); err == nil { + t.Fatal("active capacity exceeded") + } +} + +func TestExecutorRejectsSessionStateScopeMismatch(t *testing.T) { + e := &reusableExecutor{} + r, s, calls := executorRouter(t, e, time.Minute) + executorAdmission(t, r, s, "one", executorRequest()) + req := executorRequest() + req.SessionID = "another" + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "scope", req)); err == nil { + t.Fatal("two sessions shared native state") + } + if calls.Load() != 1 { + t.Fatal("mismatched session invoked native preparation") + } +} + +func TestExecutorRejectsOutputFromAnotherTurn(t *testing.T) { + e := &reusableExecutor{starts: make(chan *reusableTurn, 1)} + r, s, _ := executorRouter(t, e, time.Minute) + p := executorAdmission(t, r, s, "one", executorRequest()) + startExecutorTurn(t, r, s, "one", "current", p) + turn := <-e.starts + wrong, _ := proto.NewEnvelope(proto.TypeDelta, "old-turn", proto.DeltaPayload{Delta: "late"}) + turn.out <- wrong + turn.finish() + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "invalid native event cleanup") + if e.closes.Load() != 1 { + t.Fatal("invalid event left executor reusable") + } + for _, frame := range s.snapshot() { + if frame.ID == "old-turn" { + t.Fatal("old Turn output escaped") + } + } +} diff --git a/apps/parsar-daemon/internal/dispatch/local_directory_test.go b/apps/parsar-daemon/internal/dispatch/local_directory_test.go index 0515a59d6..56605ca58 100644 --- a/apps/parsar-daemon/internal/dispatch/local_directory_test.go +++ b/apps/parsar-daemon/internal/dispatch/local_directory_test.go @@ -57,7 +57,7 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing if got := waitWorkspaceRead(t, sender, "foreign"); got.Outcome != "rejected" { t.Fatal("foreign directory accepted", got) } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "idle", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "forbidden", Input: proto.TextInput("work")})); err == nil { + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "idle", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "forbidden", Input: proto.TextInput("work")})); err == nil { t.Fatal("read preparation admitted execution") } bad := request diff --git a/apps/parsar-daemon/internal/dispatch/mcp_http_test.go b/apps/parsar-daemon/internal/dispatch/mcp_http_test.go index 127a7f96d..13bb7e76e 100644 --- a/apps/parsar-daemon/internal/dispatch/mcp_http_test.go +++ b/apps/parsar-daemon/internal/dispatch/mcp_http_test.go @@ -102,7 +102,7 @@ func TestLocalMCPRejectsBeforePreparationFactory(t *testing.T) { t.Error("ordinary factory called") return nil, errors.New("unexpected") }) - h.reg.RegisterPreparation("prepared", false, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + h.reg.RegisterExecutor("prepared", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { entered <- struct{}{} return nil, errors.New("controlled stop") }) diff --git a/apps/parsar-daemon/internal/dispatch/preparation.go b/apps/parsar-daemon/internal/dispatch/preparation.go index acfe25be6..26e230cab 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation.go +++ b/apps/parsar-daemon/internal/dispatch/preparation.go @@ -19,6 +19,7 @@ const preparationRecords = 64 // All mutable fields are protected by Router.mu. owns includes resources whose // cancellation is underway; a slow close cannot bypass the capacity bound. type preparationState struct { + executor *executorState requestID string trace string fingerprint [32]byte @@ -44,6 +45,9 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) return r.rejectPreparation(env, "invalid_request") } req := input.Configuration + if !req.WorkspaceReadOnly { + return r.handleExecutorPrepare(ctx, env, input) + } caps := r.availableCapabilities(req.AgentKind) prepare, err := r.registry.ResolvePreparation(req.AgentKind) if err != nil || !caps.Preparation { @@ -179,6 +183,11 @@ func (r *Router) releasePreparation(p *preparationState, state, code string, pub r.mu.Unlock() return } + if p.executor != nil { + r.mu.Unlock() + r.abandonExecutorAdmission(p, state, code, publish) + return + } if p.handoff != nil { if active := r.sessions[p.status.RunID]; active != nil && active.preparedHandoff == p.handoff { if state == "expired" && p.handoff.published { diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go b/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go index d5fd98ec6..ecba30e74 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_cancel_test.go @@ -3,7 +3,6 @@ package dispatch_test import ( "context" "errors" - "fmt" "reflect" "sync" "sync/atomic" @@ -49,7 +48,7 @@ func startCancellationPreparation(t *testing.T, r *dispatch.Router, sender *recS t.Fatal(err) } ready := waitPreparationStatus(t, sender, "request", "ready", "") - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "run", Input: proto.TextInput("input")})); err != nil { + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "run", Input: proto.TextInput("input")})); err != nil { t.Fatal(err) } return ready @@ -167,7 +166,7 @@ func TestPreparedCancellationWaitsForOutputAndCleanup(t *testing.T) { } func TestPreparedCancellationBeforeTransferPreservesUnknownOutcome(t *testing.T) { - for _, boundary := range []string{"start_failure", "release", "expiry", "shutdown"} { + for _, boundary := range []string{"start_failure", "release", "shutdown"} { t.Run(boundary, func(t *testing.T) { sender := &recSender{} entered, cancelled, allowReturn := make(chan struct{}), make(chan struct{}), make(chan struct{}) @@ -195,7 +194,7 @@ func TestPreparedCancellationBeforeTransferPreservesUnknownOutcome(t *testing.T) switch boundary { case "release": _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "request", proto.ExecutionReleasePayload{Handle: ready.Handle})) - waitPreparationStatus(t, sender, "request", "released", "") + // An admission release does not terminate a handed-off Turn. case "expiry": waitPreparationStatus(t, sender, "request", "expired", "") case "shutdown": @@ -248,6 +247,9 @@ func TestPreparedCancellationFailuresRemainConservative(t *testing.T) { startCancellationPreparation(t, r, sender.recSender) <-entered _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) + if failure == "cancel_failed" { + waitFor(t, func() bool { return p.calls.Load() == 1 }, "failed pre-transfer cleanup") + } close(allowReturn) waitFor(t, func() bool { return len(cancellationAcks(sender.recSender)) == 1 }, "failed cancellation") ack := cancellationAcks(sender.recSender)[0] @@ -255,17 +257,21 @@ func TestPreparedCancellationFailuresRemainConservative(t *testing.T) { t.Fatalf("uncertain cancellation accepted: %+v", ack) } if failure == "cancel_failed" { - if r.ActiveRuns() != 1 || session.cancels() != 0 { - t.Fatal("failed attempt released ownership or used a fallback target") + waitFor(t, func() bool { p.mu.Lock(); defer p.mu.Unlock(); return p.session != nil }, "late Turn returned") + if r.ActiveRuns() != 1 { + t.Fatal("unconfirmed cleanup lost owner") } _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "retry"})) - waitFor(t, func() bool { return len(cancellationAcks(sender.recSender)) == 2 && r.ActiveRuns() == 0 }, "same-target cancellation retry") - if retry := cancellationAcks(sender.recSender)[1]; !retry.Applied || retry.ErrorCode != "" || session.cancels() != 1 || p.calls.Load() != 2 { - t.Fatalf("same target was not retried exactly once: ack=%+v calls=%d session=%d", retry, p.calls.Load(), session.cancels()) + waitFor(t, func() bool { return len(cancellationAcks(sender.recSender)) == 2 }, "cleanup retry receipt") + if !cancellationAcks(sender.recSender)[1].Applied { + t.Fatal("confirmed retry was rejected") } - } else if r.ActiveRuns() != 0 || session.cancels() != 1 { - t.Fatal("successful cleanup did not settle output failure") } + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "confirmed resource cleanup") + if session.cancels() != 1 { + t.Fatal("confirmed cleanup did not stop the native Turn") + } + }) } } @@ -310,11 +316,7 @@ func TestPreparedCancellationTimeoutKeepsCapacityUntilStartReturns(t *testing.T) }) startCancellationPreparation(t, r, sender) <-entered - for i := 0; i < 3; i++ { - id := fmt.Sprint(i) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, id, preparationRequest())) - waitPreparationStatus(t, sender, id, "ready", "") - } + _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"})) deadline := time.Now().Add(12 * time.Second) for len(cancellationAcks(sender)) == 0 && time.Now().Before(deadline) { diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cleanup.go b/apps/parsar-daemon/internal/dispatch/preparation_cleanup.go index 2c3cd176b..2a51c0e8c 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_cleanup.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_cleanup.go @@ -32,6 +32,9 @@ func (r *Router) closePendingPreparationsLocked() []*preparationState { var closeNow []*preparationState for _, p := range r.preparations { p.timer.Stop() + if p.executor != nil { + continue + } if !p.owns { continue } diff --git a/apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go b/apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go index 7c08293d6..a36355bd5 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_cleanup_test.go @@ -55,7 +55,7 @@ func TestPreparedCancellationDoesNotAcknowledgeFailedCleanup(t *testing.T) { if owned, _ := r.PreparationOwnershipForTest(ready.Handle); !owned { t.Fatal("cancel receipt discarded unsettled preparation") } - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "request", proto.ExecutionReleasePayload{Handle: ready.Handle})) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel-retry"})) waitFor(t, func() bool { owned, _ := r.PreparationOwnershipForTest(ready.Handle); return !owned }, "retained cancellation cleanup") } @@ -69,7 +69,7 @@ func TestShutdownRetriesFailedPreparedCancellationOnSameTarget(t *testing.T) { return session, nil } p.cancel = func(ctx context.Context) error { - if p.calls.Load() == 1 { + if p.calls.Load() <= 2 { return want } return session.Cancel(ctx) @@ -80,7 +80,7 @@ func TestShutdownRetriesFailedPreparedCancellationOnSameTarget(t *testing.T) { if err := r.Shutdown(t.Context()); !errors.Is(err, want) { t.Fatalf("first shutdown lost native failure: %v", err) } - if r.ActiveRuns() != 1 || p.calls.Load() != 1 || session.cancels() != 0 { + if r.ActiveRuns() != 1 || p.calls.Load() != 2 || session.cancels() != 0 { t.Fatal("failed shutdown released ownership or changed release target") } select { @@ -91,78 +91,11 @@ func TestShutdownRetriesFailedPreparedCancellationOnSameTarget(t *testing.T) { if err := r.Shutdown(t.Context()); err != nil { t.Fatal(err) } - if r.ActiveRuns() != 0 || p.calls.Load() != 2 || session.cancels() != 1 { + if r.ActiveRuns() != 0 || p.calls.Load() != 3 || session.cancels() != 1 { t.Fatalf("shutdown did not retry the same target once: active=%d target=%d session=%d", r.ActiveRuns(), p.calls.Load(), session.cancels()) } } -func TestPreparationCloseFailureRetainsCapacityAndRetries(t *testing.T) { - sender := &recSender{} - entered, release := make(chan struct{}), make(chan struct{}) - var once sync.Once - unblock := func() { once.Do(func() { close(release) }) } - p := &retryablePreparation{close: func(call int32) error { - if call == 1 { - return errors.New("cleanup incomplete") - } - if call == 2 { - close(entered) - <-release - } - return nil - }} - var factories atomic.Int32 - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - if factories.Add(1) == 1 { - return p, nil - } - return &controlledPreparation{closed: make(chan struct{})}, nil - }) - t.Cleanup(unblock) - var handle string - for i := range 4 { - id := fmt.Sprint(i) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, id, preparationRequest())) - ready := waitPreparationStatus(t, sender, id, "ready", "") - if i == 0 { - handle = ready.Handle - } - } - request := mustEnv(t, proto.TypeExecutionRelease, "0", proto.ExecutionReleasePayload{Handle: handle}) - _ = r.Handle(t.Context(), request) - waitFor(t, func() bool { _, busy := r.PreparationOwnershipForTest(handle); return p.calls.Load() == 1 && !busy }, "failed cleanup return") - if owned, _ := r.PreparationOwnershipForTest(handle); !owned { - t.Fatal("failed cleanup discarded ownership") - } - replacement := mustEnv(t, proto.TypeExecutionPrepare, "replacement", preparationRequest()) - if err := r.Handle(t.Context(), replacement); err == nil { - t.Fatal("failed cleanup released capacity") - } - start := proto.ExecutionStartPayload{Handle: handle, RunID: "run", Input: proto.TextInput("do not execute")} - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "0", start)); err == nil { - t.Fatal("failed cleanup allowed Start") - } - _ = r.Handle(t.Context(), request) - select { - case <-entered: - case <-time.After(time.Second): - t.Fatal("release did not retry retained resource") - } - _ = r.Handle(t.Context(), request) - if p.calls.Load() != 2 { - t.Fatal("release started overlapping cleanup") - } - unblock() - waitFor(t, func() bool { owned, _ := r.PreparationOwnershipForTest(handle); return !owned }, "successful cleanup") - if err := r.Handle(t.Context(), replacement); err != nil { - t.Fatalf("settled cleanup retained capacity: %v", err) - } - waitPreparationStatus(t, sender, "replacement", "ready", "") - if p.starts.Load() != 0 { - t.Fatal("cleanup restarted native execution") - } -} - func TestShutdownRetriesFailedPreparationCleanup(t *testing.T) { want := errors.New("native cleanup incomplete") p := &retryablePreparation{close: func(call int32) error { @@ -174,18 +107,16 @@ func TestShutdownRetriesFailedPreparationCleanup(t *testing.T) { sender := &recSender{} r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) - handle := waitPreparationStatus(t, sender, "request", "ready", "").Handle + waitPreparationStatus(t, sender, "request", "ready", "") if err := r.Shutdown(t.Context()); !errors.Is(err, want) { t.Fatalf("shutdown lost cleanup error: %v", err) } - if owned, _ := r.PreparationOwnershipForTest(handle); !owned { - t.Fatal("shutdown discarded unsettled resource") - } + if err := r.Shutdown(t.Context()); err != nil { t.Fatal(err) } - if owned, _ := r.PreparationOwnershipForTest(handle); owned || p.calls.Load() != 2 { - t.Fatalf("retry did not settle original resource: owned=%t calls=%d", owned, p.calls.Load()) + if p.calls.Load() != 2 { + t.Fatalf("retry did not settle original resource: calls=%d", p.calls.Load()) } } @@ -239,7 +170,7 @@ func TestPublishedPreparedRunRetainsRetryAfterHandleRetirement(t *testing.T) { return session, nil } p.cancel = func(ctx context.Context) error { - if p.calls.Load() == 1 { + if p.calls.Load() <= 2 { return errors.New("cleanup incomplete") } return session.Cancel(ctx) @@ -260,7 +191,7 @@ func TestPublishedPreparedRunRetainsRetryAfterHandleRetirement(t *testing.T) { } // A later admission retires the expired preparation record. The Run still // owns the exact cancellation target independently of that old handle. - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "replacement", preparationRequest())); err != nil { + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "replacement", readOnlyPreparationRequest())); err != nil { t.Fatal(err) } waitPreparationStatus(t, sender, "replacement", "ready", "") @@ -278,7 +209,7 @@ func TestPublishedPreparedRunRetainsRetryAfterHandleRetirement(t *testing.T) { } } } - if p.calls.Load() != 2 || session.cancels() != 1 || r.ActiveRuns() != 0 { + if p.calls.Load() != 3 || session.cancels() != 1 || r.ActiveRuns() != 0 { t.Fatal("Run cancellation did not retry and settle the same target") } select { @@ -287,3 +218,9 @@ func TestPublishedPreparedRunRetainsRetryAfterHandleRetirement(t *testing.T) { default: } } + +func readOnlyPreparationRequest() proto.ExecutionPreparePayload { + req := preparationRequest() + req.Configuration.WorkspaceReadOnly = true + return req +} diff --git a/apps/parsar-daemon/internal/dispatch/preparation_executor_fixture_test.go b/apps/parsar-daemon/internal/dispatch/preparation_executor_fixture_test.go new file mode 100644 index 000000000..c82ac841e --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/preparation_executor_fixture_test.go @@ -0,0 +1,133 @@ +package dispatch_test + +import ( + "context" + "errors" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Old fault-injection fixtures model disposable executors, not reusable native implementations. +func preparationExecutorFixture(factory agent.PreparationFactory) agent.ExecutorFactory { + return func(ctx context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + prepared, err := factory(ctx, req) + if prepared == nil { + return nil, err + } + owner := &preparationExecutor{prepared: prepared} + if _, ok := prepared.(agent.PreparedCancellation); !ok { + return owner, errors.New("fixture has no cancellation target") + } + return owner, err + } +} + +type preparationExecutor struct { + prepared agent.Prepared + mu sync.Mutex + cancelled bool +} + +func (e *preparationExecutor) Close(ctx context.Context) error { + e.mu.Lock() + defer e.mu.Unlock() + if e.cancelled { + return nil + } + var err error + if resource, ok := e.prepared.(*retryablePreparation); ok { + err = resource.Close() + } else if cancel, ok := e.prepared.(agent.PreparedCancellation); ok { + err = cancel.Cancel(ctx) + } else { + err = e.prepared.Close() + } + e.cancelled = err == nil + return err +} + +func (e *preparationExecutor) StartTurn(ctx context.Context, id string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + upstream := make(chan proto.Envelope, 64) + terminal := make(chan struct{}) + var once sync.Once + go func() { + defer close(out) + defer once.Do(func() { close(terminal) }) + for event := range upstream { + if event.Type == proto.TypeDone { + once.Do(func() { close(terminal) }) + } + out <- event + } + }() + session, err := e.prepared.Start(ctx, id, input, upstream) + if session == nil { + close(upstream) + // This fixture's old Start may have produced output before rejecting; retain its Turn. + return &preparationTurn{owner: e, settled: terminal}, err + } + return &preparationTurn{Session: session, owner: e, settled: terminal}, err +} + +type preparationTurn struct { + agent.Session + owner *preparationExecutor + settled <-chan struct{} +} + +func (t *preparationTurn) Cancel(ctx context.Context) error { return t.owner.Close(ctx) } +func (t *preparationTurn) CancellationOutcome() proto.DonePayload { + if target, ok := t.owner.prepared.(agent.PreparedCancellation); ok { + return target.CancellationOutcome() + } + return proto.DonePayload{} +} +func (t *preparationTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + select { + case <-t.settled: + return agent.TurnSettlement{Reason: "disposable fault fixture"}, t.owner.Close(ctx) + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} +func (t *preparationTurn) SubmitFunctionResult(ctx context.Context, p proto.FunctionResultPayload) error { + if target, ok := t.Session.(agent.FunctionResultSubmitter); ok { + return target.SubmitFunctionResult(ctx, p) + } + return agent.ErrUnknownFunctionCall +} +func (t *preparationTurn) Steer(ctx context.Context, p proto.PromptSteerPayload) error { + if target, ok := t.Session.(agent.Steerer); ok { + return target.Steer(ctx, p) + } + return agent.ErrSteeringInactive +} +func (t *preparationTurn) SteerWithReceipt(ctx context.Context, p proto.PromptSteerPayload, write func()) error { + if target, ok := t.Session.(agent.DurableSteerer); ok { + return target.SteerWithReceipt(ctx, p, write) + } + if target, ok := t.Session.(agent.Steerer); ok { + return target.Steer(ctx, p) + } + return agent.ErrSteeringInactive +} +func (t *preparationTurn) SubmitPermission(ctx context.Context, id string, p proto.PermissionDecisionPayload) error { + if target, ok := t.Session.(agent.PermissionResponder); ok { + return target.SubmitPermission(ctx, id, p) + } + return agent.ErrUnknownPermission +} +func (t *preparationTurn) SubmitPromptForUserChoice(ctx context.Context, id string, p proto.PromptForUserChoiceDecisionPayload) error { + if target, ok := t.Session.(agent.UserChoiceResponder); ok { + return target.SubmitPromptForUserChoice(ctx, id, p) + } + return agent.ErrUnknownAsk +} +func (t *preparationTurn) ReadWorkspaceFile(ctx context.Context, path string, limit int) (agent.WorkspaceReadResult, error) { + if target, ok := t.Session.(agent.WorkspaceReader); ok { + return target.ReadWorkspaceFile(ctx, path, limit) + } + return agent.WorkspaceReadResult{}, agent.ErrWorkspaceReadUnsupported +} diff --git a/apps/parsar-daemon/internal/dispatch/preparation_start.go b/apps/parsar-daemon/internal/dispatch/preparation_start.go index 0607d407f..bd1e64894 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_start.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_start.go @@ -14,17 +14,17 @@ import ( func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) error { var input proto.ExecutionStartPayload - if env.DecodePayload(&input) != nil || input.Handle == "" || strings.TrimSpace(input.RunID) == "" || input.Input.Validate() != nil { + if env.DecodePayload(&input) != nil || input.Handle == "" || input.ExecutorID == "" || strings.TrimSpace(input.RunID) == "" || input.Input.Validate() != nil { return r.rejectPreparation(env, "invalid_start") } encoded, _ := json.Marshal(input) fingerprint := sha256.Sum256(encoded) r.mu.Lock() - if r.closed { + if r.closed || r.suspension != nil { r.mu.Unlock() return ErrRouterClosed } - if r.workspaceExport != nil { + if r.workspaceWrite != nil || r.workspaceExport != nil { r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") } @@ -37,23 +37,25 @@ func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) err r.mu.Unlock() return r.rejectPreparation(env, "read_only_preparation") } + owner := p.executor + if owner == nil || owner.id != input.ExecutorID { + r.mu.Unlock() + return r.rejectPreparation(env, "unknown_executor") + } if p.status.State == "starting" || p.status.State == "started" { matches, status := p.startFingerprint == fingerprint, p.status - if matches { - if state := r.sessions[input.RunID]; state != nil && state.preparedHandoff != nil && - (state.session == nil || state.preparedHandoff.release != nil) { - r.mu.Unlock() - return nil - } - } + pending := p.handoff != nil && !p.handoff.published r.mu.Unlock() if !matches { return r.rejectPreparation(env, "start_conflict") } + if pending { + return nil + } r.publishPreparation(p, status) return nil } - if p.status.State != "ready" || p.ctx.Err() != nil { + if p.status.State != "ready" || p.ctx.Err() != nil || owner.admission != p || owner.run != nil || owner.invalid || owner.native == nil { r.mu.Unlock() return r.rejectPreparation(env, "preparation_not_ready") } @@ -66,24 +68,13 @@ func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) err r.mu.Unlock() return r.rejectPreparation(env, "run_conflict") } - target, ok := p.prepared.(agent.PreparedCancellation) - if !ok { - r.mu.Unlock() - return r.rejectPreparation(env, "preparation_not_ready") - } p.status.State, p.status.RunID, p.status.Revision = "starting", input.RunID, p.status.Revision+1 p.startFingerprint, p.busy = fingerprint, true - state := &sessionState{ - runID: input.RunID, stateKey: p.stateKey, environmentID: p.environmentID, - out: make(chan proto.Envelope, 64), ctx: p.ctx, ctxCancel: p.cancel, - pendingIDs: make(map[string]struct{}), pendingAsks: make(map[string]struct{}), - traceparent: env.Trace, releaseOnCompletion: true, - } - state.preparedHandoff = newPreparedHandoff(p, target) - p.handoff = state.preparedHandoff + state := &sessionState{runID: input.RunID, stateKey: p.stateKey, environmentID: p.environmentID, out: make(chan proto.Envelope, 64), ctx: p.ctx, ctxCancel: p.cancel, pendingIDs: make(map[string]struct{}), pendingAsks: make(map[string]struct{}), traceparent: env.Trace} + state.preparedHandoff = newPreparedHandoff(p, owner.native) + p.handoff, owner.run = state.preparedHandoff, state r.sessions[input.RunID] = state status := p.status - // Track the Start owner and output consumer before either can publish. r.shutdownWG.Add(2) r.mu.Unlock() go r.startPreparedExecution(p, state, input, status) @@ -95,100 +86,74 @@ func (r *Router) startPreparedExecution(p *preparationState, state *sessionState handoff := state.preparedHandoff go r.forwardPreparedOutput(state) <-handoff.outputReady - - if !r.sendPreparation(p.requestID, p.trace, starting) { - r.mu.Lock() - handoff.outputErr = errors.Join(handoff.outputErr, errPreparedStatusDelivery) - if p.status.State == "starting" { - p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "status_delivery_failed", p.status.Revision+1 - } - p.timer.Stop() - r.claimPreparedReleaseLocked(state, true, "", false) - r.mu.Unlock() - close(state.out) - close(handoff.startDone) - return - } - - // A release admitted before this point must not cause native work to start. + delivered := r.sendPreparation(p.requestID, p.trace, starting) r.mu.Lock() - blocked := handoff.release != nil && handoff.release.aborted() + blocked := !delivered || handoff.release != nil && handoff.release.aborted() || p.ctx.Err() != nil r.mu.Unlock() - var session agent.Session + var turn agent.Turn var startErr error if blocked { startErr = context.Canceled } else { - session, startErr = handoff.target.Start(p.ctx, input.RunID, input.Input, state.out) + turn, startErr = handoff.target.StartTurn(p.ctx, input.RunID, input.Input, state.out) } - r.mu.Lock() - release := handoff.release - notAborted := release == nil || !release.aborted() - started := startErr == nil && session != nil && p.status.State == "starting" && p.ctx.Err() == nil && notAborted - if started { + handoff.turn = turn + handoff.startErr = startErr + p.timer.Stop() + p.busy = false + if turn != nil { p.status.State, p.status.ErrorCode, p.status.Revision = "started", "", p.status.Revision+1 } else { - p.timer.Stop() - if p.status.State == "starting" { - p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "start_failed", p.status.Revision+1 - } - r.claimPreparedReleaseLocked(state, true, "prepared execution could not start", false) + p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "executor_unavailable", p.status.Revision+1 } status := p.status - // Keep release behind successful or failed status publication. This also - // orders an early native Done after the started status. - if started { - handoff.operations.RLock() - } r.mu.Unlock() - - // A nil Session leaves output ownership with the Router. A non-nil Session - // owns the close even when Start also returned an error. - if session == nil { + if turn == nil { close(state.out) - } - deadline := time.Now().Add(5 * time.Second) - if started && p.deadline.Before(deadline) { - deadline = p.deadline - } - delivered := r.sendPreparationUntil(p.requestID, p.trace, status, deadline) - - r.mu.Lock() - if started && !time.Now().Before(p.deadline) { - if p.status.State == "started" { - p.status.State, p.status.ErrorCode, p.status.Revision = "expired", "", p.status.Revision+1 + code := "executor_unavailable" + closeErr := r.closeExecutor(p.executor) + if closeErr != nil { + code = "executor_cleanup_unconfirmed" + } + if blocked { + code = "start_cancelled" + } + r.mu.Lock() + p.status.ErrorCode = code + release, _ := r.claimPreparedReleaseLocked(state, true, "", false) + close(handoff.startDone) + r.mu.Unlock() + if closeErr == nil { + <-release.settled + } + status = proto.PreparationStatusPayload{Handle: p.status.Handle, ExecutorID: p.executor.id, State: "rejected", ErrorCode: code, Operation: proto.TypeExecutionStart} + if delivered { + r.sendPreparation(p.requestID, p.trace, status) } - r.claimPreparedReleaseLocked(state, true, "", false) + return } - if started && handoff.release != nil && handoff.release.aborted() && p.status.State == "started" { - p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "start_failed", p.status.Revision+1 + r.mu.Lock() + aborted := handoff.release != nil && handoff.release.aborted() + r.mu.Unlock() + if aborted { + status.State, status.ErrorCode = "failed", "start_cancelled" } - if !delivered { + delivered = delivered && !aborted && r.sendPreparationUntil(p.requestID, p.trace, status, p.deadline) + r.mu.Lock() + if !delivered && !aborted { handoff.outputErr = errors.Join(handoff.outputErr, errPreparedStatusDelivery) - if started && p.status.State == "started" { - p.status.State, p.status.ErrorCode, p.status.Revision = "failed", "status_delivery_failed", p.status.Revision+1 - } - r.claimPreparedReleaseLocked(state, true, "", false) - } else if started && p.status.State == "started" && - (handoff.release == nil || !handoff.release.aborted()) { - handoff.published = true - p.timer.Stop() } + handoff.published = turn != nil && delivered if handoff.published { - // Publication transfers resource tracking even when natural completion - // has already closed input admission. - if handoff.release == nil { - state.session = session - } - p.prepared = nil p.owns = false - p.busy = false - p.handoff = nil + } + if handoff.published && handoff.release == nil { + state.session = turn + } + if turn == nil || startErr != nil || !delivered { + r.claimPreparedReleaseLocked(state, true, "prepared execution could not start", false) } close(handoff.startDone) r.mu.Unlock() - if started { - handoff.operations.RUnlock() - } } diff --git a/apps/parsar-daemon/internal/dispatch/preparation_test.go b/apps/parsar-daemon/internal/dispatch/preparation_test.go index 4dee25e40..16134d129 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_test.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_test.go @@ -3,7 +3,6 @@ package dispatch_test import ( "context" "errors" - "fmt" "os" "path/filepath" "sync" @@ -32,7 +31,9 @@ func (p *controlledPreparation) Close() error { if p.closeHook != nil { p.closeHook() } - close(p.closed) + if p.closed != nil { + close(p.closed) + } }) return nil } @@ -111,7 +112,7 @@ func localPreparationHarness(t *testing.T) *harness { } func preparationRequest() proto.ExecutionPreparePayload { - return proto.ExecutionPreparePayload{Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "agents-api-" + preparationSessionID, StrictResume: true, ReleaseOnCompletion: true, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled"}}} + return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "agents-api-" + preparationSessionID, StrictResume: true, ReleaseOnCompletion: true, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled"}}} } func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory agent.PreparationFactory) *dispatch.Router { @@ -121,6 +122,7 @@ func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Durati return nil, errors.New("ordinary Factory must not be used for preparation") }) reg.RegisterPreparation("prepared", true, factory) + reg.RegisterExecutor("prepared", preparationExecutorFixture(factory)) r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, PreparationTimeout: timeout, LocalWorkspace: preparationWorkspace(t)}) if err != nil { t.Fatal(err) @@ -225,7 +227,7 @@ func TestPreparationSingleTransferAndReleaseDoesNotCancelRun(t *testing.T) { if err := r.Handle(t.Context(), prepare); err != nil { t.Fatal(err) } - start := mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "real-run", Input: proto.TextInput("actual input")}) + start := mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "real-run", Input: proto.TextInput("actual input")}) if err := r.Handle(t.Context(), start); err != nil { t.Fatal(err) } @@ -276,7 +278,7 @@ func TestPreparationCancelDuringStartClosesLateSession(t *testing.T) { r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) ready := waitPreparationStatus(t, sender, "request", "ready", "") - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "real-run", Input: proto.TextInput("input")})) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "real-run", Input: proto.TextInput("input")})) <-entered if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "real-run", proto.PromptCancelPayload{DeliveryID: "cancel"})); err != nil { t.Fatal(err) @@ -307,37 +309,6 @@ func TestPreparationCancelDuringStartClosesLateSession(t *testing.T) { } } -func TestPreparationCapacityAndConnectionOwnership(t *testing.T) { - sender := &recSender{} - var count atomic.Int32 - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - count.Add(1) - return &controlledPreparation{closed: make(chan struct{})}, nil - }) - var handles []string - for i := 0; i < 4; i++ { - id := fmt.Sprint(i) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, id, preparationRequest())) - handles = append(handles, waitPreparationStatus(t, sender, id, "ready", "").Handle) - } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "overflow", preparationRequest())); err == nil { - t.Fatal("capacity unbounded") - } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "0", preparationRequest())); err != nil { - t.Fatal(err) - } - if count.Load() != 4 { - t.Fatal("retry recreated native resource") - } - other := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - t.Error("unexpected new native resource") - return nil, errors.New("unexpected") - }) - if err := other.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "0", proto.ExecutionStartPayload{Handle: handles[0], RunID: "run", Input: proto.TextInput("input")})); err == nil { - t.Fatal("another connection consumed handle") - } -} - func TestPreparationExpiryAndOldHandleCannotStartReplacement(t *testing.T) { sender := &recSender{} created := make(chan *controlledPreparation, 2) @@ -350,13 +321,18 @@ func TestPreparationExpiryAndOldHandleCannotStartReplacement(t *testing.T) { _ = r.Handle(t.Context(), env) old := waitPreparationStatus(t, sender, "request", "ready", "") waitPreparationStatus(t, sender, "request", "expired", "") - waitPreparationClosed(t, <-created) + owner := <-created + select { + case <-owner.closed: + t.Fatal("admission expiry closed a healthy executor") + default: + } _ = r.Handle(t.Context(), env) - next := waitPreparationStatus(t, sender, "request", "preparing", old.Handle) + next := waitPreparationStatus(t, sender, "request", "ready", old.Handle) if next.Handle == old.Handle || next.ExpiresAt <= old.ExpiresAt { t.Fatal("replacement reused expired identity") } - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: old.Handle, RunID: "late", Input: proto.TextInput("late")})); err == nil { + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: old.Handle, ExecutorID: old.ExecutorID, RunID: "late", Input: proto.TextInput("late")})); err == nil { t.Fatal("old handle started replacement") } } @@ -371,61 +347,25 @@ func (s failReadySender) Send(ctx context.Context, env proto.Envelope) error { return s.recSender.Send(ctx, env) } -func TestPreparationFailedReadyDeliveryClosesResource(t *testing.T) { +func TestPreparationFailedReadyDeliveryAbandonsAdmission(t *testing.T) { + created := make(chan struct{}) p := &controlledPreparation{closed: make(chan struct{})} - r := preparationRouter(t, failReadySender{&recSender{}}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + r := preparationRouter(t, failReadySender{&recSender{}}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + close(created) + return p, nil + }) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "request", preparationRequest())) + <-created + waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "abandoned admission") + if err := r.Shutdown(t.Context()); err != nil { + t.Fatal(err) + } waitPreparationClosed(t, p) if r.ActiveRuns() != 0 { t.Fatal("failed preparation became a run") } } -func TestPreparationCapacityIncludesClosingResources(t *testing.T) { - sender := &recSender{} - entered, unblock := make(chan struct{}), make(chan struct{}) - var releaseOnce sync.Once - release := func() { releaseOnce.Do(func() { close(unblock) }) } - defer release() - var count atomic.Int32 - r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { - p := &controlledPreparation{closed: make(chan struct{})} - if count.Add(1) == 1 { - p.closeHook = func() { close(entered); <-unblock } - } - return p, nil - }) - var first proto.PreparationStatusPayload - for i := 0; i < 4; i++ { - id := fmt.Sprint(i) - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, id, preparationRequest())) - ready := waitPreparationStatus(t, sender, id, "ready", "") - if i == 0 { - first = ready - } - } - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionRelease, "0", proto.ExecutionReleasePayload{Handle: first.Handle})) - select { - case <-entered: - case <-time.After(time.Second): - t.Fatal("cleanup did not start") - } - request := mustEnv(t, proto.TypeExecutionPrepare, "replacement", preparationRequest()) - if err := r.Handle(t.Context(), request); err == nil { - t.Fatal("closing resource returned capacity early") - } - release() - deadline := time.Now().Add(time.Second) - for time.Now().Before(deadline) { - if err := r.Handle(t.Context(), request); err == nil { - waitPreparationStatus(t, sender, "replacement", "ready", "") - return - } - time.Sleep(time.Millisecond) - } - t.Fatal("completed cleanup did not release capacity") -} - func TestPreparationRejectsInputAndProductConfiguration(t *testing.T) { for name, change := range map[string]func(*proto.PromptRequestPayload){ "run": func(p *proto.PromptRequestPayload) { p.RunID = "run" }, @@ -437,7 +377,6 @@ func TestPreparationRejectsInputAndProductConfiguration(t *testing.T) { }, "missing environment": func(p *proto.PromptRequestPayload) { p.LocalEnvironment = nil }, "resume": func(p *proto.PromptRequestPayload) { p.StrictResume = false }, - "release": func(p *proto.PromptRequestPayload) { p.ReleaseOnCompletion = false }, } { t.Run(name, func(t *testing.T) { r := preparationRouter(t, &recSender{}, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff.go index b5416df44..3df8b2934 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff.go +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff.go @@ -40,12 +40,13 @@ func (release *preparedRelease) aborted() bool { } } -// preparedHandoff owns the exact PreparedCancellation from Start admission -// through native cleanup and output settlement. It never falls back to the -// returned Session or Prepared.Close. +// preparedHandoff owns one Turn admission and settlement on a retained Executor. type preparedHandoff struct { preparation *preparationState - target agent.PreparedCancellation + target agent.Executor + turn agent.Turn + startErr error + protocolErr error startDone chan struct{} outputReady chan struct{} @@ -62,7 +63,7 @@ type preparedHandoff struct { shutdownInterrupted bool } -func newPreparedHandoff(p *preparationState, target agent.PreparedCancellation) *preparedHandoff { +func newPreparedHandoff(p *preparationState, target agent.Executor) *preparedHandoff { return &preparedHandoff{ preparation: p, target: target, @@ -116,6 +117,9 @@ func (r *Router) claimPreparedReleaseLocked(state *sessionState, abort bool, fai } if abort && !release.aborted() { close(release.abort) + if handoff.turn == nil { + handoff.preparation.cancel() + } } if release.succeeded { return release, release.attempt @@ -142,66 +146,134 @@ func (r *Router) claimPreparedReleaseLocked(state *sessionState, abort bool, fai func (r *Router) runPreparedRelease(state *sessionState, handoff *preparedHandoff, release *preparedRelease, attempt *preparedReleaseAttempt) { defer r.shutdownWG.Done() - - // Natural completion must publish started before it can release the native - // resource. Abort is allowed to fence a Start that is still in progress. select { case <-handoff.startDone: case <-release.abort: + select { + case <-handoff.startDone: + default: + if err := r.closeExecutor(handoff.preparation.executor); err != nil { + r.mu.Lock() + attempt.err = err + handoff.preparation.busy = false + handoff.preparation.closeErr = err + close(attempt.done) + r.mu.Unlock() + return + } + } + <-handoff.startDone } + // Admission is closed; all earlier mutations retain their native receipt wait. handoff.operations.Lock() - ctx, cancel := context.WithTimeout(context.Background(), preparedCancelTimeout) - nativeErr := handoff.target.Cancel(ctx) - cancel() + handoff.operations.Unlock() + turn := handoff.turn + var settlement agent.TurnSettlement + var nativeErr error var outcome *proto.DonePayload + if turn == nil { + outcome = &proto.DonePayload{} + } + if turn != nil { + ctx, cancel := context.WithTimeout(context.Background(), preparedCancelTimeout) + cancelDone := make(chan error, 1) + settled := make(chan struct{}) + go func() { + select { + case <-release.abort: + err := turn.Cancel(ctx) + if err != nil { + cancel() + } + cancelDone <- err + case <-settled: + cancelDone <- nil + } + }() + settlement, nativeErr = turn.AwaitSettlement(ctx) + close(settled) + nativeErr = errors.Join(nativeErr, <-cancelDone) + cancel() + if nativeErr == nil { + value := turn.CancellationOutcome() + outcome = &value + } + } if nativeErr == nil { - observed := handoff.target.CancellationOutcome() - outcome = &observed + <-handoff.outputDone + } + r.mu.Lock() + owner := handoff.preparation.executor + if turn != nil && handoff.terminal == nil && !release.aborted() { + handoff.protocolErr = errors.New("executor output ended without a terminal result") + } + if handoff.protocolErr != nil { + release.failure = handoff.protocolErr.Error() + } + invalidate := nativeErr != nil || !settlement.Reusable || handoff.startErr != nil || handoff.outputErr != nil || handoff.protocolErr != nil || r.closed || owner.invalid + if invalidate { + owner.invalid = true + } + r.mu.Unlock() + var closeErr error + if invalidate { + closeErr = r.closeExecutor(owner) + // A failed Turn settlement is not repaired into a cancellation receipt by Close. + nativeErr = errors.Join(nativeErr, closeErr) } - handoff.operations.Unlock() - r.mu.Lock() attempt.err = nativeErr - if nativeErr != nil { + if closeErr != nil { handoff.preparation.busy = false handoff.preparation.closeErr = nativeErr close(attempt.done) r.mu.Unlock() return } - release.succeeded = true - release.outcome = outcome + if nativeErr != nil { + release.failure = "executor Turn settlement failed" + } + release.succeeded, release.outcome = true, outcome close(attempt.done) r.mu.Unlock() - - // Cancel success promises local cleanup and no further output writes. Wait - // for Start and the one output consumer before publishing terminal state. - <-handoff.startDone <-handoff.outputDone - r.mu.Lock() - outputErr := handoff.outputErr - terminal := handoff.terminal - closed := r.closed + outputErr, terminal, closed := handoff.outputErr, handoff.terminal, r.closed r.mu.Unlock() - var terminalErr error - if outputErr == nil && !closed { - terminalErr = r.forwardPreparedTerminal(state, release.failure, terminal, release.failure != "" && terminal == nil) + if outputErr == nil && !closed && turn != nil { + terminalErr = r.forwardPreparedTerminal(state, release.failure, terminal, terminal == nil) } r.cleanupSession(state) - r.mu.Lock() handoff.outputErr = errors.Join(handoff.outputErr, terminalErr) p := handoff.preparation - p.busy = false + p.busy, p.owns, p.handoff = false, false, nil p.closeErr = terminalErr - p.prepared = nil - p.owns = false - p.handoff = nil p.cancel() + owner.run = nil + if owner.invalid && owner.closeDone != nil && owner.closeErr == nil && r.executors[owner.sessionID] == owner { + delete(r.executors, owner.sessionID) + } + if owner.admission == p { + owner.admission = nil + } + if outcome != nil { + if id, ok := outcome.Metadata[proto.DoneMetaAgentSessionID].(string); ok && id != "" { + owner.nativeID = id + } + } + if terminalErr != nil { + owner.invalid = true + } + if !owner.invalid { + r.scheduleExecutorIdleLocked(owner) + } close(release.settled) r.mu.Unlock() + if terminalErr != nil { + _ = r.closeExecutor(owner) + } } func (r *Router) forwardPreparedOutput(state *sessionState) { @@ -230,7 +302,9 @@ func (r *Router) forwardPreparedOutput(state *sessionState) { return } r.mu.Lock() - if handoff.terminal != nil { + if env.ID != state.runID || handoff.terminal != nil { + handoff.protocolErr = errors.New("executor output crossed the Turn boundary") + r.claimPreparedReleaseLocked(state, true, handoff.protocolErr.Error(), false) r.mu.Unlock() continue } diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go index d12c8246e..db420f8dc 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff_test.go @@ -208,7 +208,7 @@ func TestPreparedHandoffDuplicateStartDoesNotReexecuteDuringPublication(t *testi if session.functions.Load() != 0 || session.steers.Load() != 0 || session.reads.Load() != 0 || len(session.submissions()) != 0 || askCalls != 0 { t.Fatal("private Session accepted work before started publication") } - duplicate := mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "run", Input: proto.TextInput("input")}) + duplicate := mustEnv(t, proto.TypeExecutionStart, "request", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "run", Input: proto.TextInput("input")}) if err := r.Handle(t.Context(), duplicate); err != nil { t.Fatal(err) } @@ -315,7 +315,7 @@ func TestPreparedHandoffEarlyDoneDetachesPublishedPreparation(t *testing.T) { return session, nil } p.cancel = func(ctx context.Context) error { - if p.calls.Load() == 1 { + if p.calls.Load() <= 2 { return errors.New("cleanup incomplete") } return session.Cancel(ctx) @@ -327,7 +327,7 @@ func TestPreparedHandoffEarlyDoneDetachesPublishedPreparation(t *testing.T) { waitPreparationStatus(t, sender, "request", "started", "") waitFor(t, func() bool { _, busy := r.PreparationOwnershipForTest(ready.Handle) - return p.calls.Load() == 1 && !busy + return p.calls.Load() == 2 && !busy }, "failed natural cleanup") if owned, _ := r.PreparationOwnershipForTest(ready.Handle); owned { t.Fatal("published Run retained preparation capacity after early Done") @@ -345,14 +345,14 @@ func TestPreparedHandoffEarlyDoneDetachesPublishedPreparation(t *testing.T) { } return count == 2 }, "published preparation release response") - if p.calls.Load() != 1 { + if p.calls.Load() != 2 { t.Fatal("old preparation handle retried native cancellation") } if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "retry"})); err != nil { t.Fatal(err) } waitFor(t, func() bool { return len(cancellationAcks(sender)) == 1 && r.ActiveRuns() == 0 }, "Run-owned retry settlement") - if p.calls.Load() != 2 || session.cancels() != 1 || !cancellationAcks(sender)[0].Applied { + if p.calls.Load() != 3 || session.cancels() != 1 || !cancellationAcks(sender)[0].Applied { t.Fatal("explicit Run cancellation did not retry the retained native target") } } diff --git a/apps/parsar-daemon/internal/dispatch/router.go b/apps/parsar-daemon/internal/dispatch/router.go index 61c97f360..8bcf14c12 100644 --- a/apps/parsar-daemon/internal/dispatch/router.go +++ b/apps/parsar-daemon/internal/dispatch/router.go @@ -47,6 +47,7 @@ type Router struct { shutdownWG dispatchWork // waits for all pump goroutines idleTimeout time.Duration closed bool + executors map[string]*executorState preparations map[string]*preparationState preparationRequests map[string]*preparationState preparationTimeout time.Duration @@ -135,6 +136,7 @@ func New(cfg Config) (*Router, error) { applied: make(map[string]appliedInteractionDecision), shutdownCh: make(chan struct{}), idleTimeout: idleTimeout, + executors: make(map[string]*executorState), preparations: make(map[string]*preparationState), preparationRequests: make(map[string]*preparationState), preparationTimeout: preparationTimeout, diff --git a/apps/parsar-daemon/internal/dispatch/shutdown.go b/apps/parsar-daemon/internal/dispatch/shutdown.go index a59808ead..f5ea7693c 100644 --- a/apps/parsar-daemon/internal/dispatch/shutdown.go +++ b/apps/parsar-daemon/internal/dispatch/shutdown.go @@ -57,12 +57,14 @@ func (r *Router) Shutdown(ctx context.Context) error { close(r.shutdownCh) } preparations := r.closePendingPreparationsLocked() + executors := r.closeIdleExecutorsLocked() attempt := &shutdownAttempt{done: make(chan struct{})} r.shutdownAttempt = attempt // Keep the WaitGroup non-zero until all cancellation dispatch is complete. r.shutdownWG.Add(1) r.mu.Unlock() + r.closeIdleExecutors(executors) for _, p := range preparations { go func() { defer r.shutdownWG.Done(); r.closePreparationResource(p) }() } @@ -110,6 +112,9 @@ func (r *Router) runShutdownAttempt(attempt *shutdownAttempt, victims []sessionC attempt.err = errors.Join(attempt.err, fmt.Errorf("dispatch: preparation %s: %w", p.status.Handle, cause)) } } + for _, owner := range r.executors { + attempt.err = errors.Join(attempt.err, fmt.Errorf("dispatch: executor %s cleanup unconfirmed: %w", owner.id, owner.closeErr)) + } close(attempt.done) r.mu.Unlock() } @@ -155,7 +160,9 @@ func (r *Router) handleDeviceShutdown(ctx context.Context, env proto.Envelope) e } r.idle = make(map[string]map[*sessionState]struct{}) preparations := r.closePendingPreparationsLocked() + executors := r.closeIdleExecutorsLocked() r.mu.Unlock() + r.closeIdleExecutors(executors) for _, p := range preparations { go func() { defer r.shutdownWG.Done(); r.closePreparationResource(p) }() } diff --git a/apps/parsar-daemon/internal/dispatch/steering.go b/apps/parsar-daemon/internal/dispatch/steering.go index 00265cb91..c6636fa1b 100644 --- a/apps/parsar-daemon/internal/dispatch/steering.go +++ b/apps/parsar-daemon/internal/dispatch/steering.go @@ -98,8 +98,8 @@ func (r *Router) queueSteering(ctx context.Context, env proto.Envelope, input pr return &ack } if input.DurableReceipt { - if _, ok := session.(agent.DurableSteerer); !ok || !state.releaseOnCompletion { - ack.ErrorCode, ack.Error = "unsupported", "Durable input receipts require a supported release-on-completion run." + if _, ok := session.(agent.DurableSteerer); !ok || (!state.releaseOnCompletion && state.preparedHandoff == nil) { + ack.ErrorCode, ack.Error = "unsupported", "Durable input receipts require a supported Turn settlement contract." return &ack } } diff --git a/apps/parsar-daemon/internal/dispatch/suspend.go b/apps/parsar-daemon/internal/dispatch/suspend.go index 924fe237e..6058530f0 100644 --- a/apps/parsar-daemon/internal/dispatch/suspend.go +++ b/apps/parsar-daemon/internal/dispatch/suspend.go @@ -49,6 +49,12 @@ func (r *Router) Quiesce(ctx context.Context, request proto.EnvironmentSuspendPa } } } + for _, owner := range r.executors { + if owner.invalid || owner.preparing || owner.run != nil || owner.admission != nil || owner.environmentID != request.EnvironmentID { + r.mu.Unlock() + return ErrRouterBusy + } + } r.suspension = &request for _, p := range r.preparations { if p.timer != nil { @@ -63,6 +69,12 @@ func (r *Router) Quiesce(ctx context.Context, request proto.EnvironmentSuspendPa } } } + for _, owner := range r.executors { + owner.idleLease++ + if owner.timer != nil { + owner.timer.Stop() + } + } r.mu.Unlock() err := r.shutdownWG.waitContext(ctx) r.mu.Lock() @@ -89,6 +101,9 @@ func (r *Router) Resume(request proto.EnvironmentSuspendPayload, sender Sender) } r.sender = sender r.suspension = nil + for _, owner := range r.executors { + r.scheduleExecutorIdleLocked(owner) + } for _, states := range r.idle { for state := range states { r.scheduleIdleLocked(state) diff --git a/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go b/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go index 49835c073..28a14c02a 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go @@ -33,7 +33,7 @@ func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { t.Fatal(got) } if phase == "idle" { - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: ready.Handle, RunID: "run", Input: proto.TextInput("start")})) + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, RunID: "run", Input: proto.TextInput("start")})) waitPreparationStatus(t, sender, "prepare", "started", "") _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "stale", request)) if got := waitWorkspaceRead(t, sender, "stale"); got.Outcome != "rejected" { diff --git a/apps/parsar-daemon/internal/dispatch/workspace_read.go b/apps/parsar-daemon/internal/dispatch/workspace_read.go index 77aeb8b67..0932387d2 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_read.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_read.go @@ -68,6 +68,9 @@ func (r *Router) workspaceResourceLocked(request proto.WorkspaceReadPayload) (an return nil, "resource_unavailable" } resource = p.prepared + if p.executor != nil { + resource = p.executor.native + } } else { s := r.sessions[request.RunID] if s == nil || s.environmentID != request.EnvironmentID || s.session == nil || diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write.go b/apps/parsar-daemon/internal/dispatch/workspace_write.go index 751cdb807..adac13c47 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_write.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_write.go @@ -58,10 +58,16 @@ func (r *Router) handleWorkspaceWrite(ctx context.Context, env proto.Envelope) e } return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("write_capacity")) } - if !r.localWorkspace.AcceptsFileWrite(request.EnvironmentID, request.SessionID) || len(r.sessions) != 0 || len(r.idle) != 0 { + if !r.localWorkspace.AcceptsFileWrite(request.EnvironmentID, request.SessionID) || len(r.sessions) != 0 || len(r.idle) != 0 || len(r.workspaceReads) != 0 { r.mu.Unlock() return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("resource_unavailable")) } + for _, owner := range r.executors { + if owner.preparing || owner.admission != nil || owner.run != nil || owner.invalid { + r.mu.Unlock() + return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("resource_unavailable")) + } + } for _, p := range r.preparations { if p.owns { r.mu.Unlock() diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/parsar-daemon/internal/localworkspace/binding.go index 928db7fdb..825661c78 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding.go +++ b/apps/parsar-daemon/internal/localworkspace/binding.go @@ -90,7 +90,7 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa } if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.ID != b.environment || r.AgentStateKey != b.stateKey || r.DisableExecutionEnvironment || r.WorkDir != "" || - r.ConversationID != "" || r.WorkspaceAuthoring || !r.StrictResume || !r.ReleaseOnCompletion { + r.ConversationID != "" || r.WorkspaceAuthoring || !r.StrictResume { return r, errors.New("request does not match the dedicated local Environment") } if !r.WorkspaceReadOnly || r.LocalEnvironment.NetworkAccess != "" || len(r.LocalEnvironment.AllowedDomains) > 0 { diff --git a/apps/parsar-daemon/internal/localworkspace/binding_test.go b/apps/parsar-daemon/internal/localworkspace/binding_test.go index 98973300b..8a68555f9 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding_test.go +++ b/apps/parsar-daemon/internal/localworkspace/binding_test.go @@ -107,3 +107,11 @@ func TestDirectoryRejectsMalformedOrIncompleteResponses(t *testing.T) { } } } + +func TestBindingAllowsRetainedExecutor(t *testing.T) { + b, req := testBinding(t) + req.ReleaseOnCompletion = false + if _, err := b.Configure(req); err != nil { + t.Fatal(err) + } +} diff --git a/apps/parsar-daemon/testdata/onboarding/main.go b/apps/parsar-daemon/testdata/onboarding/main.go index 67a29f92b..679f98338 100644 --- a/apps/parsar-daemon/testdata/onboarding/main.go +++ b/apps/parsar-daemon/testdata/onboarding/main.go @@ -28,34 +28,81 @@ func (s *sender) Send(_ context.Context, e proto.Envelope) error { return s.encoder.Encode(e) } -type harness struct{ history map[string]string } +type harness struct { + mu sync.Mutex + history map[string]string +} -func (h *harness) start(_ context.Context, req proto.PromptRequestPayload, out chan<- proto.Envelope) (agent.Session, error) { - if _, err := req.Input.TextOnly(); err != nil { - return nil, err +func (h *harness) prepare(_ context.Context, req proto.PromptRequestPayload) (agent.Executor, error) { + if req.RunID != "" || len(req.Input) != 0 || req.ConversationID != "" { + return nil, errors.New("preparation submitted fixture input") } - if !req.StrictResume || !req.ReleaseOnCompletion || !req.DisableExecutionEnvironment || !req.DisableSubagents || len(req.FunctionTools) > 0 || req.MCPHTTPServers != nil { + if !req.StrictResume || !req.DisableExecutionEnvironment || !req.DisableSubagents || len(req.FunctionTools) > 0 || req.MCPHTTPServers != nil { return nil, errors.New("unsupported fixture operation") } + h.mu.Lock() + defer h.mu.Unlock() previous := h.history[req.AgentStateKey] - if req.AgentSessionID != previous || req.RequireExistingNativeSession { + if req.AgentSessionID != previous || (req.RequireExistingNativeSession && previous == "") { return nil, errors.New("native history mismatch") } - id := previous - if id == "" { - id = "fixture-" + req.AgentStateKey - h.history[req.AgentStateKey] = id + if previous == "" { + previous = "fixture-" + req.AgentStateKey + h.history[req.AgentStateKey] = previous + } + return &executor{native: previous}, nil +} + +type executor struct { + mu sync.Mutex + native string + active *session + closed bool +} + +func (e *executor) StartTurn(ctx context.Context, run string, input proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + if ctx.Err() != nil || run == "" || out == nil { + return nil, errors.New("invalid fixture Start") + } + if _, err := input.TextOnly(); err != nil { + return nil, err + } + e.mu.Lock() + defer e.mu.Unlock() + if e.closed || e.active != nil { + return nil, errors.New("fixture Executor unavailable") + } + s := &session{out: out, run: run, native: e.native, settled: make(chan struct{})} + s.release = func() { + e.mu.Lock() + if e.active == s { + e.active = nil + } + e.mu.Unlock() } - s := &session{out: out, run: req.RunID, native: id} + e.active = s s.emit(proto.TypeDelta, proto.DeltaPayload{Delta: "ready", Sequence: 1}) return s, nil } +func (e *executor) Close(ctx context.Context) error { + e.mu.Lock() + e.closed = true + current := e.active + e.mu.Unlock() + if current != nil { + return current.Cancel(ctx) + } + return nil +} + type session struct { mu sync.Mutex out chan<- proto.Envelope run, native string closed bool + settled chan struct{} + release func() } func (s *session) emit(kind string, payload any) { @@ -68,6 +115,8 @@ func (s *session) Cancel(context.Context) error { if !s.closed { s.closed = true close(s.out) + s.release() + close(s.settled) } return nil } @@ -92,15 +141,29 @@ func (s *session) SteerWithReceipt(_ context.Context, p proto.PromptSteerPayload s.emit(proto.TypeDone, proto.DonePayload{Content: "ready" + text, Metadata: map[string]any{proto.DoneMetaAgentSessionID: s.native}}) s.closed = true close(s.out) + s.release() + close(s.settled) return nil } +func (s *session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + select { + case <-s.settled: + return agent.TurnSettlement{Reusable: true}, nil + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} + func run() error { registry := agent.NewRegistry() h := &harness{history: map[string]string{}} registry.RegisterKind(proto.SupportedAgentKind{Kind: "fixture_harness", Available: true, Capabilities: proto.AgentKindCapabilities{ Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, ExecutionControls: true, ToolObservations: true, SubagentControl: true, EnvironmentNone: true, - }}, h.start) + }}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("fixture execution requires an Executor") + }) + registry.RegisterExecutor("fixture_harness", h.prepare) sink := &sender{encoder: json.NewEncoder(os.Stdout)} router, err := dispatch.New(dispatch.Config{Registry: registry, Sender: sink, Log: slog.New(slog.NewTextHandler(io.Discard, nil))}) if err != nil { diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index ce98e8e66..c9fdde33e 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -1,64 +1,139 @@ # Add a native harness to OpenAgentCore -This reference is for adapter developers using the shared contract on main after -PR #701 (2026-09-19). Start with a working native SDK or machine-readable protocol. -The goal is to register an engine without changing public handlers, storage or the -scheduler. Codex, Claude and additional harnesses have equal architectural status; -each engine qualifies its own supported operations. +Implement one adapter for a native SDK or machine-readable protocol. Core and +Runtime use the common contract; adding a harness should not add engine-name +branches to public handlers, storage, scheduling or environment providers. -The [harness contract](harnesses.md) is the semantic baseline. The +The [harness contract](harnesses.md) defines observable behavior. The [contributor guide](../../CONTRIBUTING.md#harness-qualification-and-onboarding) -owns architecture and delivery rules. The pinned external Agents API in -[upstream.json](upstream.json) remains separate from this private adapter contract. - -## Ownership and implementation locations - -| Component | Responsibility | Existing location | +owns architecture and required checks. The pinned public Agents API in +[upstream.json](upstream.json) is separate from the internal adapter protocol. + +## Ownership + +```text +Core: Session, Turn, immutable configuration, durable events + | + common Runtime protocol + | +Runtime: Executor preparation, reuse, idle expiry, recovery + | + Harness adapter package + | + native SDK, process or connection +``` + +| Component | Responsibility | Location | | --- | --- | --- | -| Core | Public protocol, authentication, resource ownership, durable state and scheduling | `services/agents-api` | -| Runtime | Authenticated connection, dispatch, input receipts and preparation ownership | `apps/parsar-daemon/internal/dispatch` | -| Adapter | Native configuration, process/SDK calls, event translation and native restrictions | `apps/parsar-daemon/internal/agent/` | -| Harness | Model/tool loop and native history | Pinned upstream SDK or executable | -| Service profile | Pure validation of qualified placements and option values | `services/agents-api/internal/engine` | -| Runtime registration | Factory and verified capabilities available in this installation | `apps/parsar-daemon/internal/cli` | - -A service profile authorizes supported combinations. A Runtime advertisement says -what that installation can execute. Neither replaces public schema validation, -tenant authorization or the other boundary. Native limits belong in adapters and -profiles, not engine-name branches in Core. - -## Implement the existing interfaces - -Use `internal/agentdaemon/proto` for requests, events, receipts and errors. Do not -introduce a parallel wire protocol or another model/tool loop. - -| Interface | When required | Observable obligation | +| Core | Public API, authority, durable state, scheduling and configuration snapshots | `services/agents-api` | +| Runtime | Authenticated connection and common Executor/Turn lifecycle | `apps/parsar-daemon/internal/dispatch` | +| Adapter | Native configuration, resources, API calls, event translation and restrictions | `apps/parsar-daemon/internal/agent/` | +| Harness | Native model/tool loop and history | Pinned SDK or executable | +| Service profile | Pure validation of qualified operations and placements | `services/agents-api/internal/engine` | +| Registration | Installed factories and verified capability declarations | `apps/parsar-daemon/internal/cli` | + +An Environment supplies execution resources. Managed Docker, E2B and user-managed +machines differ in provisioning and connection; their connected Runtime uses this +same contract. Operating-system support belongs in the implementation and its +qualification. The current implementation is Linux; a platform-neutral interface +alone does not qualify another platform. Resource management owns machine selection, allocation and Environment creation +and reclamation. Executor close does not release that allocation or delete the +workspace. Environment reclamation is an explicit resource-management action that +coordinates with execution. Runtime connection, installed capability snapshot, +Session Executor and Turn each have their own lifetime. Model providers supply +model communication configuration, not Turn scheduling or native process ownership. + +## Required adapter interfaces + +The canonical signatures and ownership comments are in +[`agent/executor.go`](../../apps/parsar-daemon/internal/agent/executor.go): + +```go +type ExecutorFactory func(context.Context, proto.PromptRequestPayload) (Executor, error) + +type Executor interface { + StartTurn(context.Context, string, proto.MessageInput, chan<- proto.Envelope) (Turn, error) + Close(context.Context) error +} + +type Turn interface { + Cancel(context.Context) error + CancellationOutcome() proto.DonePayload + AwaitSettlement(context.Context) (TurnSettlement, error) +} + +type TurnSettlement struct { + Reusable bool + Reason string +} +``` + +The factory prepares a fixed configuration without sending model input. Executor +owns the native process or connection, native Session, capability configuration and +cleanup resources. If preparation fails while cleanup remains unconfirmed, return +that non-nil Executor together with the error so Runtime retains its cleanup owner. +`StartTurn` creates a fresh Turn wrapper and event stream; +normal completion retains the Executor. Never reset a completed Turn object or +restart the process merely to implement the next Turn. + +A nil Turn from `StartTurn` guarantees no input was submitted and no output writer +was retained. Runtime closes the output channel in that case. Once input may have +been submitted, return a non-nil Turn even with an error: that Turn owns exactly +one output-channel close, and uncertain input must not be replayed. + +`Cancel` addresses that exact Turn. `AwaitSettlement` confirms that native events, +inputs, function results, interactions, child work and output writes have settled. +Only then may `Reusable` be true. False requires a reason and confirmed Executor +close before replacement. An error means settlement is unconfirmed. A caller +cancelling its wait cannot discard resource ownership or redirect a late callback +to the next Turn. Close must retain its exact cleanup target after failure so the +Runtime can retry serially. Successful resource close alone does not prove that a +cancelled input was applied or that its original outcome is known. + +The Runtime owns admission handles, idle deadlines and resource limits. Adapters +own native translation. For example, a Codex adapter retains its app-server and +thread, a Claude adapter retains one streaming Query, and a MiniMax adapter retains +its ACP connection and native Session. Core sees the same Executor and Turn +semantics in each case. + +## Events, inputs and optional capabilities + +Use [`internal/agentdaemon/proto`](../../internal/agentdaemon/proto) for neutral +requests, events and receipts. Each Turn emits only its own events with its Run ID, +in order, and one terminal outcome. Native IDs and usage must be observed rather +than invented. Capture the originating Turn before asynchronous native callbacks; +never attribute a late result to whichever Turn is currently active. + +Initial input and steering use ordered `proto.MessageInput`. Preserve user-message +and content order. Text-only adapters reject images through `TextOnly()` instead +of dropping them. A successful transport write is distinct from confirmed native +application. Resume only the exact history bound to the Session; missing or +ambiguous required history fails before new model input. + +| Interface or contract | When required | Obligation | | --- | --- | --- | -| `agent.Factory` and `agent.Session` | Every harness | Start a run; cancel idempotently; own output closure and native process lifetime | -| `agent.DurableSteerer` | Current public text execution contract | Distinguish a complete native write from confirmed application; return rejection/inactive/not-ready accurately | -| `agent.PreparationFactory` and `agent.Prepared` | Placements requiring preparation | Prepare without consuming model input; transfer ownership once in Start; retain failed cleanup ownership | -| `agent.PreparedCancellation` | Executable preparations | Cancel the same resource across Start; settle only after native effects and output writes stop | -| `agent.PermissionResponder`, `agent.UserChoiceResponder` | Only when emitting those interactions | Route exact interaction identities and preserve application receipts | -| Other optional interfaces | Only for declared operations | Implement the existing operation semantics and validate native support | - -Read the source comments in `registry.go`, `steering.go`, `preparation.go` and -`interactions.go` before implementing. Base `Session.Cancel` is a cancellation -signal; its return alone is not proof that all effects stopped. Use the existing -router's settlement path and adapter outcome interfaces. Do not give both the -adapter and router ownership of closing the same output channel. - -Preserve event order and native call identities. Emit one run terminal outcome; -never manufacture applied input, usage counters or successful cleanup. Resume only -the native history bound to the execution Session. Missing or ambiguous history -fails before new model input. A disconnected observer does not authorize replay. +| `agent.DurableSteerer` | Current public text execution | Distinguish write and application receipts; preserve retry identity | +| `agent.FunctionResultSubmitter` | Public function tools | Match call/result identity and acknowledge native application | +| `agent.PermissionResponder`, `agent.UserChoiceResponder` | When emitting these interactions | Route exact identities and settle receipts | +| `agent.WorkspaceReader`, `agent.WorkspaceDirectoryLister` | Qualified workspace operations | Use the fixed authorized workspace and retain accepted operations through close | +| Neutral message, image, MCP, structured-output and Subagent observations | Only when qualified and advertised | Preserve the operation-specific contract and reject unsupported combinations | + +Optional features need not match another harness. The service profile qualifies +public combinations; the Runtime advertises this installation's available support. +Neither replaces schema validation or tenant authorization. Declaring a capability +without implementing its semantics is an error. Workspace reads may use separate +read-only preparations; those do not start model work or provide another execution +lifecycle. ## Register a supported operation set 1. Pin the upstream source/package version and document the native entry point. 2. Implement the adapter using its SDK or native protocol. Reuse shared process, credential/configuration and local workspace helpers where applicable. -3. Register its `SupportedAgentKind` and factory with `RegisterKind`; register - preparation afterward with `RegisterPreparation` when supported. Runtime +3. Register its `SupportedAgentKind` and direct-call factory with `RegisterKind`, + then register the shared `ExecutorFactory` with `RegisterExecutor`. A direct-call + factory should delegate to the same Executor implementation. Register separate + read-only preparations only when required by qualified workspace operations. Runtime advertisements must describe behavior verified for that installation. 4. Add a profile to the existing static catalog, or supply an immutable catalog through service composition. Custom composition supplies the same @@ -90,7 +165,7 @@ confirmed native output through the existing Message contract. Register public qualification separately from the Runtime capability; see the [structured-output boundary](structured-output.md). No Core engine-name branch is required. -Initial requests, `Prepared.Start` and steering consume the same ordered +Initial requests, `Executor.StartTurn` and steering consume the same ordered `proto.MessageInput`. Text-only adapters use `TextOnly()` to reject images without discarding content. Image adapters translate each part natively and acknowledge an active batch only after all its messages are applied. Register @@ -109,14 +184,18 @@ Before implementation, record the operation set, expected results, exclusions an stopping conditions in the board. A batch ends when its declared operations pass; it does not expand to match another harness's feature list. -- Adapter tests: native failures, ordered events, input write/application receipts, - cancellation settlement, strict continuation and unknown-outcome handling. +- Adapter tests: two ordinary Turns share one native process/connection and history; + cancellation followed by another Turn; stale cancellation and late events; native + exit, cleanup failure, input write/application receipts and unknown outcomes. + Verify fresh per-Turn usage, function, input and child-observation state. - Shared integration: public admission, actual Worker/device selection, gateway, daemon registration/dispatch and durable terminal projection. See `TestThirdHarnessPublicOnboarding` for a synthetic example, not native evidence. - Real acceptance: pinned official Python SDK and raw HTTP against our Agents API, real provider API, native harness and independent execution database. Verify - initial execution, follow-up input, cancellation and restart/continuation. + initial execution, warm follow-up, cancellation and restart/continuation. + Record native owner identity and same-condition cold/warm timing; mock results + cannot establish native reuse or performance gains. For hosted qualification also verify Files/Artifacts, workspace identity, credential protection and foreign-history rejection. - Regression: existing qualified engines keep working. Run targeted tests during @@ -132,31 +211,24 @@ Feishu. Failed or synthetic runs cannot be counted as real acceptance. Merge the bounded PR after required checks/review, update its board child and reassess the full board. Acceptance of one engine is not complete public protocol compatibility. -## MiniMax Code application - -The upstream project is [MiniMax-AI/minimax-code](https://github.com/MiniMax-AI/minimax-code). -The repository already contains an ACP stdio adapter under `agent/mcode`, including -native session creation/loading, events and human interactions. Its existing -product integration is not Agents API qualification. The current service catalog -registers Codex, Claude Code and MiniMax Code. Text qualification in #702 and -workspace qualification in #703 are separate recorded milestones. - -The implementation reuses that adapter with an explicit native 0.4.12 opt-in for -`environment:none` text execution. It adds native active-input receipts and -cancellation settlement, a pure service profile and verified registration. No -public handler, store schema or scheduler engine branch is needed. Existing -product behavior and accepted Codex/Claude features remain protected. - -See [the MiniMax Code deployment guide](../../services/agents-api/deploy/mcode/README.md) -for setup, real-provider acceptance and limits. Hosted workspace, Files/Artifacts, -public functions and MCP are not qualified by the text profile. Native differences -remain separately tracked work; they do not require feature equality for onboarding. - -The requested MiniMax Code workspace Runtime is qualified separately in -[MCODE-WORKSPACE-V1-001](mcode-workspace-v1.md). Its explicit scope includes -workspace execution and shared Files/Artifacts, cancellation/recovery and independent -Docker deployment. Text-only qualification is an intermediate milestone for that -scope, not completion of the requested Runtime integration. +## Minimal runnable example and native references + +[`testdata/onboarding/main.go`](../../apps/parsar-daemon/testdata/onboarding/main.go) +is a runnable synthetic adapter. It demonstrates registration, a Session-owned +Executor, fresh Turns, durable steering, cancellation and history binding through +the actual gateway and Router. Run `TestThirdHarnessPublicOnboarding` against a +dedicated test database to exercise the complete integration path. The fixture is +not shipped and is not evidence for a real SDK or sandbox. + +Use the native adapters as implementation references after choosing a native API: + +- [Codex](../../apps/parsar-daemon/internal/agent/codex/executor.go): app-server transport. +- [Claude](../../apps/parsar-daemon/internal/agent/claudesdk/executor.go): Go ownership and a [TypeScript SDK bridge](../../packages/claude-sdk-adapter). +- [MiniMax](../../apps/parsar-daemon/internal/agent/mcode): ACP and native workspace companion. + +Start with the mandatory text lifecycle, then qualify optional operations using +[harness coverage](harnesses.md) and each operation's contract. Do not copy an +adapter's native limitations into the shared Core protocol. ## Optional Subagent observations diff --git a/contracts/agents-api/harnesses.md b/contracts/agents-api/harnesses.md index 0aaeda64a..5c8976f84 100644 --- a/contracts/agents-api/harnesses.md +++ b/contracts/agents-api/harnesses.md @@ -13,15 +13,15 @@ For implementation steps and interface obligations, see ## Integration surface -1. Implement the existing daemon `agent.Factory`/`Session` and, for prepared - environments, `PreparationFactory`/`Prepared` interfaces. `Session` requires - cancellation; adapters that emit permission or user-choice requests additionally - implement `PermissionResponder` or `UserChoiceResponder`. Other optional - interfaces, such as function results, follow their declared operations. Reuse - `internal/agentdaemon/proto` requests, neutral events, input receipts and errors. -2. Register the factory, preparation factory and verified Runtime capabilities in - the daemon registry. Keep native translation inside the adapter. Dedicated local - environments reuse shared Files/write/export helpers and binding checks. +1. Implement `agent.ExecutorFactory`, `Executor` and `Turn`. Prepare fixed native + resources once, start fresh Turns on that owner, cancel an exact Turn, confirm + settlement and close the Executor. Implement optional interfaces only for + declared operations. Reuse `internal/agentdaemon/proto` requests, neutral events, + input receipts and errors. +2. Register the Executor factory and verified Runtime capabilities in the daemon + registry. Keep native translation in the adapter. Managed and user-managed + environments use the same execution lifecycle after connection; dedicated local + environments retain shared Files/write/export helpers and binding checks. 3. Add a pure qualified profile to `services/agents-api/internal/engine` and its static catalog (or supply an immutable catalog at service composition). Declare supported placements, public configuration/result limits @@ -49,9 +49,10 @@ not a different contract or authority level. The documented extension remains se ## Shared behavioral obligations -- Preparation holds resources without consuming input; start transfers ownership - once. Unused preparation releases through `Close`; cancellation remains valid - across the transfer and reports settlement only after native effects stop. +- Preparation holds resources without consuming input. Normal Turn completion + retains a settled healthy Executor. Cancellation targets one Turn; idle expiry, + environment shutdown and invalidation close the Executor. Failed cleanup retains + ownership and capacity, and never authorizes replay of uncertain input. - Confirm accepted/applied inputs separately. Preserve ordered public Items and events, stable call identity and one terminal outcome. Never replay uncertain work merely because a connection closed. diff --git a/contracts/agents-api/message-input.md b/contracts/agents-api/message-input.md index 165584d93..cfe784f89 100644 --- a/contracts/agents-api/message-input.md +++ b/contracts/agents-api/message-input.md @@ -74,7 +74,7 @@ before. ## Runtime boundary -Private wire 0.5.0 uses `MessageInput` for initial requests, prepared start and +Private wire 0.6.0 uses `MessageInput` for initial requests, prepared start and active steering. Each message contains ordered `InputContent` parts, also reused by function-result content. Core does not download, transcode or repair media. The separate Claude bridge reports protocol 2; daemon readiness rejects protocol 1. diff --git a/internal/agentdaemon/proto/envelope_test.go b/internal/agentdaemon/proto/envelope_test.go index 8aace4d66..5c3cd96f6 100644 --- a/internal/agentdaemon/proto/envelope_test.go +++ b/internal/agentdaemon/proto/envelope_test.go @@ -88,8 +88,8 @@ func TestVersionCompatible(t *testing.T) { ok bool }{ {Version, true}, // exact match - {"0.5.99", true}, // patch drift OK - {"0.4.99", false}, // minor drift NOT OK + {"0.6.99", true}, // patch drift OK + {"0.5.99", false}, // minor drift NOT OK {"1.0.0", false}, // major drift NOT OK {"", false}, // missing {"garbage", false}, // unparseable diff --git a/internal/agentdaemon/proto/preparation.go b/internal/agentdaemon/proto/preparation.go index f26c9eb7e..b03ba0905 100644 --- a/internal/agentdaemon/proto/preparation.go +++ b/internal/agentdaemon/proto/preparation.go @@ -10,16 +10,18 @@ const ( ) // ExecutionPreparePayload reuses execution configuration without accepting input -// or product authoring. The initial private profile requires a bound local environment, -// stable state key, strict resume and completion release. +// or product authoring. SessionID identifies the immutable configuration owner; +// each request reserves a separate Turn admission on its Runtime Executor. type ExecutionPreparePayload struct { + SessionID string `json:"session_id"` Configuration PromptRequestPayload `json:"configuration"` } type ExecutionStartPayload struct { - Handle string `json:"handle"` - RunID string `json:"run_id"` - Input MessageInput `json:"input"` + ExecutorID string `json:"executor_id"` + Handle string `json:"handle"` + RunID string `json:"run_id"` + Input MessageInput `json:"input"` } type ExecutionReleasePayload struct { @@ -31,6 +33,10 @@ type ExecutionReleasePayload struct { // order. ExpiresAt is Unix milliseconds. Repeated requests do not extend it. Retired request IDs // may allocate a fresh handle, but an old handle can never start its replacement. type PreparationStatusPayload struct { + // ExecutorID identifies the native resource owner, independently of this admission handle. + ExecutorID string `json:"executor_id,omitempty"` + // Reused is true when readiness reused an existing settled Executor. + Reused bool `json:"reused,omitempty"` Handle string `json:"handle,omitempty"` Revision uint64 `json:"revision"` State string `json:"state"` diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go index a030854b8..134ead26e 100644 --- a/internal/agentdaemon/proto/version.go +++ b/internal/agentdaemon/proto/version.go @@ -5,7 +5,7 @@ package proto // upgrade query (`version=`) and in the bootstrap HTTP // response; mismatches fail closed at WS upgrade. const ( - Version = "0.5.0" + Version = "0.6.0" ) // VersionCompatible returns true when clientVersion's "X.Y" prefix diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index e4b86b070..3ab8b88d7 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -1,3 +1,4 @@ +import { ExecutorTurns, type ExecutorEvent } from "./executor_protocol.js"; import { StructuredOutput } from "./structured_output.js"; import { toolSearchEnvironment } from "./tool_search.js"; import { Subagents } from "./subagents.js"; @@ -6,7 +7,7 @@ import { WorkspaceDirectories, type WorkspaceDirectoryEvent } from "./workspace_ import { getSessionInfo, query, startup, type McpServerConfig, type Options, type WarmQuery } from "@anthropic-ai/claude-agent-sdk"; import { WorkspaceReads, type WorkspaceReadEvent } from "./workspace_reads.js"; import { Inputs, type InputEvent } from "./inputs.js"; -import { resultUsage, type NativeUsage } from "./usage.js"; +import { executorResultUsage, resultUsage, type NativeUsage } from "./usage.js"; import { spawnNative } from "./native.js"; import { MessageObserver, type MessageEvent } from "./messages.js"; import { createFunctionServer } from "./functions.js"; @@ -15,11 +16,12 @@ import { MCPProfile } from "./mcp.js"; import { MCPObserver, type MCPEvent } from "./mcp_observer.js"; import { CommandObserver, type CommandEvent } from "./command_observer.js"; import { WorkspaceProfile } from "./workspace.js"; -import { immediateInput, type Prepare, type Start } from "./request.js"; +import { immediateInput, type Prepare, type Start, type ExecutorPrepare } from "./request.js"; import { recoverSession } from "./recovery.js"; export { parseStart, type Start } from "./request.js"; export type Event = + | ExecutorEvent | Fact | WorkspaceDirectoryEvent | WorkspaceReadEvent @@ -34,7 +36,9 @@ export type Event = | { type: "result"; session_id: string; text: string } | { type: "error"; code: "invalid_request" | "history_unavailable" | "execution_failed" | "cancelled" }; -export async function execute(request: Start | Prepare, emit: (event: Event) => Promise, abort: AbortController, functions = new FunctionBridge(emit), inputs = new Inputs(immediateInput(request)), reads = new WorkspaceReads(emit, abort), directories = new WorkspaceDirectories(emit, abort)): Promise { +export async function execute(request: Start | Prepare | ExecutorPrepare, emit: (event: Event) => Promise, abort: AbortController, functions = new FunctionBridge(emit), inputs = new Inputs(immediateInput(request)), reads = new WorkspaceReads(emit, abort), directories = new WorkspaceDirectories(emit, abort), turns?: ExecutorTurns): Promise { + const ownerEmit=emit; + if(turns) emit=event=>turns.emit(event); const definitions = (request.functions ?? []).map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.parameters, deferLoading: tool.defer_loading })); const names = definitions.map(tool => `mcp__functions__${tool.name}`); const allowed = [...names, ...(request.tool_search ? ["ToolSearch"] : [])]; @@ -42,7 +46,7 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => const profile = declarations === undefined ? undefined : new MCPProfile(declarations, names); const subagents = request.subagents ? new Subagents(request.cwd, request.subagents.max_concurrent, request.resume) : undefined; const workspace = request.workspace === undefined ? undefined : new WorkspaceProfile(request.cwd, request.workspace, names, profile, subagents, !!request.output_format); - const commands = workspace ? new CommandObserver() : undefined; + let commands = workspace ? new CommandObserver() : undefined; if (request.type === "prepare" && !workspace) throw new Error("invalid_request"); if (workspace && "mcp_http_servers" in request) throw new Error("invalid_request"); if (request.require_history && !request.resume) { @@ -59,8 +63,8 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => } subagents?.expectSession(request.resume); const mcpServers: Record = Object.create(null); - if (definitions.length) mcpServers.functions = createFunctionServer(definitions, functions.invoke); - const mcp = profile ? new MCPObserver(profile.identities) : undefined; + if (definitions.length) mcpServers.functions = createFunctionServer(definitions, (call,signal) => { const target=functions; return turns ? turns.track(()=>target.invoke(call,signal)) : target.invoke(call,signal); }); + let mcp = profile ? new MCPObserver(profile.identities) : undefined; if (profile) Object.assign(mcpServers, profile.servers); const children: Promise[] = []; let result: Extract | undefined; @@ -68,12 +72,12 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => const resultIDs = new Set(); let failed = false; let cancellationFactsFailed = false; - const structured = request.output_format ? new StructuredOutput() : undefined; - const messages = request.observe_messages ? new MessageObserver() : undefined; + let structured = request.output_format ? new StructuredOutput() : undefined; + let messages = request.observe_messages ? new MessageObserver() : undefined; let stream: ReturnType | undefined; let warm: WarmQuery | undefined; let nativeAlive = false; - const closeInputs = () => inputs.close(); + const closeInputs = () => { inputs.close(); turns?.close(); }; abort.signal.addEventListener("abort", closeInputs, { once: true }); try { if (abort.signal.aborted) throw new Error("cancelled"); @@ -111,20 +115,46 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => return child; }, }; - if (request.type === "prepare") { + if(turns) { + for(const matchers of Object.values(options.hooks ?? {})) for(const matcher of matchers ?? []) { + matcher.hooks=matcher.hooks.map(hook=>(...args)=>turns.track(()=>hook(...args))); + } + const canUseTool=options.canUseTool; + if(canUseTool) options.canUseTool=(...args)=>turns.track(()=>canUseTool(...args)); + } + if (request.type === "prepare" || request.type === "executor_prepare") { warm = await startup({ options, initializeTimeoutMs: 15000 }); - stream = warm.query(inputs); + stream = warm.query(turns ?? inputs); const initialized = await stream.initializationResult(); - if (initialized.hooks_applied !== true || children.length !== 1 || !nativeAlive || abort.signal.aborted) { + const hooksRequested = Object.values(options.hooks ?? {}).some(matchers => matchers?.some(matcher => matcher.hooks.length > 0)); + if ((hooksRequested && initialized.hooks_applied !== true) || children.length !== 1 || !nativeAlive || abort.signal.aborted) { throw new Error("preparation unavailable"); } - reads.bind(stream, request.cwd); - if (process.platform === "linux") await directories.bind(request.cwd); - await emit({ type: "prepared" }); + if(workspace) { + reads.bind(stream, request.cwd); + if (process.platform === "linux") await directories.bind(request.cwd); + } + if (request.mcp_http_servers?.some(server=>server.required)) profile?.verifyRequired(await stream.mcpServerStatus()); + if(turns) { + turns.configure(stream,(input,output)=>{ + inputs=new Inputs(input); + functions=new FunctionBridge(output); + messages=request.observe_messages ? new MessageObserver() : undefined; + structured=request.output_format ? new StructuredOutput() : undefined; + commands=workspace ? new CommandObserver() : undefined; + mcp=profile ? new MCPObserver(profile.identities) : undefined; + subagents?.beginTurn(); + return inputs; + },async value=>{ + if(value.type==="steer") for(const event of inputs.submit(value)) await emit(event); + else functions.submit(JSON.stringify(value)); + }); + await ownerEmit({type:"executor_ready",protocol:3}); + } else await emit({ type: "prepared" }); } else if (profile) { if (inputs.hasInput) throw new Error("MCP input released before initialization"); warm = await startup({ options, initializeTimeoutMs: 15000 }); - stream = warm.query(inputs); + stream = warm.query(turns ?? inputs); const initialized = await stream.initializationResult(); if (initialized.hooks_applied !== true || children.length !== 1) throw new Error("MCP initialization unavailable"); if (request.mcp_http_servers?.some(server => server.required)) profile.verifyRequired(await stream.mcpServerStatus()); @@ -132,6 +162,10 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => inputs.release(request.input); } else stream = query({ prompt: inputs, options }); for await (const message of stream) { + // Native command queue telemetry can follow a result. It is not model + // output or a consumed-input receipt, and has no authority over a Turn. + if (isCommandLifecycle(message)) continue; + if(turns && !turns.id && !(message.type==="system" && message.subtype==="init")) throw new Error("unbound native event"); subagents?.consume(message); structured?.consume(message, nativeID); await functions.consume(message, nativeID); @@ -148,22 +182,33 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => message.mcp_servers.some(server => server.name !== "functions" || server.status !== "connected")) { throw new Error("unexpected native configuration"); } - for (const event of inputs.start(nativeID)) await emit(event); + turns?.identify(nativeID); + if(!turns || turns.id) for (const event of inputs.start(nativeID)) await emit(event); } else if (!messages && message.type === "stream_event" && message.parent_tool_use_id === null && message.event.type === "content_block_delta" && message.event.delta.type === "text_delta") { await emit({ type: "delta", delta: message.event.delta.text }); } else if (message.type === "result") { if (!message.uuid || resultIDs.has(message.uuid) || !nativeID || message.session_id !== nativeID) throw new Error("invalid native result identity"); resultIDs.add(message.uuid); - await emit({ type: "usage", session_id: nativeID, result_id: message.uuid, usage: resultUsage(message) }); + await emit({ type: "usage", session_id: nativeID, result_id: message.uuid, usage: turns ? executorResultUsage(message) : resultUsage(message) }); for (const event of inputs.consume(message)) await emit(event); - if (message.subtype !== "success" || message.is_error) throw new Error("unsuccessful native result"); - if (structured) await emit(structured.complete(message)); - result = { type: "result", session_id: nativeID, text: message.result }; + if ((message.subtype !== "success" || message.is_error) && !turns?.cancelled) throw new Error("unsuccessful native result"); + if (structured && !turns?.cancelled) await emit(structured.complete(message)); + result = { type: "result", session_id: nativeID, text: message.subtype === "success" ? message.result : "" }; + if(turns?.cancelled && !inputs.complete) throw new Error("unconfirmed cancelled inputs"); + } + if (message.type !== "result" && (!turns || turns.id)) for (const event of inputs.consume(message)) await emit(event); + if(turns && result && inputs.complete && (!subagents || subagents.complete)) { + if(!await turns.quiescent() || abort.signal.aborted) throw new Error("unconfirmed interrupt"); + functions.assertComplete(); mcp?.assertComplete(); commands?.assertComplete(); + if(subagents) for(const event of await subagents.facts()) await emit(event); + await emit(turns.cancelled ? {type:"error",code:"cancelled"} : result); + functions.close(); + await turns.settled(true,""); + result=undefined; } - if (message.type !== "result") for (const event of inputs.consume(message)) await emit(event); } - if (subagents) for (const event of await subagents.facts()) await emit(event); + if (subagents && (!turns || turns.id)) for (const event of await subagents.facts()) await emit(event); functions.assertComplete(); mcp?.assertComplete(); commands?.assertComplete(); @@ -172,6 +217,7 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => } finally { profile?.close(); inputs.close(); + turns?.close(); functions.close(); try { await directories.close(); } catch { failed = true; } await reads.close(); @@ -179,16 +225,29 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => warm?.close(); abort.signal.removeEventListener("abort", closeInputs); const exits = await Promise.all(children); + await turns?.drain(); if (!exits.length || exits.some(code => code !== 0)) failed = true; - if (subagents && abort.signal.aborted && exits.length === 1) { + if (subagents && (!turns || turns.id) && abort.signal.aborted && exits.length === 1) { try { for (const event of await subagents.facts(Date.now())) await emit(event); } catch { cancellationFactsFailed = true; } } if (mcp) for (const event of mcp.close()) await emit(event); if (commands) for (const event of commands.close()) await emit(event); } + if(turns) { + if(turns.id) { + await emit({type:"error",code: turns.cancelled && !cancellationFactsFailed ? "cancelled" : "execution_failed"}); + await turns.settled(false, turns.cancelled ? "cancellation_unconfirmed" : "native_execution_unavailable"); + } else if(failed && !abort.signal.aborted) await ownerEmit({type:"error",code:"execution_failed"}); + return; + } if (cancellationFactsFailed) await emit({ type: "error", code: "execution_failed" }); else if (abort.signal.aborted) await emit({ type: "error", code: "cancelled" }); else if (failed || !result || !inputs.complete) await emit({ type: "error", code: "execution_failed" }); else await emit(result); } + +function isCommandLifecycle(message: unknown): boolean { + return typeof message === "object" && message !== null && + "type" in message && message.type === "command_lifecycle"; +} diff --git a/packages/claude-sdk-adapter/src/executor_protocol.ts b/packages/claude-sdk-adapter/src/executor_protocol.ts new file mode 100644 index 000000000..ba4e4c162 --- /dev/null +++ b/packages/claude-sdk-adapter/src/executor_protocol.ts @@ -0,0 +1,129 @@ +import type { Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import { Inputs } from "./inputs.js"; +import { parseMessageInput, type MessageInput } from "./message_input.js"; + +export type ExecutorEvent = + | { type: "executor_ready"; protocol: 3 } + | { type: "turn_started"; turn_id: string } + | { type: "turn_settled"; turn_id: string; reusable: boolean; reason: string }; +type WireEvent = { type: string; [key: string]: unknown }; +type Turn = { id: string; inputs: Inputs; cancelled: boolean; interrupt?: Promise; callbacks: Set> }; + +// The SDK sees one iterator for the Executor lifetime. Each yielded batch belongs +// to a fresh receipt ledger; finishing a Turn never closes this outer iterator. +export class ExecutorTurns implements AsyncIterable { + private active?: Turn; + private readonly used = new Set(); + private readonly queue: Inputs[] = []; + private wake?: () => void; + private ended = false; + private stream?: Query; + private begin?: (input: MessageInput, emit: (event: WireEvent) => Promise) => Inputs; + private submitInput?: (value: Record) => Promise; + private nativeID = ""; + constructor(private readonly output: (event: WireEvent) => Promise, private readonly abort: AbortController) {} + + configure(stream: Query, begin: NonNullable, submit: (value: Record)=>Promise): void { this.stream = stream; this.begin = begin; this.submitInput=submit; } + async submit(value: Record): Promise { + const payload=this.assertCurrent(value); + if(!this.submitInput || (payload.type!=="steer" && payload.type!=="function_result")) throw new Error("invalid turn input"); + await this.track(()=>this.submitInput!(payload)); + } + identify(id: string): void { + if (!id || this.nativeID && this.nativeID !== id) throw new Error("invalid native identity"); + this.nativeID = id; + } + get id(): string | undefined { return this.active?.id; } + get cancelled(): boolean { return this.active?.cancelled ?? false; } + get inputs(): Inputs { if (!this.active) throw new Error("no active turn"); return this.active.inputs; } + + async start(value: Record): Promise { + const id = value.turn_id; + if (this.ended || this.active || !this.stream || !this.begin || typeof id !== "string" || !id.trim() || id.length > 256 || + this.used.has(id) || Object.keys(value).some(key => !["type", "turn_id", "input"].includes(key))) throw new Error("invalid turn start"); + const input = parseMessageInput(value.input); + const output = async (event: WireEvent) => { + if (this.active?.id !== id) throw new Error("late turn event"); + await this.output({ ...event, turn_id: id }); + }; + const inputs = this.begin(input, output); + this.active = { id, inputs, cancelled: false, callbacks: new Set() }; + this.used.add(id); + await this.output({ type: "turn_started", turn_id: id }); + if (this.nativeID) for (const event of inputs.start(this.nativeID)) await output(event); + this.queue.push(inputs); + this.wake?.(); + } + + async cancel(value: Record): Promise { + if (typeof value.turn_id !== "string" || Object.keys(value).some(key => !["type", "turn_id"].includes(key))) throw new Error("invalid cancellation"); + const turn = this.active; + // A retired identity has no authority over its successor. + if (!turn || value.turn_id !== turn.id) { + if (this.used.has(value.turn_id)) return; + throw new Error("unknown turn"); + } + if (turn.interrupt) return; + turn.cancelled = true; + const discarded=turn.inputs.cancelQueued(); + turn.interrupt = this.stream!.interrupt().then(receipt => { + const confirmed = !discarded && receipt !== undefined && Array.isArray(receipt.still_queued) && receipt.still_queued.length === 0; + if (!confirmed) this.abort.abort(); + return confirmed; + }, () => { this.abort.abort(); return false; }); + await turn.interrupt; + } + + assertCurrent(value: Record): Record { + if (!this.active || this.active.cancelled || value.turn_id !== this.active.id) throw new Error("inactive turn input"); + const { turn_id: _id, ...payload } = value; + return payload; + } + async emit(event: WireEvent): Promise { + if (!this.active) throw new Error("unbound native event"); + await this.output({ ...event, turn_id: this.active.id }); + } + track(operation: () => Promise): Promise { + const turn = this.active; + if (!turn) return Promise.reject(new Error("unbound native callback")); + const promise = Promise.resolve().then(operation); + turn.callbacks.add(promise); + void promise.then(() => turn.callbacks.delete(promise), () => turn.callbacks.delete(promise)); + return promise; + } + async quiescent(): Promise { + const turn = this.active; + if (!turn) return false; + if (this.abort.signal.aborted) return false; + let stop: () => void = () => {}; + const aborted = new Promise(resolve => { + stop = () => resolve(false); + this.abort.signal.addEventListener("abort", stop, { once: true }); + }); + try { + return await Promise.race([ + this.drain().then(async () => !turn.cancelled || await turn.interrupt === true), + aborted, + ]); + } finally { this.abort.signal.removeEventListener("abort", stop); } + } + async drain(): Promise { + const turn = this.active; + while (turn?.callbacks.size) await Promise.allSettled([...turn.callbacks]); + } + async settled(reusable: boolean, reason: string): Promise { + const turn = this.active; + if (!turn) return; + turn.inputs.close(); + this.active = undefined; + await this.output({ type: "turn_settled", turn_id: turn.id, reusable, reason }); + } + close(): void { this.ended = true; this.active?.inputs.close(); this.wake?.(); } + async *[Symbol.asyncIterator](): AsyncIterator { + while (!this.ended) { + const inputs = this.queue.shift(); + if (inputs) yield* inputs; + else await new Promise(resolve => { this.wake = resolve; }); + } + } +} diff --git a/packages/claude-sdk-adapter/src/inputs.ts b/packages/claude-sdk-adapter/src/inputs.ts index 67b37d768..c11ed7867 100644 --- a/packages/claude-sdk-adapter/src/inputs.ts +++ b/packages/claude-sdk-adapter/src/inputs.ts @@ -96,6 +96,11 @@ export class Inputs implements AsyncIterable { get hasInput(): boolean { return this.submitted.size > 0; } + cancelQueued(): boolean { + const discarded = this.queue.length > 0; + this.queue.length = 0; this.close(); return discarded; + } + close(): void { this.ended = true; this.wake?.(); } async *[Symbol.asyncIterator](): AsyncIterator { diff --git a/packages/claude-sdk-adapter/src/main.ts b/packages/claude-sdk-adapter/src/main.ts index 496c9ab0a..86b12aa09 100644 --- a/packages/claude-sdk-adapter/src/main.ts +++ b/packages/claude-sdk-adapter/src/main.ts @@ -1,3 +1,4 @@ +import { ExecutorTurns } from "./executor_protocol.js"; import { WorkspaceDirectories } from "./workspace_directories.js"; import { WorkspaceReads } from "./workspace_reads.js"; import { Inputs } from "./inputs.js"; @@ -12,7 +13,7 @@ const stop = () => abort.abort(); process.once("SIGTERM", stop); process.once("SIGINT", stop); lines.once("close", stop); -const emit = (event: Event): Promise => new Promise((resolve, reject) => { +const emit = (event: Event | {type:string;[key:string]:unknown}): Promise => new Promise((resolve, reject) => { process.stdout.write(JSON.stringify(event) + "\n", error => error ? reject(error) : resolve()); }); try { @@ -30,6 +31,7 @@ try { const reads = new WorkspaceReads(output, abort); const directories = new WorkspaceDirectories(output, abort); const prompts = new Inputs(immediateInput(request)); + const turns=request.type==="executor_prepare" ? new ExecutorTurns(emit,abort) : undefined; const incoming = (async () => { try { for await (const line of { [Symbol.asyncIterator]: () => input }) { @@ -39,6 +41,12 @@ try { reads.submit(value as Record); } else if (value && typeof value === "object" && "type" in value && value.type === "workspace_directory") { directories.submit(value as Record); + } else if(turns) { + if(!value || typeof value!=="object" || Array.isArray(value)) throw new Error("invalid_request"); + const control=value as Record; + if(control.type==="turn_start") await turns.start(control); + else if(control.type==="turn_cancel") await turns.cancel(control); + else await turns.submit(control); } else if (request.type === "prepare" && phase !== "running") { if (phase !== "prepared" || abort.signal.aborted) throw new Error("invalid_request"); prompts.release(preparedInput(value)); @@ -50,7 +58,7 @@ try { } catch { invalid = request.type === "prepare"; abort.abort(); } })(); - try { await execute(request, output, abort, functions, prompts, reads, directories); } + try { await execute(request, output, abort, functions, prompts, reads, directories, turns); } catch { await output({ type: "error", code: "execution_failed" }); } finally { prompts.close(); diff --git a/packages/claude-sdk-adapter/src/request.ts b/packages/claude-sdk-adapter/src/request.ts index dbf353008..ca9636ca0 100644 --- a/packages/claude-sdk-adapter/src/request.ts +++ b/packages/claude-sdk-adapter/src/request.ts @@ -20,20 +20,21 @@ export type Start = { mcp_http_servers?: HTTPServer[]; workspace?: Workspace; }; +export type ExecutorPrepare = Omit & { type: "executor_prepare" }; export type Prepare = Omit & { type: "prepare"; workspace: Workspace }; // MCP startup confirms its hooks before the native input iterator yields. -export function immediateInput(request: Start | Prepare): MessageInput | undefined { +export function immediateInput(request: Start | Prepare | ExecutorPrepare): MessageInput | undefined { return request.type === "start" && request.mcp_http_servers === undefined && !request.workspace?.mcp?.length ? request.input : undefined; } -export function parseRequest(line: string): Start | Prepare { +export function parseRequest(line: string): Start | Prepare | ExecutorPrepare { const value: unknown = JSON.parse(line); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const request = value as Record; const allowed = new Set(["type", "input", "model", "system_prompt", "cwd", "resume", "require_history", "observe_messages", "output_format", "subagents", "functions", "tool_search", "mcp_http_servers", "workspace"]); if (Object.keys(request).some(key => !allowed.has(key)) || - (request.type !== "start" && request.type !== "prepare") || + (request.type !== "start" && request.type !== "prepare" && request.type !== "executor_prepare") || (request.type === "start" ? !Array.isArray(request.input) : "input" in request) || typeof request.model !== "string" || !request.model.trim() || typeof request.system_prompt !== "string" || @@ -68,7 +69,7 @@ export function parseRequest(line: string): Start | Prepare { if (request.require_history && !workspace) throw new Error("invalid_request"); if ((workspace && "mcp_http_servers" in request) || (request.type === "prepare" && !workspace)) throw new Error("invalid_request"); - return request as Start | Prepare; + return request as Start | Prepare | ExecutorPrepare; } export function parseStart(line: string): Start { diff --git a/packages/claude-sdk-adapter/src/runtime_check.ts b/packages/claude-sdk-adapter/src/runtime_check.ts index e221c67fd..dc6caec94 100644 --- a/packages/claude-sdk-adapter/src/runtime_check.ts +++ b/packages/claude-sdk-adapter/src/runtime_check.ts @@ -45,7 +45,7 @@ try { assert.equal(smoke.error, undefined, "bridge_unavailable"); assert.equal(smoke.status, 0, "bridge_unavailable"); assert.deepEqual(JSON.parse(smoke.stdout), { type: "error", code: "invalid_request" }); - process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 2, features: [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions", "workspace_structured_output"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); + process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 3, features: ["executor_reuse", ...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions", "workspace_structured_output"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); } catch { // Native diagnostics can include operator environment; never forward them. process.stdout.write(JSON.stringify({ type: "runtime_unavailable" }) + "\n"); diff --git a/packages/claude-sdk-adapter/src/subagents.ts b/packages/claude-sdk-adapter/src/subagents.ts index 0441d0f1c..add5e689d 100644 --- a/packages/claude-sdk-adapter/src/subagents.ts +++ b/packages/claude-sdk-adapter/src/subagents.ts @@ -14,6 +14,11 @@ export class Subagents { constructor(private readonly cwd: string, private readonly limit: number, private resume: string | undefined) { if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("invalid_request"); } + get complete(): boolean { return this.pending.size === 0 && this.running.size === 0; } + beginTurn(): void { + if (!this.complete) throw new Error("native child work is unsettled"); + this.observedCalls.clear(); this.admitted.clear(); + } expectSession(id: string | undefined): void { this.resume = id; } permitsActor(id?: string): boolean { return id === undefined || this.known.has(id); } isCoordination(name: string): boolean { return ["Agent", "Task", "SendMessage"].includes(name); } diff --git a/packages/claude-sdk-adapter/src/usage.ts b/packages/claude-sdk-adapter/src/usage.ts index e7ad54077..03baf1b57 100644 --- a/packages/claude-sdk-adapter/src/usage.ts +++ b/packages/claude-sdk-adapter/src/usage.ts @@ -7,3 +7,14 @@ export function resultUsage(message: SDKResultMessage): NativeUsage { return structuredClone({ usage: message.usage, modelUsage: message.modelUsage, total_cost_usd: message.total_cost_usd, subtype: message.subtype, is_error: message.is_error }); } + +// Streaming-input results mix two scopes. Keep that distinction alongside the +// untouched values so a Turn consumer cannot sum query totals across Turns. +export function executorResultUsage(message: SDKResultMessage): NativeUsage & { + scopes: { usage: "native_turn_main_loop"; modelUsage: "query_cumulative"; total_cost_usd: "query_cumulative_estimate" }; +} { + return { ...resultUsage(message), scopes: { + usage: "native_turn_main_loop", modelUsage: "query_cumulative", + total_cost_usd: "query_cumulative_estimate", + } }; +} diff --git a/packages/claude-sdk-adapter/tests/executor.test.mjs b/packages/claude-sdk-adapter/tests/executor.test.mjs new file mode 100644 index 000000000..79ef95c4c --- /dev/null +++ b/packages/claude-sdk-adapter/tests/executor.test.mjs @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import test from "node:test"; + +const fixture = ` +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +const sdk = "export async function startup(args){return globalThis.startupFixture(args)} export function query(){throw new Error('one warm query required')} export async function getSessionInfo(){return {sessionId:'native'}}"; +registerHooks({resolve(specifier,context,next){if(specifier==='@anthropic-ai/claude-agent-sdk') return {url:'data:text/javascript,'+encodeURIComponent(sdk),shortCircuit:true};return next(specifier,context)}}); +globalThis.startupFixture=async({options})=>{ + const child=options.spawnClaudeCodeProcess({command:process.execPath,args:['-e',"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))"],env:options.env,signal:options.abortController.signal}); + process.send({type:'native',pid:child.pid}); + const exited=new Promise(resolve=>child.once('close',()=>{process.send({type:'native_closed'});resolve()})); + let client; + if(options.mcpServers.functions){ + client=new Client({name:'fixture',version:'1'}); + const [a,b]=InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(a),options.mcpServers.functions.instance.connect(b)]); + } + let queried=false,interrupt,number=0; + const close=()=>{child.stdin.end();interrupt?.()}; + options.abortController.signal.addEventListener('abort',close); + return {close,query(prompt){assert.equal(queried,false);queried=true;process.send({type:'query'});return { + close,async initializationResult(){return process.argv[1]==="no-hook-report" ? {} : process.argv[1]==="false-hook-report" ? {hooks_applied:false} : {hooks_applied:true}}, + async interrupt(){process.send({type:'interrupt'});interrupt?.();return process.argv[1]==='unknown' ? undefined : {still_queued:process.argv[1]==='queued' ? ['not-consumed'] : []}}, + async *[Symbol.asyncIterator](){ + for await(const user of prompt){ + number++; + const text=user.message.content[0].text; + process.send({type:'input',text}); + yield {type:'system',subtype:'init',session_id:'native',tools:client ? ['mcp__functions__lookup'] : [],mcp_servers:client ? [{name:'functions',status:'connected'}] : []}; + if(client){ + if(text==='features'){ + const called=await client.callTool({name:'lookup',arguments:{text},_meta:{'claudecode/toolUseId':'same-native-call'}}); + yield {type:'user',session_id:'native',parent_tool_use_id:null,message:{content:[{type:'tool_result',tool_use_id:'same-native-call',content:called.content,is_error:called.isError}]}}; + } + for(const event of [ + {type:'message_start',message:{id:'same-native-message'}}, + {type:'content_block_start',index:0,content_block:{type:'text',text}}, + {type:'content_block_stop',index:0},{type:'message_stop'} + ])yield {type:'stream_event',parent_tool_use_id:null,session_id:'native',event}; + } + if(text==='hold') await Promise.race([new Promise(resolve=>{interrupt=resolve}),exited]); + if(options.abortController.signal.aborted)return; + yield {type:'result',uuid:'result-'+number,session_id:'native',user_message_uuids:[user.uuid],subtype:'success',is_error:false,result:'answer-'+number,usage:{input_tokens:1,output_tokens:1},modelUsage:{fixture:{inputTokens:number,outputTokens:number,costUSD:number/100}},total_cost_usd:number/100}; + interrupt=undefined; + yield {type:'command_lifecycle',uuid:user.uuid,state:'completed'}; + } + } + }}}; +}; +await import(${JSON.stringify(new URL("../dist/main.js", import.meta.url).href)}); +process.disconnect(); + +`; + +async function launch(t,mode="normal") { + const child=spawn(process.execPath,["--input-type=module","-e",fixture,mode],{stdio:["pipe","pipe","pipe","ipc"]}); + const events=[],observations=[];let buffer="",stderr=""; + child.stdout.setEncoding("utf8");child.stderr.setEncoding("utf8"); + child.stdout.on("data",data=>{buffer+=data;while(buffer.includes("\n")){const end=buffer.indexOf("\n");events.push(JSON.parse(buffer.slice(0,end)));buffer=buffer.slice(end+1)}}); + child.stderr.on("data",data=>{stderr+=data});child.on("message",event=>observations.push(event)); + const closed=new Promise(resolve=>child.once("close",(code,signal)=>resolve({code,signal}))); + t.after(async()=>{child.kill("SIGKILL");await closed}); + const wait=async predicate=>{const until=Date.now()+5000;while(!predicate()){assert.ok(Date.now()setTimeout(resolve,5))}}; + const send=value=>child.stdin.write(JSON.stringify(value)+"\n"); + const start=(id,text)=>send({type:"turn_start",turn_id:id,input:[{content:[{type:"input_text",text}]}]}); + send({type:"executor_prepare",cwd:"/tmp",model:"fixture",system_prompt:"", + ...(mode==="features" ? {observe_messages:true,functions:[{name:"lookup",description:"lookup",parameters:{type:"object",properties:{text:{type:"string"}}}}]} : {})}); + await wait(()=>events.some(event=>event.type==="executor_ready")); + assert.equal(observations.filter(event=>event.type==="input").length,0); + return {child,events,observations,closed,wait,send,start}; +} + +test("executor retains one native process and one Query over two settled Turns",{timeout:10000},async t=>{ + const {child,events,observations,closed,wait,start}=await launch(t); + for(const id of ["first","second"]){start(id,"answer");await wait(()=>events.some(event=>event.type==="turn_settled"&&event.turn_id===id));assert.equal(events.find(event=>event.type==="turn_settled"&&event.turn_id===id).reusable,true)} + assert.equal(observations.filter(event=>event.type==="native").length,1); + assert.equal(observations.filter(event=>event.type==="query").length,1); + for(const event of events.filter(event=>event.type!=="executor_ready")) assert.ok(["first","second"].includes(event.turn_id)); + assert.equal(events.filter(event=>event.type==="input_ready").length,2); + const usage=events.filter(event=>event.type==="usage"); + assert.deepEqual(usage.map(event=>[event.turn_id,event.usage.usage.input_tokens]),[["first",1],["second",1]]); + assert.deepEqual(usage.map(event=>event.usage.modelUsage.fixture.inputTokens),[1,2]); + assert.deepEqual(usage.map(event=>event.usage.total_cost_usd),[0.01,0.02]); + for(const event of usage) assert.deepEqual(event.usage.scopes,{usage:"native_turn_main_loop",modelUsage:"query_cumulative",total_cost_usd:"query_cumulative_estimate"}); + child.stdin.end();assert.deepEqual(await closed,{code:0,signal:null}); + assert.equal(observations.filter(event=>event.type==="native_closed").length,1); +}); + +test("public interrupt settles cancellation and stale cancellation cannot stop the next Turn",{timeout:10000},async t=>{ + const {child,events,observations,closed,wait,send,start}=await launch(t); + start("first","hold");await wait(()=>events.some(event=>event.type==="input_ready")); + send({type:"turn_cancel",turn_id:"first"}); + await wait(()=>events.some(event=>event.type==="turn_settled")); + assert.equal(events.find(event=>event.type==="turn_settled").reusable,true); + assert.ok(events.some(event=>event.type==="error"&&event.code==="cancelled"&&event.turn_id==="first")); + start("second","hold");await wait(()=>events.some(event=>event.type==="input_ready"&&event.turn_id==="second")); + send({type:"turn_cancel",turn_id:"first"}); + await new Promise(resolve=>setTimeout(resolve,20)); + assert.equal(observations.filter(event=>event.type==="interrupt").length,1); + send({type:"turn_cancel",turn_id:"second"}); + await wait(()=>events.some(event=>event.type==="turn_settled"&&event.turn_id==="second")); + child.stdin.end();assert.deepEqual(await closed,{code:0,signal:null}); +}); + +for(const mode of ["queued","unknown"])test(`interrupt ${mode} receipt retires native ownership before non-reusable settlement`,{timeout:10000},async t=>{ + const {events,observations,closed,wait,send,start}=await launch(t,mode); + start("first","hold");await wait(()=>events.some(event=>event.type==="input_ready")); + send({type:"turn_cancel",turn_id:"first"}); + await wait(()=>events.some(event=>event.type==="turn_settled")); + assert.equal(events.find(event=>event.type==="turn_settled").reusable,false); + assert.equal(observations.filter(event=>event.type==="native_closed").length,1); + assert.deepEqual(await closed,{code:0,signal:null}); +}); + +test("reused Turns retain functions, native receipts, steering and message boundaries", {timeout:10000}, async t => { + const {child,events,closed,wait,send,start}=await launch(t,"features"); + for(const id of ["first","second"]){ + start(id,"features"); + await wait(()=>events.some(event=>event.type==="function_call"&&event.turn_id===id)); + send({type:"steer",turn_id:id,input_id:"same-input-id",input:[{content:[{type:"input_text",text:"extra"}]}]}); + send({type:"function_result",turn_id:id,call_id:"same-native-call",delivery_id:"same-delivery-id",success:true,content:[{type:"input_text",text:"function result"}]}); + await wait(()=>events.some(event=>event.type==="turn_settled"&&event.turn_id===id)); + const own=events.filter(event=>event.turn_id===id); + assert.equal(own.find(event=>event.type==="turn_settled").reusable,true); + assert.equal(own.filter(event=>event.type==="function_applied").length,1); + assert.equal(own.find(event=>event.type==="input_applied").input_id,"same-input-id"); + assert.deepEqual(own.filter(event=>event.type==="output_message"&&event.message.status==="completed").map(event=>event.message.text),["features","extra"]); + assert.equal(own.filter(event=>event.type==="usage").length,2); + } + child.stdin.end();assert.deepEqual(await closed,{code:0,signal:null}); +}); + +for(const mode of ["no-hook-report","false-hook-report"])test("none Executor without requested hooks accepts "+mode,{timeout:10000},async t=>{ + const {child,events,closed,wait,start}=await launch(t,mode); + start("first","answer"); + await wait(()=>events.some(event=>event.type==="turn_settled")); + assert.equal(events.find(event=>event.type==="turn_settled").reusable,true); + child.stdin.end();assert.deepEqual(await closed,{code:0,signal:null}); +}); diff --git a/packages/claude-sdk-adapter/tests/usage.test.mjs b/packages/claude-sdk-adapter/tests/usage.test.mjs index 39e65a059..e1f4365cc 100644 --- a/packages/claude-sdk-adapter/tests/usage.test.mjs +++ b/packages/claude-sdk-adapter/tests/usage.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { resultUsage } from "../dist/usage.js"; +import { executorResultUsage, resultUsage } from "../dist/usage.js"; test("preserve separate SDK scopes and price provenance without recalculating", () => { const first = { type: "result", subtype: "success", is_error: false, @@ -25,3 +25,17 @@ test("reported error usage survives and missing fields are not manufactured", () const missing = JSON.parse(JSON.stringify(resultUsage({ subtype: "error_during_execution", is_error: true }))); assert.deepEqual(missing, { subtype: "error_during_execution", is_error: true }); }); + +test("executor preserves cumulative counter resets without inventing per-Turn accounting", () => { + const values = [8, 15, 0, 2].map(total => executorResultUsage({ + subtype: "success", is_error: false, usage: { input_tokens: 2 }, + modelUsage: { primary: { inputTokens: total, costUSD: total / 100 } }, + total_cost_usd: total / 100, + })); + assert.deepEqual(values.map(value => value.modelUsage.primary.inputTokens), [8, 15, 0, 2]); + assert.deepEqual(values.map(value => value.usage.input_tokens), [2, 2, 2, 2]); + for (const value of values) { + assert.equal(value.scopes.modelUsage, "query_cumulative"); + assert.equal(value.scopes.usage, "native_turn_main_loop"); + } +}); diff --git a/scripts/check-claude-sdk-runtime.mjs b/scripts/check-claude-sdk-runtime.mjs index 156f79983..960bb36de 100644 --- a/scripts/check-claude-sdk-runtime.mjs +++ b/scripts/check-claude-sdk-runtime.mjs @@ -22,8 +22,8 @@ const probe = spawnSync(process.execPath, [join(root, "dist/runtime_check.js"), assert.equal(probe.status, 0, "Exported runtime is unavailable"); const report = JSON.parse(probe.stdout); assert.equal(report.type, "runtime_ready"); -assert.equal(report.protocol, 2); -assert.deepEqual(report.features, [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions", "workspace_structured_output"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); +assert.equal(report.protocol, 3); +assert.deepEqual(report.features, ["executor_reuse", ...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions", "workspace_structured_output"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); assert.equal(report.sdk, source.dependencies["@anthropic-ai/claude-agent-sdk"]); assert.equal(report.mcp, source.dependencies["@modelcontextprotocol/sdk"]); console.log(`Verified exported SDK ${report.sdk}, MCP ${report.mcp}, ${report.native}`); diff --git a/services/agents-api/internal/execution/delivery.go b/services/agents-api/internal/execution/delivery.go index 52b6d59dd..054b1bf29 100644 --- a/services/agents-api/internal/execution/delivery.go +++ b/services/agents-api/internal/execution/delivery.go @@ -3,6 +3,7 @@ package execution import ( "context" "encoding/json" + "errors" "strconv" "time" @@ -50,6 +51,8 @@ func abort(peer *gateway.Session, runID string) { } func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, peer *gateway.Session, request proto.PromptRequestPayload, first int64, prepared *preparedStart) (result Result, status string) { + changed, unsubscribeChanges := d.notifications.subscribe(tenantID, sessionID) + defer unsubscribeChanges() status = store.TurnFailed result.AppliedThrough = first subscription, err := peer.SubscribeDurable(request.RunID) @@ -80,6 +83,9 @@ func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, pe } }() var preparationEvents <-chan proto.Envelope + var executorRetried, nativeObserved bool + inputStarted := time.Now() + firstTextObserved := false if prepared != nil { preparationEvents = prepared.sub.Events err = prepared.start(ctx, request) @@ -92,6 +98,8 @@ func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, pe } ticker := time.NewTicker(250 * time.Millisecond) defer ticker.Stop() + workReady := make(chan struct{}, 1) + workReady <- struct{}{} flushTicker := time.NewTicker(100 * time.Millisecond) defer flushTicker.Stop() var pending *pendingInput @@ -118,6 +126,31 @@ func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, pe } started, err := prepared.started(env, request.RunID) if err != nil { + var rejection *preparationRejection + if errors.As(err, &rejection) && rejection.operation == proto.TypeExecutionStart && rejection.code == "executor_unavailable" && !executorRetried && !nativeObserved && cancelReply == nil { + // This receipt guarantees no native input was submitted and + // the previous owner was closed. Unknown delivery is never retried. + executorRetried = true + prepared.close() + currentPeer, peerErr := d.authorizedPeer(ctx, peer.DeviceID) + if peerErr != nil || currentPeer != peer { + result.ErrorCode = "device_disconnected" + return + } + replacement, prepareErr := d.prepareTurnExecutor(ctx, peer, tenantID, sessionID, request.RunID, request, store.TurnInProgress) + if prepareErr != nil { + result.ErrorCode = "executor_recovery_failed" + return + } + prepared = replacement + defer prepared.close() + preparationEvents = prepared.sub.Events + if prepared.start(ctx, request) != nil { + result.ErrorCode = "delivery_unknown" + return + } + continue + } // Cancellation owns the receipt even when it interrupts native Start. if cancelReply != nil { preparationEvents = nil @@ -165,6 +198,11 @@ func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, pe result.ErrorCode = "device_disconnected" return } + nativeObserved = true + if !firstTextObserved && hasText(env) { + firstTextObserved = true + recordFirstText(ctx, sessionID, request.RunID, inputStarted) + } writeErr := journal.observe(ctx, env) if result.mergeObservation(env) != nil { result.ErrorCode = "invalid_executor_result" @@ -223,6 +261,16 @@ func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, pe return } case <-ticker.C: + select { + case workReady <- struct{}{}: + default: + } + case <-changed: + select { + case workReady <- struct{}{}: + default: + } + case <-workReady: if !cancelSent.IsZero() { if time.Since(cancelSent) > 15*time.Second { result.ErrorCode = "cancel_unconfirmed" @@ -233,6 +281,9 @@ func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, pe turn, err := d.Store.GetTurn(ctx, tenantID, sessionID, request.RunID) if err != nil { result.ErrorCode = "execution_state_unavailable" + if ctx.Err() != nil { + result.ErrorCode = "execution_interrupted" + } return } if turn.Status != store.TurnInProgress && turn.Status != store.TurnWaiting { @@ -255,6 +306,9 @@ func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, pe inputs, err := d.Store.ListTurnInputs(ctx, tenantID, sessionID, request.RunID, result.AppliedThrough, 1) if err != nil { result.ErrorCode = "execution_state_unavailable" + if ctx.Err() != nil { + result.ErrorCode = "execution_interrupted" + } return } if len(inputs) == 0 { diff --git a/services/agents-api/internal/execution/directory_preparation.go b/services/agents-api/internal/execution/directory_preparation.go index 6d4aca014..a5c64a2f9 100644 --- a/services/agents-api/internal/execution/directory_preparation.go +++ b/services/agents-api/internal/execution/directory_preparation.go @@ -42,7 +42,7 @@ func (d *Dispatcher) withPreparedWorkspace(owner context.Context, peer *gateway. } }() prepare, stop := context.WithTimeout(owner, 10*time.Second) - err = send(prepare, peer, proto.TypeExecutionPrepare, prepared.requestID, proto.ExecutionPreparePayload{Configuration: req}) + err = send(prepare, peer, proto.TypeExecutionPrepare, prepared.requestID, proto.ExecutionPreparePayload{SessionID: session.ID, Configuration: req}) if err == nil { err = prepared.awaitDirectoryReady(prepare) } diff --git a/services/agents-api/internal/execution/dispatcher.go b/services/agents-api/internal/execution/dispatcher.go index 11ec43c75..7ca3ab041 100644 --- a/services/agents-api/internal/execution/dispatcher.go +++ b/services/agents-api/internal/execution/dispatcher.go @@ -29,6 +29,7 @@ type DaemonConfig struct { } type Dispatcher struct { + notifications *executionNotifications Policy Store *store.Store Registry *gateway.Registry @@ -89,12 +90,17 @@ func (d *Dispatcher) Run(ctx context.Context, tenantID, sessionID, turnID string if err != nil { return store.Turn{}, err } + req.WorkDir, req.DisableExecutionEnvironment = workDir, noEnvironment + prepared, err := d.prepareTurnExecutor(ctx, peer, tenantID, sessionID, turnID, req, store.TurnQueued) + if err != nil { + return store.Turn{}, err + } + defer prepared.close() if _, err := d.Store.TransitionTurn(ctx, tenantID, sessionID, turnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil { return store.Turn{}, err } req.ConversationID, req.RunID, req.Input = sessionID, turnID, text - req.WorkDir, req.DisableExecutionEnvironment = workDir, noEnvironment - result, status := d.deliver(ctx, tenantID, sessionID, peer, req, through, nil) + result, status := d.deliver(ctx, tenantID, sessionID, peer, req, through, prepared) return d.finishRun(tenantID, sessionID, turnID, snapshot.Agent.Model, result, status) } diff --git a/services/agents-api/internal/execution/environment_admission.go b/services/agents-api/internal/execution/environment_admission.go index b915e37af..da0da4a1c 100644 --- a/services/agents-api/internal/execution/environment_admission.go +++ b/services/agents-api/internal/execution/environment_admission.go @@ -85,7 +85,7 @@ func (w *Worker) submitEnvironmentInputs(ctx context.Context, session store.Sess } if (kind == "cancel" || kind == "tool_result") && !slices.ContainsFunc(inputs, func(input store.Input) bool { return input.Kind != kind }) { // Neither kind creates a Turn. The Session lock preserves target and retry identity. - return w.admission.SubmitInputs(ctx, session.TenantID, session.ID, key, inputs) + return w.admitInputs(ctx, session.TenantID, session.ID, key, inputs) } // Messages start work. A Session from before deployment defaults moved into // Core may have no frozen provider; reject it here instead of queueing work @@ -94,12 +94,15 @@ func (w *Worker) submitEnvironmentInputs(ctx context.Context, session store.Sess if json.Unmarshal(session.Configuration, &snapshot) != nil || !snapshot.ModelProviderConfigured { return nil, store.ErrModelProviderRequired } + changed, unsubscribe := w.dispatcher.notifications.subscribe(session.TenantID, session.ID) + defer unsubscribe() reserve, cancel := context.WithTimeout(ctx, 5*time.Second) reservation, err := w.admission.ReserveEnvironmentInput(reserve, session.TenantID, session.ID, key, inputs) cancel() if err != nil { return nil, err } + w.wakeScheduler() if reservation.State == store.EnvironmentInputPending && !reservation.IsInitial { w.hintRuntimeWake(ctx, session) } @@ -120,6 +123,7 @@ func (w *Worker) submitEnvironmentInputs(ctx context.Context, session store.Sess case <-ctx.Done(): return nil, ctx.Err() case <-ticker.C: + case <-changed: } if err := w.checkAdmissionOwnership(ctx); err != nil { return nil, err diff --git a/services/agents-api/internal/execution/executor_observation.go b/services/agents-api/internal/execution/executor_observation.go new file mode 100644 index 000000000..12eec8293 --- /dev/null +++ b/services/agents-api/internal/execution/executor_observation.go @@ -0,0 +1,41 @@ +package execution + +import ( + "context" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" +) + +// These durations use one Core process monotonic clock. They describe control +// readiness and input-to-first-text latency, not model-only or native tool time. +func recordExecutorReadiness(p *preparedStart, status proto.PreparationStatusPayload) { + obslog.Bg().Info("executor ready", "request_id", p.requestID, + "executor_id", status.ExecutorID, "reused", status.Reused, + "control_ready_ms", time.Since(p.createdAt).Milliseconds()) +} + +// The started receipt confirms adapter ownership, not model consumption. +func recordExecutorStart(p *preparedStart, turn string) { + obslog.Bg().Info("executor start acknowledged", "turn_id", turn, + "request_id", p.requestID, "executor_id", p.executorID, + "start_control_ms", time.Since(p.startSentAt).Milliseconds()) +} + +func recordFirstText(ctx context.Context, session, turn string, submitted time.Time) { + obslog.Bg().InfoContext(ctx, "executor first text", "session_id", session, + "turn_id", turn, "input_to_first_text_ms", time.Since(submitted).Milliseconds()) +} + +func hasText(env proto.Envelope) bool { + switch env.Type { + case proto.TypeDelta: + var text proto.DeltaPayload + return env.DecodePayload(&text) == nil && text.Delta != "" + case proto.TypeOutputMessage: + var text proto.OutputMessagePayload + return env.DecodePayload(&text) == nil && text.Text != nil && *text.Text != "" + } + return false +} diff --git a/services/agents-api/internal/execution/executor_preparation.go b/services/agents-api/internal/execution/executor_preparation.go new file mode 100644 index 000000000..11078598b --- /dev/null +++ b/services/agents-api/internal/execution/executor_preparation.go @@ -0,0 +1,63 @@ +package execution + +import ( + "context" + "errors" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// prepareTurnExecutor reserves one admission on the Runtime-owned Executor. +// Core does not cache native ownership. A replacement requires the Runtime to +// confirm cleanup and recover the exact Session history before returning ready. +func (d *Dispatcher) prepareTurnExecutor(ctx context.Context, peer *gateway.Session, tenant, session, turn string, request proto.PromptRequestPayload, expectedStatus string) (*preparedStart, error) { + prepared, err := newPreparedStart(peer) + if err != nil { + return nil, err + } + request.RunID, request.ConversationID, request.Input = "", "", nil + if err = send(ctx, peer, proto.TypeExecutionPrepare, prepared.requestID, proto.ExecutionPreparePayload{SessionID: session, Configuration: request}); err == nil { + err = d.awaitTurnExecutor(ctx, tenant, session, turn, expectedStatus, prepared) + } + if err != nil { + prepared.close() + return nil, err + } + return prepared, nil +} + +func (d *Dispatcher) awaitTurnExecutor(ctx context.Context, tenant, session, turn, expectedStatus string, prepared *preparedStart) error { + tick := time.NewTicker(250 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-tick.C: + current, err := d.Store.GetTurn(ctx, tenant, session, turn) + if err != nil { + return err + } + if current.Status != expectedStatus || !current.CancelRequestedAt.IsZero() { + return store.ErrTurnConflict + } + case env, ok := <-prepared.sub.Events: + if !ok { + return errors.New("executor preparation control stream closed") + } + status, err := prepared.observation(env) + if err != nil { + return err + } + if status.State == "ready" && status.RunID == "" && status.ExecutorID != "" { + return nil + } + if status.State != "preparing" { + return errors.New("unexpected executor state before Turn start") + } + } + } +} diff --git a/services/agents-api/internal/execution/notifications.go b/services/agents-api/internal/execution/notifications.go new file mode 100644 index 000000000..830f36859 --- /dev/null +++ b/services/agents-api/internal/execution/notifications.go @@ -0,0 +1,50 @@ +package execution + +import "sync" + +// executionNotifications carries process-local hints after durable commits. +// Every consumer rechecks storage and retains polling for external changes. +// Subscriptions exist only while a caller is waiting or a Turn is active. +type executionNotifications struct { + mu sync.Mutex + waiters map[string]map[chan struct{}]struct{} +} + +func (n *executionNotifications) subscribe(tenant, session string) (<-chan struct{}, func()) { + if n == nil { + return nil, func() {} + } + key := tenant + ":" + session + ch := make(chan struct{}, 1) + n.mu.Lock() + if n.waiters == nil { + n.waiters = make(map[string]map[chan struct{}]struct{}) + } + if n.waiters[key] == nil { + n.waiters[key] = make(map[chan struct{}]struct{}) + } + n.waiters[key][ch] = struct{}{} + n.mu.Unlock() + return ch, func() { + n.mu.Lock() + defer n.mu.Unlock() + delete(n.waiters[key], ch) + if len(n.waiters[key]) == 0 { + delete(n.waiters, key) + } + } +} + +func (n *executionNotifications) notify(tenant, session string) { + if n == nil { + return + } + n.mu.Lock() + defer n.mu.Unlock() + for ch := range n.waiters[tenant+":"+session] { + select { + case ch <- struct{}{}: + default: + } + } +} diff --git a/services/agents-api/internal/execution/notifications_test.go b/services/agents-api/internal/execution/notifications_test.go new file mode 100644 index 000000000..b39588fc9 --- /dev/null +++ b/services/agents-api/internal/execution/notifications_test.go @@ -0,0 +1,50 @@ +package execution + +import ( + "sync" + "testing" +) + +func TestExecutionNotificationsKeepCommitHintsAndIsolateSessions(t *testing.T) { + notifications := &executionNotifications{} + one, leaveOne := notifications.subscribe("tenant", "one") + other, leaveOther := notifications.subscribe("other-tenant", "one") + defer leaveOther() + var writers sync.WaitGroup + for range 20 { + writers.Go(func() { notifications.notify("tenant", "one") }) + } + writers.Wait() + select { + case <-one: + default: + t.Fatal("committed change was lost") + } + select { + case <-other: + t.Fatal("hint crossed tenant ownership") + default: + } + select { + case <-one: + t.Fatal("hints did not coalesce") + default: + } + notifications.notify("tenant", "one") + select { + case <-one: + default: + t.Fatal("next commit was lost") + } + leaveOne() + notifications.notify("tenant", "one") + select { + case <-one: + t.Fatal("detached observer received a hint") + default: + } + leaveOther() + if len(notifications.waiters) != 0 { + t.Fatal("subscriptions leaked") + } +} diff --git a/services/agents-api/internal/execution/preparation.go b/services/agents-api/internal/execution/preparation.go index 8f1706d3d..f33db9a38 100644 --- a/services/agents-api/internal/execution/preparation.go +++ b/services/agents-api/internal/execution/preparation.go @@ -11,11 +11,23 @@ import ( "github.com/google/uuid" ) +type preparationRejection struct { + code string + operation string +} + +func (e *preparationRejection) Error() string { return "preparation control rejected: " + e.code } + type preparedStart struct { - peer *gateway.Session - requestID string - handle string - sub *gateway.Subscription + createdAt time.Time + startSentAt time.Time + startObserved bool + readyObserved bool + peer *gateway.Session + requestID string + handle string + executorID string + sub *gateway.Subscription } func newPreparedStart(peer *gateway.Session) (*preparedStart, error) { @@ -24,7 +36,7 @@ func newPreparedStart(peer *gateway.Session) (*preparedStart, error) { if err != nil { return nil, err } - return &preparedStart{peer: peer, requestID: id, sub: sub}, nil + return &preparedStart{peer: peer, requestID: id, sub: sub, createdAt: time.Now()}, nil } func (p *preparedStart) close() { @@ -45,7 +57,14 @@ func (p *preparedStart) controlStatus(env proto.Envelope) (proto.PreparationStat if status.Handle == "" || status.Revision == 0 || (p.handle != "" && p.handle != status.Handle) { return status, errors.New("preparation identity changed") } - p.handle = status.Handle + if p.executorID != "" && status.ExecutorID != p.executorID { + return status, errors.New("executor identity changed") + } + p.handle, p.executorID = status.Handle, status.ExecutorID + if status.State == "ready" && status.ExecutorID != "" && !p.readyObserved { + p.readyObserved = true + recordExecutorReadiness(p, status) + } return status, nil } @@ -55,7 +74,7 @@ func (p *preparedStart) observation(env proto.Envelope) (proto.PreparationStatus return status, err } if status.State == "rejected" { - return status, errors.New("preparation control rejected") + return status, &preparationRejection{code: status.ErrorCode, operation: status.Operation} } switch status.State { case "preparing", "ready", "starting", "started": @@ -85,7 +104,7 @@ func (d *Dispatcher) awaitPreparation(ctx context.Context, tenant, session strin if err != nil { return pending, err } - if status.State == "ready" && status.RunID == "" { + if status.State == "ready" && status.RunID == "" && status.ExecutorID != "" { return pending, nil } if status.State != "preparing" { @@ -96,7 +115,8 @@ func (d *Dispatcher) awaitPreparation(ctx context.Context, tenant, session strin } func (p *preparedStart) start(ctx context.Context, request proto.PromptRequestPayload) error { - return send(ctx, p.peer, proto.TypeExecutionStart, p.requestID, proto.ExecutionStartPayload{Handle: p.handle, RunID: request.RunID, Input: request.Input}) + p.startSentAt = time.Now() + return send(ctx, p.peer, proto.TypeExecutionStart, p.requestID, proto.ExecutionStartPayload{Handle: p.handle, ExecutorID: p.executorID, RunID: request.RunID, Input: request.Input}) } func (p *preparedStart) started(env proto.Envelope, runID string) (bool, error) { @@ -107,5 +127,9 @@ func (p *preparedStart) started(env proto.Envelope, runID string) (bool, error) if status.RunID != runID || (status.State != "starting" && status.State != "started") { return false, errors.New("unexpected preparation state after admission") } + if status.State == "started" && !p.startObserved { + p.startObserved = true + recordExecutorStart(p, runID) + } return status.State == "started", nil } diff --git a/services/agents-api/internal/execution/prepared_dispatch.go b/services/agents-api/internal/execution/prepared_dispatch.go index 453d85745..c480de5c8 100644 --- a/services/agents-api/internal/execution/prepared_dispatch.go +++ b/services/agents-api/internal/execution/prepared_dispatch.go @@ -16,7 +16,7 @@ type EnvironmentRun struct { Turn store.Turn } -// RunEnvironmentInput owns a private preparation through its first admitted Run. +// RunEnvironmentInput reserves a Turn on the Session-owned Runtime Executor. func (d *Dispatcher) RunEnvironmentInput(ctx context.Context, tenantID, sessionID, reservationID string) (run EnvironmentRun, err error) { if err = d.Store.CheckExecutionOwnership(ctx); err != nil { return run, err @@ -81,7 +81,7 @@ func (d *Dispatcher) RunEnvironmentInput(ctx context.Context, tenantID, sessionI return run, err } defer prepared.close() - if err = send(owner, peer, proto.TypeExecutionPrepare, prepared.requestID, proto.ExecutionPreparePayload{Configuration: req}); err != nil { + if err = send(owner, peer, proto.TypeExecutionPrepare, prepared.requestID, proto.ExecutionPreparePayload{SessionID: sessionID, Configuration: req}); err != nil { return run, err } run.Reservation, err = d.awaitPreparation(owner, tenantID, sessionID, run.Reservation, prepared) @@ -96,6 +96,9 @@ func (d *Dispatcher) RunEnvironmentInput(ctx context.Context, tenantID, sessionI // A rejected claim leaves the reservation pending for a later attempt. return run, err } + if err == nil { + d.notifications.notify(tenantID, sessionID) + } run.Reservation = promoted if err != nil || run.Reservation.State != store.EnvironmentInputAdmitted { return run, err diff --git a/services/agents-api/internal/execution/request.go b/services/agents-api/internal/execution/request.go index 8d398e622..000435f17 100644 --- a/services/agents-api/internal/execution/request.go +++ b/services/agents-api/internal/execution/request.go @@ -52,7 +52,7 @@ func (d *Dispatcher) executionRequest(ctx context.Context, session store.Session } request := proto.PromptRequestPayload{AgentKind: session.Engine, FunctionTools: tools.Functions, ToolSearch: tools.Search, AgentOptions: options, ExecutionControls: controls, AgentStateKey: "agents-api-" + session.ID, - AgentSessionID: bound.NativeSessionID, ReleaseOnCompletion: true, StrictResume: true, + AgentSessionID: bound.NativeSessionID, StrictResume: true, RequireExistingNativeSession: recoverNativeSession, ObserveMessages: caps.MessageItems, ObserveToolObservations: true, ObserveSubagentIdentities: snapshot.Agent.MultiAgent.Enabled, diff --git a/services/agents-api/internal/execution/support.go b/services/agents-api/internal/execution/support.go index c00dbc5cf..43798f733 100644 --- a/services/agents-api/internal/execution/support.go +++ b/services/agents-api/internal/execution/support.go @@ -86,6 +86,9 @@ func (p Policy) engineCapabilities(peer *gateway.Session, engine string, snapsho if !known || !found || !info.Available || !caps.Streaming || !caps.Steering || !caps.DurableTurns || !caps.DurableInputReceipts { return fail("device must advertise streaming, steering and durable turns for this engine") } + if !caps.Preparation { + return fail("device must advertise executor preparation") + } if !caps.ExecutionControls { return fail("device must advertise execution_controls") } diff --git a/services/agents-api/internal/execution/worker.go b/services/agents-api/internal/execution/worker.go index cbdd976d7..41eec56b3 100644 --- a/services/agents-api/internal/execution/worker.go +++ b/services/agents-api/internal/execution/worker.go @@ -22,6 +22,7 @@ type Worker struct { lease *store.ExecutionLease directoryReads chan directoryReadRequest fileWrites chan fileWriteRequest + scheduleWake chan struct{} stopped chan struct{} stopOnce sync.Once runtimes *runtimeManager @@ -38,7 +39,8 @@ func StartWorker(ctx context.Context, dispatcher *Dispatcher) (*Worker, error) { } owned := *dispatcher owned.Store = lease.Store() - worker := &Worker{concurrency: dispatcher.MaxConcurrentExecutions, dispatcher: &owned, admission: dispatcher.Store, lease: lease, directoryReads: make(chan directoryReadRequest), fileWrites: make(chan fileWriteRequest), stopped: make(chan struct{}), enrolledConnections: make(map[string]*runtimeConnection)} + owned.notifications = &executionNotifications{} + worker := &Worker{concurrency: dispatcher.MaxConcurrentExecutions, dispatcher: &owned, admission: dispatcher.Store, lease: lease, directoryReads: make(chan directoryReadRequest), fileWrites: make(chan fileWriteRequest), stopped: make(chan struct{}), scheduleWake: make(chan struct{}, 1), enrolledConnections: make(map[string]*runtimeConnection)} worker.runtimes, err = newRuntimeManager(owned.Store, owned.Registry, owned.ManagedRuntimes) if err != nil { _ = lease.Close(context.Background()) @@ -105,7 +107,7 @@ func (w *Worker) SubmitInputs(ctx context.Context, tenant, session, key string, if !w.dispatcher.canAdmitInputs(value.Engine, value.Configuration) { return nil, store.ErrInvalidInput } - return w.admission.SubmitInputs(ctx, tenant, session, key, inputs) + return w.admitInputs(ctx, tenant, session, key, inputs) } // CreateSession validates execution support before reserving or admitting initial work. @@ -113,7 +115,11 @@ func (w *Worker) CreateSession(ctx context.Context, tenant string, input store.C if err := w.validateCreation(ctx, input); err != nil { return store.Session{}, err } - return w.admission.CreateSession(ctx, tenant, input) + session, err := w.admission.CreateSession(ctx, tenant, input) + if err == nil && len(input.InitialInputs) > 0 { + w.wakeScheduler() + } + return session, err } // CreateSessionStream applies the same execution admission before creating a stream. @@ -121,7 +127,11 @@ func (w *Worker) CreateSessionStream(ctx context.Context, tenant string, input s if err := w.validateCreation(ctx, input); err != nil { return store.SessionCreation{}, err } - return w.admission.CreateSessionStream(ctx, tenant, input) + creation, err := w.admission.CreateSessionStream(ctx, tenant, input) + if err == nil && len(input.InitialInputs) > 0 { + w.wakeScheduler() + } + return creation, err } // Run retains queued work across restarts, but never replays an uncertain claim. @@ -174,7 +184,12 @@ func (w *Worker) Run(ctx context.Context) (runErr error) { ticker := time.NewTicker(250 * time.Millisecond) defer ticker.Stop() schedule := workerSchedule{} + // A hint can encounter occupied slots or a preceding execution finishing + // on the same Session. Allow one immediate retry when a slot is released; + // failed preparations still wait for polling instead of spinning. + rescanOnCompletion := false for { + maintenance := false select { case <-ctx.Done(): return ctx.Err() @@ -192,10 +207,15 @@ func (w *Worker) Run(ctx context.Context) (runErr error) { defer running.Done() writesCompleted <- writeCompletion{request: request, result: w.runFileWrite(ctx, request)} }() + continue case write := <-writesCompleted: delete(active, write.request.environment.SessionID) w.observeSlots(len(active)) write.request.result <- write.result + if !rescanOnCompletion { + continue + } + rescanOnCompletion = false case request := <-w.directoryReads: if request.ctx.Err() != nil || reads == w.executionConcurrency() || (!active[request.environment.SessionID] && len(active) == w.executionConcurrency()) { request.reply(directoryReadResult{err: ErrExecutionUnavailable}) @@ -217,6 +237,7 @@ func (w *Worker) Run(ctx context.Context) (runErr error) { } readsCompleted <- readCompletion{id: id, request: request, result: result} }() + continue case read := <-readsCompleted: reads-- if read.id != "" { @@ -224,20 +245,33 @@ func (w *Worker) Run(ctx context.Context) (runErr error) { w.observeSlots(len(active)) } read.request.reply(read.result) + if read.id == "" || !rescanOnCompletion { + continue + } + rescanOnCompletion = false case result := <-completed: delete(active, result.id) w.observeSlots(len(active)) if result.err != nil { return result.err } - case <-ticker.C: - check, stop := context.WithTimeout(ctx, 5*time.Second) - err := w.CheckOwnership(check) - stop() - if err != nil { - w.observeSchedulerPoll(0, err) - return err + if !rescanOnCompletion { + continue } + rescanOnCompletion = false + case <-w.scheduleWake: + rescanOnCompletion = true + case <-ticker.C: + maintenance = true + } + check, stop := context.WithTimeout(ctx, 5*time.Second) + err := w.CheckOwnership(check) + stop() + if err != nil { + w.observeSchedulerPoll(0, err) + return err + } + if maintenance { if _, err := w.dispatcher.Store.ExpireEnvironmentInputs(ctx); err != nil { w.observeSchedulerPoll(0, err) return err @@ -246,35 +280,40 @@ func (w *Worker) Run(ctx context.Context) (runErr error) { w.observeSchedulerPoll(0, err) return err } - if len(active) == w.executionConcurrency() { - w.observeSchedulerPoll(0, nil) - continue - } - devices := w.dispatcher.Registry.Devices() - if len(devices) == 0 { - w.observeSchedulerPoll(0, nil) - continue - } - work, err := schedule.selectWork(ctx, w, devices, active) - w.observeSlots(len(active)) - if err != nil { - w.observeSchedulerPoll(0, err) - return err - } - w.observeSchedulerPoll(len(work), nil) - for _, item := range work { - running.Add(1) - go func() { - defer running.Done() - var err error - if item.reservationID != "" { - err = w.runEnvironmentInput(ctx, item) - } else { - err = w.runClaim(ctx, item.ExecutionWork) - } - completed <- completion{id: item.SessionID, err: err} - }() - } + } + if len(active) == w.executionConcurrency() { + w.observeSchedulerPoll(0, nil) + continue + } + devices := w.dispatcher.Registry.Devices() + if len(devices) == 0 { + w.observeSchedulerPoll(0, nil) + continue + } + if !maintenance { + schedule.nextEnvironmentScan = time.Time{} + } + work, err := schedule.selectWork(ctx, w, devices, active) + w.observeSlots(len(active)) + if err != nil { + w.observeSchedulerPoll(0, err) + return err + } + w.observeSchedulerPoll(len(work), nil) + rescanOnCompletion = rescanOnCompletion && (len(active) > len(work) || len(active) == w.executionConcurrency()) + for _, item := range work { + running.Add(1) + go func() { + defer running.Done() + var err error + if item.reservationID != "" { + err = w.runEnvironmentInput(ctx, item) + } else { + err = w.runClaim(ctx, item.ExecutionWork) + } + w.dispatcher.notifications.notify(item.TenantID, item.SessionID) + completed <- completion{id: item.SessionID, err: err} + }() } } } diff --git a/services/agents-api/internal/execution/worker_schedule.go b/services/agents-api/internal/execution/worker_schedule.go index 3e5321483..fbb932320 100644 --- a/services/agents-api/internal/execution/worker_schedule.go +++ b/services/agents-api/internal/execution/worker_schedule.go @@ -25,8 +25,12 @@ func (s *workerSchedule) selectWork(ctx context.Context, w *Worker, devices []st if err != nil { return nil, err } - if len(turns) == 0 { + if len(turns) == 0 && s.turnCursor != "" { s.turnCursor = "" + turns, err = w.dispatcher.Store.ListExecutionWork(ctx, "", []string{store.TurnQueued}, devices) + if err != nil { + return nil, err + } } var environments []store.EnvironmentInputWork if !time.Now().Before(s.nextEnvironmentScan) { diff --git a/services/agents-api/internal/execution/worker_wakeup.go b/services/agents-api/internal/execution/worker_wakeup.go new file mode 100644 index 000000000..fa5539189 --- /dev/null +++ b/services/agents-api/internal/execution/worker_wakeup.go @@ -0,0 +1,25 @@ +package execution + +import ( + "context" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// wakeScheduler only hints at committed work. The existing loop retains lease, +// capacity and Session ownership; polling recovers absent or coalesced hints. +func (w *Worker) wakeScheduler() { + select { + case w.scheduleWake <- struct{}{}: + default: + } +} + +func (w *Worker) admitInputs(ctx context.Context, tenant, session, key string, inputs []store.Input) ([]store.InputReceipt, error) { + receipts, err := w.admission.SubmitInputs(ctx, tenant, session, key, inputs) + if err == nil { + w.wakeScheduler() + w.dispatcher.notifications.notify(tenant, session) + } + return receipts, err +} diff --git a/services/agents-api/internal/execution/worker_wakeup_test.go b/services/agents-api/internal/execution/worker_wakeup_test.go new file mode 100644 index 000000000..8a0a48f15 --- /dev/null +++ b/services/agents-api/internal/execution/worker_wakeup_test.go @@ -0,0 +1,35 @@ +package execution + +import ( + "sync" + "testing" +) + +func TestSchedulerWakeCoalescesConcurrentAdmissionsAndKeepsNextHint(t *testing.T) { + worker := &Worker{scheduleWake: make(chan struct{}, 1)} + var callers sync.WaitGroup + for range 100 { + callers.Go(func() { + for range 100 { + worker.wakeScheduler() + } + }) + } + callers.Wait() + if got := len(worker.scheduleWake); got != 1 { + t.Fatalf("queued wakeups = %d, want one", got) + } + <-worker.scheduleWake + // A commit while a previous scan is running needs a subsequent scan. + worker.wakeScheduler() + select { + case <-worker.scheduleWake: + default: + t.Fatal("admission during a scan lost its wakeup") + } + select { + case <-worker.scheduleWake: + t.Fatal("coalesced submissions caused an extra scan") + default: + } +} diff --git a/services/agents-api/internal/store/claude_execution_test.go b/services/agents-api/internal/store/claude_execution_test.go index f6eb75005..deca0c94c 100644 --- a/services/agents-api/internal/store/claude_execution_test.go +++ b/services/agents-api/internal/store/claude_execution_test.go @@ -30,7 +30,7 @@ func claudeSession(t *testing.T, h *dispatchHarness, configuration string, prebo func claudeHeartbeat(t *testing.T, h *dispatchHarness, ready bool) { t.Helper() - caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: ready, ExecutionControls: true, EnvironmentNone: true, SubagentControl: true, FunctionTools: true, ToolObservations: true} + caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: ready, ExecutionControls: true, EnvironmentNone: true, SubagentControl: true, FunctionTools: true, ToolObservations: true, Preparation: true} h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) deadline := time.Now().Add(3 * time.Second) for { @@ -85,7 +85,7 @@ func TestClaudeWorkerSelectsStoredEngineAndRestrictiveCapabilities(t *testing.T) queued() // Durable application receipts are required for this engine too. claudeHeartbeat(t, h, true) var prompt proto.PromptRequestPayload - if h.read(proto.TypePromptRequest).DecodePayload(&prompt) != nil { + if h.read(testExecutionRequest).DecodePayload(&prompt) != nil { t.Fatal("invalid prompt") } if prompt.AgentKind != "claude_sdk" || len(prompt.FunctionTools) != 1 || !prompt.DisableExecutionEnvironment || !prompt.DisableSubagents || prompt.ExecutionControls == nil || *prompt.ExecutionControls != (proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}) { diff --git a/services/agents-api/internal/store/claude_mcp_test.go b/services/agents-api/internal/store/claude_mcp_test.go index 87a7bbc7d..c09fed51c 100644 --- a/services/agents-api/internal/store/claude_mcp_test.go +++ b/services/agents-api/internal/store/claude_mcp_test.go @@ -28,7 +28,7 @@ func TestClaudeMCPWaitsForCapableRuntime(t *testing.T) { } claudeSession(t, h, configuration, prebound) // Base MCP support does not imply authentication or required initialization. - caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, ExecutionControls: true, EnvironmentNone: true, SubagentControl: true, ToolObservations: true, MCPHTTPTools: authenticated || required} + caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, ExecutionControls: true, EnvironmentNone: true, SubagentControl: true, ToolObservations: true, MCPHTTPTools: authenticated || required, Preparation: true} h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) awaitDaemonRemoteCondition(t, t.Context(), 3*time.Second, "Claude MCP heartbeat", func() bool { peer, _ := h.registry.LookupDevice(h.device.ID) @@ -71,7 +71,7 @@ func TestClaudeMCPWaitsForCapableRuntime(t *testing.T) { caps.MCPHTTPRequired = required h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "claude_sdk", Available: true, Capabilities: caps}}}) var prompt proto.PromptRequestPayload - if h.read(proto.TypePromptRequest).DecodePayload(&prompt) != nil || prompt.AgentKind != "claude_sdk" || prompt.MCPHTTPServers == nil || len(*prompt.MCPHTTPServers) != 1 { + if h.read(testExecutionRequest).DecodePayload(&prompt) != nil || prompt.AgentKind != "claude_sdk" || prompt.MCPHTTPServers == nil || len(*prompt.MCPHTTPServers) != 1 { t.Fatal("missing typed MCP dispatch") } server := (*prompt.MCPHTTPServers)[0] @@ -104,7 +104,7 @@ func TestClaudeMCPUnsupportedSnapshotRejectedBeforeClaim(t *testing.T) { configuration = strings.Replace(configuration, `"tickets"`, `"functions"`, 1) } claudeSession(t, h, configuration, true) - caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, ExecutionControls: true, EnvironmentNone: true, SubagentControl: true, ToolObservations: true, MCPHTTPTools: true, MCPHTTPRequired: true, MCPHTTPBearerAuth: true} + caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, ExecutionControls: true, EnvironmentNone: true, SubagentControl: true, ToolObservations: true, MCPHTTPTools: true, MCPHTTPRequired: true, MCPHTTPBearerAuth: true, Preparation: true} if profile == "missing required capability" { caps.MCPHTTPRequired = false } diff --git a/services/agents-api/internal/store/command_output_test.go b/services/agents-api/internal/store/command_output_test.go index ff5d27964..34b7889bb 100644 --- a/services/agents-api/internal/store/command_output_test.go +++ b/services/agents-api/internal/store/command_output_test.go @@ -131,7 +131,7 @@ func TestExecutionJournalsCommandOutputBeforeCancellation(t *testing.T) { ctx := context.Background() input := h.message("command", "run a command") result := h.run(ctx, input.TurnID) - h.read(proto.TypePromptRequest) + h.read(testExecutionRequest) h.write(input.TurnID, proto.TypeToolCall, proto.ToolCallPayload{ID: "cmd", Stage: "before", Observation: &proto.ToolObservation{Kind: "command", Command: "wait", Status: "in_progress"}}) h.write(input.TurnID, proto.TypeCommandOutput, proto.CommandOutputPayload{ID: "cmd", Delta: "partial"}) if _, err := h.s.RequestCancel(ctx, h.tenant, h.session.ID, "cancel"); err != nil { diff --git a/services/agents-api/internal/store/dispatch_test.go b/services/agents-api/internal/store/dispatch_test.go index ed371e923..e68ff6213 100644 --- a/services/agents-api/internal/store/dispatch_test.go +++ b/services/agents-api/internal/store/dispatch_test.go @@ -22,6 +22,8 @@ import ( ) type dispatchHarness struct { + writeMu sync.Mutex + admissions map[string]fixtureAdmission t *testing.T s *store.Store d *execution.Dispatcher @@ -92,7 +94,7 @@ func newDispatchHarnessForSession(t *testing.T, configuration []byte, local bool t.Fatal("device connection failed") } t.Cleanup(func() { h.conn.Close() }) - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, Resume: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, SubagentObservations: true, ToolObservations: true, NativeSessionRecovery: true}}}}) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, Resume: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, SubagentObservations: true, ToolObservations: true, NativeSessionRecovery: true, Preparation: true}}}}) deadline := time.Now().Add(3 * time.Second) for { peer, e := h.registry.LookupDevice(h.device.ID) @@ -122,6 +124,12 @@ func (h *dispatchHarness) message(key, text string) store.InputReceipt { func (h *dispatchHarness) write(run, kind string, payload any) { h.t.Helper() + h.writeMu.Lock() + defer h.writeMu.Unlock() + if status, ok := payload.(proto.PreparationStatusPayload); ok && status.Handle != "" && status.ExecutorID == "" { + status.ExecutorID = "executor-" + status.Handle + payload = status + } env, err := proto.NewEnvelope(kind, run, payload) if err != nil { h.t.Fatal(err) @@ -139,6 +147,13 @@ func (h *dispatchHarness) read(kind string) proto.Envelope { if err := h.conn.ReadJSON(&env); err != nil { h.t.Fatal(err) } + if kind == testExecutionRequest { + var keep bool + env, keep = h.executionFrame(env) + if !keep { + continue + } + } if env.Type == kind { return env } @@ -175,7 +190,7 @@ func TestExecutionDispatchSteeringAndNativeContinuity(t *testing.T) { ctx := context.Background() first := h.message("first", "Initial input") result := h.run(ctx, first.TurnID) - request := h.read(proto.TypePromptRequest) + request := h.read(testExecutionRequest) var prompt proto.PromptRequestPayload _ = request.DecodePayload(&prompt) if inputTextForTest(t, prompt.Input) != "Initial input" || prompt.ConversationID != h.session.ID || prompt.AgentOptions["model"] != "test-model" || prompt.AgentOptions["system_prompt"] != "Keep this instruction." { @@ -217,7 +232,7 @@ func TestExecutionDispatchSteeringAndNativeContinuity(t *testing.T) { } next := h.message("third", "Continue the session") result = h.run(ctx, next.TurnID) - request = h.read(proto.TypePromptRequest) + request = h.read(testExecutionRequest) _ = request.DecodePayload(&prompt) if prompt.AgentSessionID != "native-thread-1" || prompt.AgentStateKey != "agents-api-"+h.session.ID { t.Fatal("native continuity lost") @@ -232,7 +247,7 @@ func TestExecutionCancellationRequiresReceiptAndSurvivesContextEnd(t *testing.T) h := newDispatchHarness(t) first := h.message("first", "Run") result := h.run(context.Background(), first.TurnID) - h.read(proto.TypePromptRequest) + h.read(testExecutionRequest) _, err := h.s.RequestCancel(context.Background(), h.tenant, h.session.ID, "cancel") if err != nil { t.Fatal(err) @@ -255,7 +270,7 @@ func TestExecutionCancellationRequiresReceiptAndSurvivesContextEnd(t *testing.T) h.finished(result, store.TurnCancelled) next := h.message("next", "Continue after cancellation") result = h.run(context.Background(), next.TurnID) - env = h.read(proto.TypePromptRequest) + env = h.read(testExecutionRequest) var prompt proto.PromptRequestPayload _ = env.DecodePayload(&prompt) if prompt.AgentSessionID != "cancelled-native" { @@ -269,7 +284,7 @@ func TestExecutionCancellationRequiresReceiptAndSurvivesContextEnd(t *testing.T) first := h.message("first", "Run") ctx, cancel := context.WithCancel(context.Background()) result := h.run(ctx, first.TurnID) - h.read(proto.TypePromptRequest) + h.read(testExecutionRequest) cancel() done := h.finished(result, store.TurnFailed) var outcome execution.Result @@ -285,7 +300,7 @@ func TestExecutionFailureDoesNotBecomeSuccessOrReplay(t *testing.T) { h := newDispatchHarness(t) first := h.message("first", "Run") result := h.run(context.Background(), first.TurnID) - h.read(proto.TypePromptRequest) + h.read(testExecutionRequest) switch kind { case "disconnect": h.conn.Close() diff --git a/services/agents-api/internal/store/environment_expiry_dispatch_test.go b/services/agents-api/internal/store/environment_expiry_dispatch_test.go index 17cc9c360..fc2cfc462 100644 --- a/services/agents-api/internal/store/environment_expiry_dispatch_test.go +++ b/services/agents-api/internal/store/environment_expiry_dispatch_test.go @@ -11,7 +11,7 @@ import ( func enableEnvironmentExpiryDispatch(h *dispatchHarness) { h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{ Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, EnvironmentNone: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, - }}}}) + Preparation: true}}}}) } func TestWorkerEnvironmentExpiryAtFullExecutionCapacity(t *testing.T) { @@ -26,7 +26,7 @@ func TestWorkerEnvironmentExpiryAtFullExecutionCapacity(t *testing.T) { if _, err := worker.SubmitInputs(t.Context(), h.tenant, session.ID, key, []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"remain active"}`)}}); err != nil { t.Fatal(err) } - requests = append(requests, h.read(proto.TypePromptRequest)) + requests = append(requests, h.read(testExecutionRequest)) sessions = append(sessions, session) } tenant, due := newEnvironmentExpiryReservation(t, h.s) @@ -70,7 +70,7 @@ func TestWorkerEnvironmentExpirySkipsBusySessionAndAllowsDispatch(t *testing.T) t.Fatal(err) } waitEnvironmentExpiry(t, h.s, otherTenant, other) - request := h.read(proto.TypePromptRequest) + request := h.read(testExecutionRequest) if request.ID != receipt[0].TurnID { t.Fatal("unrelated dispatch mismatch", request.ID) } diff --git a/services/agents-api/internal/store/environment_worker_helpers_test.go b/services/agents-api/internal/store/environment_worker_helpers_test.go index 3687d4637..168df067a 100644 --- a/services/agents-api/internal/store/environment_worker_helpers_test.go +++ b/services/agents-api/internal/store/environment_worker_helpers_test.go @@ -63,6 +63,11 @@ func workerFrames(t *testing.T, runtimes ...*dispatchHarness) <-chan proto.Envel if h.conn.ReadJSON(&env) != nil { return } + var keep bool + env, keep = h.executionFrame(env) + if !keep { + continue + } select { case frames <- env: case <-t.Context().Done(): diff --git a/services/agents-api/internal/store/environment_worker_scan_test.go b/services/agents-api/internal/store/environment_worker_scan_test.go index 7627e29e0..bca895242 100644 --- a/services/agents-api/internal/store/environment_worker_scan_test.go +++ b/services/agents-api/internal/store/environment_worker_scan_test.go @@ -24,7 +24,7 @@ func TestWorkerEnvironmentRetriesNewlyReadyAtNextScan(t *testing.T) { _, stop := startEnvironmentExpiryWorker(t, h.d) // Dispatch starts only after selectWork has examined the pending input on // the same pass, while its exact Runtime is still incapable of preparation. - barrier := nextWorkerFrame(t, frames, proto.TypePromptRequest) + barrier := nextWorkerFrame(t, frames, testExecutionRequest) if barrier.ID != receipt.TurnID { t.Fatal("unexpected scan barrier") } @@ -70,7 +70,7 @@ func TestWorkerEnvironmentPaginationReachesReadyTail(t *testing.T) { receipt := h.message("barrier", "ordinary work") scanned := time.Now() _, stop := startEnvironmentExpiryWorker(t, h.d) - barrier := nextWorkerFrame(t, frames, proto.TypePromptRequest) + barrier := nextWorkerFrame(t, frames, testExecutionRequest) if barrier.ID != receipt.TurnID { t.Fatal("unexpected page barrier") } diff --git a/services/agents-api/internal/store/environment_worker_test.go b/services/agents-api/internal/store/environment_worker_test.go index 64615d1b4..313cf2cb6 100644 --- a/services/agents-api/internal/store/environment_worker_test.go +++ b/services/agents-api/internal/store/environment_worker_test.go @@ -38,7 +38,7 @@ func TestWorkerEnvironmentSharesCapacityThroughClaimAndCleanup(t *testing.T) { select { case frame := <-frames: switch frame.Type { - case proto.TypePromptRequest: + case testExecutionRequest: normal = append(normal, frame) case proto.TypeExecutionPrepare: preparing = append(preparing, frame) @@ -96,7 +96,7 @@ func TestWorkerEnvironmentSharesCapacityThroughClaimAndCleanup(t *testing.T) { t.Fatal("duplicate preparation release") } release = frame - case proto.TypePromptRequest: + case testExecutionRequest: if resumed.ID != "" { t.Fatal("cleanup freed more than one capacity slot") } @@ -140,7 +140,7 @@ func TestWorkerEnvironmentRetriesPendingWithoutExtendingDeadline(t *testing.T) { nextWorkerFrame(t, frames, proto.TypeExecutionRelease) h.session = publicSession(t, h, "unrelated") receipt := h.message("ordinary", "make progress after preparation failure") - request := nextWorkerFrame(t, frames, proto.TypePromptRequest) + request := nextWorkerFrame(t, frames, testExecutionRequest) if request.ID != receipt.TurnID { t.Fatal("preparation failure blocked ordinary work") } diff --git a/services/agents-api/internal/store/execution_events_test.go b/services/agents-api/internal/store/execution_events_test.go index ba1404bb0..31bef15ff 100644 --- a/services/agents-api/internal/store/execution_events_test.go +++ b/services/agents-api/internal/store/execution_events_test.go @@ -17,7 +17,7 @@ func TestExecutionPersistsLiveAndCancelledPartialOutput(t *testing.T) { ctx := context.Background() input := h.message("start", "Stream then cancel") result := h.run(ctx, input.TurnID) - h.read(proto.TypePromptRequest) + h.read(testExecutionRequest) h.write(input.TurnID, proto.TypeDelta, proto.DeltaPayload{Delta: "已输出", Sequence: 1}) h.write(input.TurnID, proto.TypeToolCall, proto.ToolCallPayload{ID: "tool-1", Name: "Bash", Stage: "before", Observation: &proto.ToolObservation{Kind: "command", Command: "pwd", Status: "in_progress"}}) h.write(input.TurnID, proto.TypeToolCall, proto.ToolCallPayload{ID: "tool-1", Name: "Bash", Stage: "after", Observation: &proto.ToolObservation{Kind: "command", Command: "pwd", Status: "completed"}}) @@ -79,7 +79,7 @@ func TestExecutionDoesNotCompleteAfterEventPersistenceFailure(t *testing.T) { ctx := context.Background() input := h.message("start", "Output beyond storage budget") result := h.run(ctx, input.TurnID) - h.read(proto.TypePromptRequest) + h.read(testExecutionRequest) _, pool := store.NewTestStore(t) defer pool.Close() if _, err := pool.Exec(ctx, "UPDATE turns SET event_bytes=33554432 WHERE id=$1", input.TurnID); err != nil { diff --git a/services/agents-api/internal/store/execution_messages_test.go b/services/agents-api/internal/store/execution_messages_test.go index c5d622d49..cbda2d87e 100644 --- a/services/agents-api/internal/store/execution_messages_test.go +++ b/services/agents-api/internal/store/execution_messages_test.go @@ -16,14 +16,14 @@ func TestExecutionNegotiatesAndPersistsMessageObservations(t *testing.T) { first := h.message("legacy", "legacy observation policy") result := h.run(ctx, first.TurnID) var request proto.PromptRequestPayload - env := h.read(proto.TypePromptRequest) + env := h.read(testExecutionRequest) _ = env.DecodePayload(&request) if request.ObserveMessages { t.Fatal("unadvertised observation capability requested") } h.write(first.TurnID, proto.TypeDone, proto.DonePayload{}) h.finished(result, store.TurnCompleted) - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, Resume: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, MessageItems: true, NativeSessionRecovery: true}}}}) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, Resume: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, MessageItems: true, NativeSessionRecovery: true, Preparation: true}}}}) deadline := time.Now().Add(3 * time.Second) for { peer, err := h.registry.LookupDevice(h.device.ID) @@ -41,7 +41,7 @@ func TestExecutionNegotiatesAndPersistsMessageObservations(t *testing.T) { } input := h.message("observed", "stream separate messages") result = h.run(ctx, input.TurnID) - env = h.read(proto.TypePromptRequest) + env = h.read(testExecutionRequest) _ = env.DecodePayload(&request) if !request.ObserveMessages { t.Fatal("advertised capability was not requested") diff --git a/services/agents-api/internal/store/execution_tools_test.go b/services/agents-api/internal/store/execution_tools_test.go index 607fb324a..347aec57b 100644 --- a/services/agents-api/internal/store/execution_tools_test.go +++ b/services/agents-api/internal/store/execution_tools_test.go @@ -16,7 +16,7 @@ func TestExecutionNegotiatesAndPersistsToolObservations(t *testing.T) { ctx := context.Background() input := h.message("observed", "run tools") result := h.run(ctx, input.TurnID) - env := h.read(proto.TypePromptRequest) + env := h.read(testExecutionRequest) var request proto.PromptRequestPayload _ = env.DecodePayload(&request) if !request.ObserveToolObservations || request.ObserveTools || request.ObserveMessages { diff --git a/services/agents-api/internal/store/executor_fixture_test.go b/services/agents-api/internal/store/executor_fixture_test.go new file mode 100644 index 000000000..ef8712387 --- /dev/null +++ b/services/agents-api/internal/store/executor_fixture_test.go @@ -0,0 +1,63 @@ +package store_test + +import ( + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// testExecutionRequest is a test projection of configuration plus a confirmed +// Start. It is never sent over the Runtime connection. Tests which exercise +// preparation failures consume the actual control frames directly. +const testExecutionRequest = "test_execution_request" + +type fixtureAdmission struct { + prepare proto.ExecutionPreparePayload + handle string + executor string +} + +func (h *dispatchHarness) executionFrame(env proto.Envelope) (proto.Envelope, bool) { + if h.admissions == nil { + h.admissions = make(map[string]fixtureAdmission) + } + switch env.Type { + case proto.TypeExecutionPrepare: + var prepare proto.ExecutionPreparePayload + if env.DecodePayload(&prepare) != nil { + h.t.Fatal("invalid execution preparation") + } + if prepare.Configuration.LocalEnvironment != nil || prepare.Configuration.WorkspaceReadOnly { + return env, true + } + if prepare.SessionID == "" || prepare.Configuration.RunID != "" || len(prepare.Configuration.Input) != 0 { + h.t.Fatal("preparation changed Session identity or submitted input early") + } + admission := fixtureAdmission{prepare: prepare, handle: uuid.NewString(), executor: "executor-" + prepare.SessionID} + h.admissions[env.ID] = admission + h.write(env.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: admission.handle, ExecutorID: admission.executor, Revision: 1, State: "ready"}) + return proto.Envelope{}, false + case proto.TypeExecutionStart: + admission, ok := h.admissions[env.ID] + if !ok { + return env, true + } + var start proto.ExecutionStartPayload + if env.DecodePayload(&start) != nil || start.Handle != admission.handle || start.ExecutorID != admission.executor || start.RunID == "" || start.Input.Validate() != nil { + h.t.Fatal("Start changed admission, Executor or input identity") + } + h.write(env.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: admission.handle, ExecutorID: admission.executor, Revision: 2, State: "started", RunID: start.RunID}) + request := admission.prepare.Configuration + request.RunID, request.ConversationID, request.Input = start.RunID, admission.prepare.SessionID, start.Input + projected, err := proto.NewEnvelope(testExecutionRequest, start.RunID, request) + if err != nil { + h.t.Fatal(err) + } + return projected, true + case proto.TypeExecutionRelease: + if _, ok := h.admissions[env.ID]; ok { + delete(h.admissions, env.ID) + return proto.Envelope{}, false + } + } + return env, true +} diff --git a/services/agents-api/internal/store/executor_recovery_test.go b/services/agents-api/internal/store/executor_recovery_test.go new file mode 100644 index 000000000..eba3602cb --- /dev/null +++ b/services/agents-api/internal/store/executor_recovery_test.go @@ -0,0 +1,58 @@ +package store_test + +import ( + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func readyExecutorAttempt(t *testing.T, h *dispatchHarness) (proto.Envelope, proto.ExecutionStartPayload) { + t.Helper() + prepare := h.read(proto.TypeExecutionPrepare) + var configuration proto.ExecutionPreparePayload + if prepare.DecodePayload(&configuration) != nil || configuration.SessionID != h.session.ID || len(configuration.Configuration.Input) != 0 { + t.Fatal("invalid Executor preparation identity") + } + handle, executor := uuid.NewString(), uuid.NewString() + h.write(prepare.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, ExecutorID: executor, Revision: 1, State: "ready"}) + frame := h.read(proto.TypeExecutionStart) + var start proto.ExecutionStartPayload + if frame.ID != prepare.ID || frame.DecodePayload(&start) != nil || start.Handle != handle || start.ExecutorID != executor { + t.Fatal("Start lost its admission or Executor identity") + } + return frame, start +} + +func TestExecutorRecoveryRetriesOnlyConfirmedUnsubmittedInput(t *testing.T) { + for _, code := range []string{"executor_unavailable", "executor_cleanup_unconfirmed", "start_failed"} { + t.Run(code, func(t *testing.T) { + h := newDispatchHarness(t) + receipt := h.message("input", "execute once") + result := h.run(t.Context(), receipt.TurnID) + first, start := readyExecutorAttempt(t, h) + h.write(first.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{State: "rejected", Operation: proto.TypeExecutionStart, ErrorCode: code}) + if code != "executor_unavailable" { + h.finished(result, store.TurnFailed) + return + } + next, replacement := readyExecutorAttempt(t, h) + if next.ID == first.ID || replacement.ExecutorID == start.ExecutorID || replacement.RunID != start.RunID || inputTextForTest(t, replacement.Input) != "execute once" { + t.Fatal("recovery changed input or reused retired control ownership") + } + h.write(next.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: replacement.Handle, ExecutorID: replacement.ExecutorID, Revision: 2, State: "started", RunID: replacement.RunID}) + h.write(replacement.RunID, proto.TypeDone, proto.DonePayload{Content: "once"}) + h.finished(result, store.TurnCompleted) + }) + } +} + +func TestExecutorReadinessRejectsChangedOwner(t *testing.T) { + h := newDispatchHarness(t) + receipt := h.message("input", "execute once") + result := h.run(t.Context(), receipt.TurnID) + frame, start := readyExecutorAttempt(t, h) + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: start.Handle, ExecutorID: uuid.NewString(), Revision: 2, State: "started", RunID: start.RunID}) + h.finished(result, store.TurnFailed) +} diff --git a/services/agents-api/internal/store/function_execution_test.go b/services/agents-api/internal/store/function_execution_test.go index 19416593d..0e138b18c 100644 --- a/services/agents-api/internal/store/function_execution_test.go +++ b/services/agents-api/internal/store/function_execution_test.go @@ -26,7 +26,7 @@ func newFunctionHarness(t *testing.T) *dispatchHarness { if err := h.s.BindSessionDevice(t.Context(), h.tenant, h.session.ID, h.device.ID); err != nil { t.Fatal(err) } - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, Resume: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, EnvironmentNone: true, FunctionTools: true, FunctionResultImages: true}}}}) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, Resume: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, EnvironmentNone: true, FunctionTools: true, FunctionResultImages: true, Preparation: true}}}}) deadline := time.Now().Add(time.Second) for { peer, _ := h.registry.LookupDevice(h.device.ID) @@ -64,7 +64,7 @@ func TestExecutionFunctionsWaitForEveryApplicationReceipt(t *testing.T) { input := h.message("start", "Run functions") result := h.run(t.Context(), input.TurnID) var prompt proto.PromptRequestPayload - _ = h.read(proto.TypePromptRequest).DecodePayload(&prompt) + _ = h.read(testExecutionRequest).DecodePayload(&prompt) if len(prompt.FunctionTools) != 1 || prompt.FunctionTools[0].Name != "lookup_ticket" { t.Fatal(prompt.FunctionTools) } @@ -107,7 +107,7 @@ func TestExecutionFunctionsWaitForEveryApplicationReceipt(t *testing.T) { functionState(t, h, 0) next := h.message("next", "Resume") result = h.run(t.Context(), next.TurnID) - _ = h.read(proto.TypePromptRequest).DecodePayload(&prompt) + _ = h.read(testExecutionRequest).DecodePayload(&prompt) if prompt.AgentSessionID != "native-functions" || len(prompt.FunctionTools) != 1 { t.Fatal(prompt) } @@ -121,7 +121,7 @@ func TestExecutionFunctionsCancellationAndUnconfirmedResults(t *testing.T) { h := newFunctionHarness(t) input := h.message("start", "Run") result := h.run(t.Context(), input.TurnID) - h.read(proto.TypePromptRequest) + h.read(testExecutionRequest) h.write(input.TurnID, proto.TypeFunctionCall, proto.FunctionCallPayload{CallID: "a", Name: "lookup_ticket", Arguments: json.RawMessage(`{}`)}) state := functionState(t, h, 1) id := state.RequiredActions[0].CallID @@ -178,7 +178,7 @@ func TestExecutionFunctionsRejectUndeclaredCallsAndPrematureDone(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() result := h.run(ctx, input.TurnID) - h.read(proto.TypePromptRequest) + h.read(testExecutionRequest) h.write(input.TurnID, proto.TypeFunctionCall, proto.FunctionCallPayload{CallID: "a", Name: name, Arguments: json.RawMessage(`{}`)}) h.write(input.TurnID, proto.TypeDone, proto.DonePayload{}) h.finished(result, store.TurnFailed) diff --git a/services/agents-api/internal/store/function_input_execution_test.go b/services/agents-api/internal/store/function_input_execution_test.go index 99e763047..a932a988c 100644 --- a/services/agents-api/internal/store/function_input_execution_test.go +++ b/services/agents-api/internal/store/function_input_execution_test.go @@ -14,7 +14,7 @@ func TestExecutionFunctionInputBatchStillSteersMessages(t *testing.T) { h := newFunctionHarness(t) input := h.message("start", "Run") running := h.run(t.Context(), input.TurnID) - h.read(proto.TypePromptRequest) + h.read(testExecutionRequest) h.write(input.TurnID, proto.TypeFunctionCall, proto.FunctionCallPayload{CallID: "a", Name: "lookup_ticket", Arguments: json.RawMessage(`{}`)}) state := functionState(t, h, 1) raw, _ := json.Marshal(store.FunctionResultInput{TurnID: input.TurnID, CallID: state.RequiredActions[0].CallID, Result: json.RawMessage(`{"success":true,"output":"answer"}`)}) diff --git a/services/agents-api/internal/store/function_worker_test.go b/services/agents-api/internal/store/function_worker_test.go index 3fed06915..f3965da77 100644 --- a/services/agents-api/internal/store/function_worker_test.go +++ b/services/agents-api/internal/store/function_worker_test.go @@ -16,7 +16,7 @@ import ( ) func TestWorkerWaitsForToolCapabilities(t *testing.T) { - for _, missing := range []string{"durable_input_receipts", "execution_controls", "function_tools", "tool_observations", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required"} { + for _, missing := range []string{"preparation", "durable_input_receipts", "execution_controls", "function_tools", "tool_observations", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required"} { for _, prebound := range []bool{false, true} { t.Run(missing+"/"+map[bool]string{false: "select", true: "bound"}[prebound], func(t *testing.T) { h := newFunctionHarness(t) @@ -44,7 +44,7 @@ func TestWorkerWaitsForToolCapabilities(t *testing.T) { t.Fatal(err) } } - caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: missing != "durable_input_receipts", EnvironmentNone: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: missing != "execution_controls", SubagentControl: true, ToolObservations: missing != "tool_observations", MCPHTTPTools: missing != "mcp_http_tools", MCPHTTPRequired: missing != "mcp_http_required", MCPHTTPBearerAuth: missing != "mcp_http_bearer_auth", FunctionTools: missing != "function_tools" && !isMCP} + caps := proto.AgentKindCapabilities{Preparation: missing != "preparation", Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: missing != "durable_input_receipts", EnvironmentNone: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: missing != "execution_controls", SubagentControl: true, ToolObservations: missing != "tool_observations", MCPHTTPTools: missing != "mcp_http_tools", MCPHTTPRequired: missing != "mcp_http_required", MCPHTTPBearerAuth: missing != "mcp_http_bearer_auth", FunctionTools: missing != "function_tools" && !isMCP} heartbeat := func() { h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: caps}}}) } @@ -53,7 +53,7 @@ func TestWorkerWaitsForToolCapabilities(t *testing.T) { for { peer, _ := h.registry.LookupDevice(h.device.ID) info, _, _ := peer.AgentKindStatus("codex") - if info.Capabilities.ExecutionControls == caps.ExecutionControls && info.Capabilities.FunctionTools == caps.FunctionTools && info.Capabilities.ToolObservations == caps.ToolObservations && info.Capabilities.MCPHTTPTools == caps.MCPHTTPTools && info.Capabilities.MCPHTTPBearerAuth == caps.MCPHTTPBearerAuth && info.Capabilities.MCPHTTPRequired == caps.MCPHTTPRequired { + if info.Capabilities.Preparation == caps.Preparation && info.Capabilities.DurableInputReceipts == caps.DurableInputReceipts && info.Capabilities.ExecutionControls == caps.ExecutionControls && info.Capabilities.FunctionTools == caps.FunctionTools && info.Capabilities.ToolObservations == caps.ToolObservations && info.Capabilities.MCPHTTPTools == caps.MCPHTTPTools && info.Capabilities.MCPHTTPBearerAuth == caps.MCPHTTPBearerAuth && info.Capabilities.MCPHTTPRequired == caps.MCPHTTPRequired { break } if time.Now().After(deadline) { @@ -88,10 +88,10 @@ func TestWorkerWaitsForToolCapabilities(t *testing.T) { t.Fatal("bound an incapable device", err) } } - caps.DurableInputReceipts, caps.ExecutionControls, caps.ToolObservations, caps.MCPHTTPTools = true, true, true, true + caps.Preparation, caps.DurableInputReceipts, caps.ExecutionControls, caps.ToolObservations, caps.MCPHTTPTools = true, true, true, true, true caps.MCPHTTPBearerAuth, caps.FunctionTools, caps.MCPHTTPRequired = true, !isMCP, true heartbeat() - request := h.read(proto.TypePromptRequest) + request := h.read(testExecutionRequest) var prompt proto.PromptRequestPayload if request.DecodePayload(&prompt) != nil { t.Fatal("invalid prompt") diff --git a/services/agents-api/internal/store/harness_onboarding_test.go b/services/agents-api/internal/store/harness_onboarding_test.go index 9b723d412..c3655ddde 100644 --- a/services/agents-api/internal/store/harness_onboarding_test.go +++ b/services/agents-api/internal/store/harness_onboarding_test.go @@ -221,19 +221,42 @@ func startOnboardingPeer(t *testing.T, h *dispatchHarness) (<-chan proto.PromptR }() go func() { enc := json.NewEncoder(in) + preparations := make(map[string]proto.ExecutionPreparePayload) for { var e proto.Envelope if err := h.conn.ReadJSON(&e); err != nil { down <- err return } - if e.Type == proto.TypePromptRequest { - var p proto.PromptRequestPayload + if e.Type == proto.TypeExecutionPrepare { + var p proto.ExecutionPreparePayload if err := e.DecodePayload(&p); err != nil { down <- err return } - started <- p + if p.SessionID == "" || p.Configuration.RunID != "" || len(p.Configuration.Input) != 0 { + down <- fmt.Errorf("fixture preparation submitted input or lost Session identity") + return + } + preparations[e.ID] = p + } + if e.Type == proto.TypeExecutionStart { + var start proto.ExecutionStartPayload + if err := e.DecodePayload(&start); err != nil { + down <- err + return + } + p, ok := preparations[e.ID] + if !ok || start.ExecutorID == "" || start.Handle == "" { + down <- fmt.Errorf("fixture Start lacks prepared Executor ownership") + return + } + request := p.Configuration + request.RunID, request.ConversationID, request.Input = start.RunID, p.SessionID, start.Input + started <- request + } + if e.Type == proto.TypeExecutionRelease { + delete(preparations, e.ID) } if err := enc.Encode(e); err != nil { down <- err diff --git a/services/agents-api/internal/store/public_execution_test.go b/services/agents-api/internal/store/public_execution_test.go index 5f7adea85..6d9b2c28b 100644 --- a/services/agents-api/internal/store/public_execution_test.go +++ b/services/agents-api/internal/store/public_execution_test.go @@ -23,7 +23,7 @@ func publicSession(t *testing.T, h *dispatchHarness, key string) store.Session { func TestExecutionWorkerAdmissionBindingAndRecovery(t *testing.T) { h := newDispatchHarness(t) - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, EnvironmentNone: true}}}}) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, EnvironmentNone: true, Preparation: true}}}}) h.session = publicSession(t, h, "public") ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -58,7 +58,7 @@ func TestExecutionWorkerAdmissionBindingAndRecovery(t *testing.T) { if err != nil || !retry[0].Replayed || retry[0].TurnID != receipts[0].TurnID { t.Fatal(retry, err) } - request := h.read(proto.TypePromptRequest) + request := h.read(testExecutionRequest) var prompt proto.PromptRequestPayload if err := request.DecodePayload(&prompt); err != nil { t.Fatal(err) @@ -84,7 +84,7 @@ func TestExecutionWorkerAdmissionBindingAndRecovery(t *testing.T) { if err != nil { t.Fatal(err) } - request = h.read(proto.TypePromptRequest) + request = h.read(testExecutionRequest) _ = request.DecodePayload(&prompt) if prompt.AgentSessionID != "worker-native" { t.Fatal(prompt) diff --git a/services/agents-api/internal/store/session_deletion_execution_test.go b/services/agents-api/internal/store/session_deletion_execution_test.go index 11ef5efa6..d33489c8a 100644 --- a/services/agents-api/internal/store/session_deletion_execution_test.go +++ b/services/agents-api/internal/store/session_deletion_execution_test.go @@ -31,7 +31,7 @@ func TestDeletedSessionWaitingTurnSettlesWithoutStoppingWorker(t *testing.T) { } }() input := h.message("start", "Run") - h.read(proto.TypePromptRequest) + h.read(testExecutionRequest) h.write(input.TurnID, proto.TypeFunctionCall, proto.FunctionCallPayload{CallID: "pending", Name: "lookup_ticket", Arguments: json.RawMessage(`{}`)}) state := functionState(t, h, 1) if err := h.s.DeleteSession(ctx, h.tenant, h.session.ID); !errors.Is(err, store.ErrSessionNotIdle) { @@ -63,7 +63,7 @@ func TestDeletedSessionWaitingTurnSettlesWithoutStoppingWorker(t *testing.T) { } h.session = publicSession(t, h, "unrelated") next := h.message("next", "Unrelated work") - h.read(proto.TypePromptRequest) + h.read(testExecutionRequest) h.write(next.TurnID, proto.TypeDone, proto.DonePayload{Content: "unaffected"}) waitTurn(t, h, next.TurnID, store.TurnCompleted) } @@ -120,7 +120,7 @@ func TestWaitingSessionCancelsThenDeletesThroughWorker(t *testing.T) { } }() input := h.message("start", "Run") - h.read(proto.TypePromptRequest) + h.read(testExecutionRequest) h.write(input.TurnID, proto.TypeFunctionCall, proto.FunctionCallPayload{CallID: "pending", Name: "lookup_ticket", Arguments: json.RawMessage(`{}`)}) state := functionState(t, h, 1) if err := h.s.DeleteSession(ctx, h.tenant, h.session.ID); !errors.Is(err, store.ErrSessionNotIdle) { @@ -165,7 +165,7 @@ func TestWaitingSessionCancelsThenDeletesThroughWorker(t *testing.T) { } h.session = publicSession(t, h, "unrelated") next := h.message("next", "Unrelated work") - h.read(proto.TypePromptRequest) + h.read(testExecutionRequest) h.write(next.TurnID, proto.TypeDone, proto.DonePayload{Content: "unaffected"}) waitTurn(t, h, next.TurnID, store.TurnCompleted) } diff --git a/services/agents-api/internal/store/steering_receipts_test.go b/services/agents-api/internal/store/steering_receipts_test.go index 9b330c3d0..df620a347 100644 --- a/services/agents-api/internal/store/steering_receipts_test.go +++ b/services/agents-api/internal/store/steering_receipts_test.go @@ -19,7 +19,7 @@ func TestExecutionDurableInputReceiptLifetime(t *testing.T) { defer cancel() first := h.message("first", "initial") result := h.run(ctx, first.TurnID) - h.read(proto.TypePromptRequest) + h.read(testExecutionRequest) extra := h.message("extra", "additional") var input proto.PromptSteerPayload if err := h.read(proto.TypePromptSteer).DecodePayload(&input); err != nil || !input.DurableReceipt { diff --git a/services/agents-api/internal/store/structured_output_dispatch_test.go b/services/agents-api/internal/store/structured_output_dispatch_test.go index 91f2c1109..78d26936e 100644 --- a/services/agents-api/internal/store/structured_output_dispatch_test.go +++ b/services/agents-api/internal/store/structured_output_dispatch_test.go @@ -28,7 +28,7 @@ func TestStructuredOutputDispatchRechecksOperationQualification(t *testing.T) { if err = h.s.BindSessionDevice(t.Context(), h.tenant, h.session.ID, h.device.ID); err != nil { t.Fatal(err) } - caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, ExecutionControls: true, EnvironmentNone: true, SubagentControl: true, ToolObservations: true, StructuredOutput: true, MessageItems: true} + caps := proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, ExecutionControls: true, EnvironmentNone: true, SubagentControl: true, ToolObservations: true, StructuredOutput: true, MessageItems: true, Preparation: true} h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "fixture_harness", Available: true, Capabilities: caps}}}) peer, _ := h.registry.LookupDevice(h.device.ID) for deadline := time.Now().Add(3 * time.Second); ; { diff --git a/services/agents-api/internal/store/subagent_dispatch_test.go b/services/agents-api/internal/store/subagent_dispatch_test.go index a032b0b3a..146b8510b 100644 --- a/services/agents-api/internal/store/subagent_dispatch_test.go +++ b/services/agents-api/internal/store/subagent_dispatch_test.go @@ -36,7 +36,7 @@ func TestSubagentIdentityUsesLeasedDispatchJournal(t *testing.T) { input := h.message("first", "root message") running := h.run(ctx, input.TurnID) var request proto.PromptRequestPayload - if err = h.read(proto.TypePromptRequest).DecodePayload(&request); err != nil || request.ObserveSubagentIdentities != enabled || request.MaxConcurrentSubagents == nil || *request.MaxConcurrentSubagents != 3 { + if err = h.read(testExecutionRequest).DecodePayload(&request); err != nil || request.ObserveSubagentIdentities != enabled || request.MaxConcurrentSubagents == nil || *request.MaxConcurrentSubagents != 3 { t.Fatal("private observation policy not carried", err) } identity := proto.SubagentIdentityPayload{NativeID: "child", ParentNativeID: "root", NativeCreatedAt: 100, ParentTurnID: "native-turn", SourceItemID: "spawn-item"} diff --git a/services/agents-api/internal/store/worker_input_race_test.go b/services/agents-api/internal/store/worker_input_race_test.go index 8e87c1277..d983e037b 100644 --- a/services/agents-api/internal/store/worker_input_race_test.go +++ b/services/agents-api/internal/store/worker_input_race_test.go @@ -100,7 +100,7 @@ func TestWorkerInputReadSkipsConcurrentlyCancelledCandidate(t *testing.T) { t.Fatal("a stale candidate terminated the global Worker", err) case <-time.After(650 * time.Millisecond): } - frame := h.read(proto.TypePromptRequest) + frame := h.read(testExecutionRequest) if frame.ID != healthy.TurnID { t.Fatal("cancelled candidate reached the Runtime", frame.ID) } diff --git a/services/agents-api/internal/store/worker_lease_loss_test.go b/services/agents-api/internal/store/worker_lease_loss_test.go index d54918a11..f1dbf42cc 100644 --- a/services/agents-api/internal/store/worker_lease_loss_test.go +++ b/services/agents-api/internal/store/worker_lease_loss_test.go @@ -14,7 +14,7 @@ import ( func TestWorkerLeaseLossLeavesUncertainWorkForSuccessor(t *testing.T) { h := newDispatchHarness(t) _, pool := store.NewTestStore(t) - h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, EnvironmentNone: true}}}}) + h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, EnvironmentNone: true, Preparation: true}}}}) h.session = publicSession(t, h, "active") queued := publicSession(t, h, "queued") worker, err := execution.StartWorker(t.Context(), h.d) @@ -37,7 +37,7 @@ func TestWorkerLeaseLossLeavesUncertainWorkForSuccessor(t *testing.T) { if err != nil { t.Fatal(err) } - request := h.read(proto.TypePromptRequest) + request := h.read(testExecutionRequest) if request.ID != receipts[0].TurnID { t.Fatal("unexpected dispatch", request.ID) } diff --git a/services/agents-api/internal/store/worker_wakeup_test.go b/services/agents-api/internal/store/worker_wakeup_test.go new file mode 100644 index 000000000..d53516f3d --- /dev/null +++ b/services/agents-api/internal/store/worker_wakeup_test.go @@ -0,0 +1,141 @@ +package store_test + +import ( + "context" + "encoding/json" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type schedulerQueryOrder struct { + armed atomic.Bool + first chan string +} + +func (trace *schedulerQueryOrder) TraceQueryStart(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryStartData) context.Context { + for _, name := range []string{"ListExecutionWork", "ListDueEnvironmentInputs"} { + if strings.HasPrefix(data.SQL, "-- name: "+name+" ") && trace.armed.CompareAndSwap(true, false) { + trace.first <- name + } + } + return ctx +} +func (*schedulerQueryOrder) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {} + +func TestWorkerSchedulerCommittedAdmissionWakesBeforeMaintenance(t *testing.T) { + for _, operation := range []string{"submit", "create", "stream", "rejected"} { + t.Run(operation, func(t *testing.T) { + h := newDispatchHarness(t) + enableWorkerEnvironment(t, h) + h.session = publicSession(t, h, "wakeup") + _, pool := store.NewTestStore(t) + trace := &schedulerQueryOrder{first: make(chan string, 1)} + config := pool.Config() + config.ConnConfig.Tracer = trace + instrumented, err := pgxpool.NewWithConfig(t.Context(), config) + if err != nil { + t.Fatal(err) + } + defer instrumented.Close() + h.d.Store = store.NewWithCredentialCipher(instrumented, store.FixtureCipher()) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + started := false + defer func() { + cancel() + if !started { + go func() { done <- worker.Run(ctx) }() + } + select { + case <-done: + case <-time.After(10 * time.Second): + t.Error("worker did not stop") + } + }() + input := []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"wake"}`)}} + switch operation { + case "submit": + _, err = worker.SubmitInputs(ctx, h.tenant, h.session.ID, "wake", input) + case "create", "stream": + creation := store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "wake", Configuration: h.session.Configuration, InitialInputs: input} + if operation == "create" { + _, err = worker.CreateSession(ctx, h.tenant, creation) + } else { + _, err = worker.CreateSessionStream(ctx, h.tenant, creation) + } + case "rejected": + h.message("wake", "different") + _, err = worker.SubmitInputs(ctx, h.tenant, h.session.ID, "wake", input) + if err == nil { + t.Fatal("changed retry was accepted") + } + err = nil + } + if err != nil { + t.Fatal(err) + } + trace.armed.Store(true) + started = true + go func() { done <- worker.Run(ctx) }() + // Maintenance always starts with expiry. A committed admission must + // select work first, without depending on a short timing threshold. + select { + case first := <-trace.first: + want := "ListExecutionWork" + if operation == "rejected" { + want = "ListDueEnvironmentInputs" + } + if first != want { + t.Fatalf("first scheduler query = %s, want %s", first, want) + } + case <-time.After(5 * time.Second): + t.Fatal("worker did not schedule") + } + }) + } +} + +func TestWorkerSchedulerHintBypassesEnvironmentScanThrottle(t *testing.T) { + h := newDispatchHarnessForSession(t, []byte(`{"agent":{"model":"test-model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"}}`), false) + enableWorkerEnvironment(t, h) + frames := workerFrames(t, h) + worker, stop := startEnvironmentExpiryWorker(t, h.d) + defer stop() + awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "initial empty scheduler scan", func() bool { + return worker.MetricsSnapshot().Scheduler.LastRunAt != nil + }) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + admitted := make(chan error, 1) + started := time.Now() + go func() { + _, err := worker.SubmitInputs(ctx, h.tenant, h.session.ID, "wake", []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"wake"}`)}}) + admitted <- err + }() + prepare := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) + if elapsed := time.Since(started); elapsed >= 750*time.Millisecond { + t.Fatalf("committed input waited for the environment scan interval: %s", elapsed) + } + handle := acknowledgePreparation(h, prepare.ID) + h.write(prepare.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: handle, Revision: 2, State: "failed"}) + nextWorkerFrame(t, frames, proto.TypeExecutionRelease) + cancel() + select { + case <-admitted: + case <-time.After(5 * time.Second): + t.Fatal("detached admission waiter did not stop") + } +} diff --git a/services/agents-api/tests/official_user_runtime.py b/services/agents-api/tests/official_user_runtime.py index c006fce52..43b3b7631 100644 --- a/services/agents-api/tests/official_user_runtime.py +++ b/services/agents-api/tests/official_user_runtime.py @@ -43,7 +43,9 @@ def expired(signum, frame): signal.signal(signal.SIGALRM, previous) -def run_acceptance(client, foreign, http, session, runtime, evidence_path, secret_markers=()): +def run_acceptance(client, foreign, http, session, runtime, evidence_path, secret_markers=(), + expected_environment_type="self_hosted", + protected_credential_path="/home/runtime/.oac/daemon/executor-key.json", turn_observer=None): """Return a safe report after real execution; raise on any failed assertion.""" assert threading.current_thread() is threading.main_thread(), "Run acceptance on the main thread" pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) @@ -71,7 +73,7 @@ def run_acceptance(client, foreign, http, session, runtime, evidence_path, secre outputs = {"/workspace/outputs/a.bin": bytes(range(256)), "/workspace/outputs/b.txt": ("native-user-runtime-" + nonce + "\n").encode()} artifacts = {} - private_paths = ["/home/runtime/.oac/daemon/executor-key.json", + private_paths = [protected_credential_path, "/home/runtime/.oac/daemon/.user-runtime-isolation-canary", "/environment/staging/.user-runtime-isolation-canary"] @@ -149,6 +151,7 @@ def snapshot_items(name): def run_turn(text, number, cancellation=False): observed, stop, requested = [], threading.Event(), threading.Event() + submitted_at, first_text_at, terminal_at = None, None, None cancel_key = nonce + "-cancel" def cancel_running(): @@ -165,6 +168,7 @@ def cancel_running(): raise AssertionError("Cancellation ended before observing native effects") def consume(stream, journal, cancelling=None): + nonlocal first_text_at, terminal_at for event in stream: value = event.to_dict() journal.write(json.dumps(redact(value)) + "\n") @@ -172,6 +176,10 @@ def consume(stream, journal, cancelling=None): safe(value) observed.append(value) kind = value["type"] + if kind == "agent.session.turn.output_text.delta" and first_text_at is None and value.get("delta"): + first_text_at = time.monotonic() + if kind in ("agent.session.turn.completed", "agent.session.turn.cancelled", "agent.session.turn.failed"): + terminal_at = time.monotonic() assert kind not in ("error", "agent.session.failed", "agent.session.turn.failed"), "Native execution failed" if cancelling is not None and cancelling.done(): cancelling.result() @@ -194,6 +202,7 @@ def consume(stream, journal, cancelling=None): with _prompt_deadline(), path.open("w") as journal: path.chmod(0o600) with sessions.events.stream(sid, timeout=240) as stream: + submitted_at = time.monotonic() sessions.events.create(sid, events=[{"type": "agent.session.input.message", "input": [ {"role": "user", "content": [{"type": "input_text", "text": text}]}]}], idempotency_key=nonce + "-turn-" + str(number)) if cancellation: @@ -216,7 +225,11 @@ def consume(stream, journal, cancelling=None): assert current, "Native Turn has no public Items" if not cancellation: assert all(item.get("status") not in ("failed", "incomplete", "in_progress") for item in current), "Completed Turn contains failed native work" - report["turns"].append({"id": turn.id, "status": turn.status, "event_count": len(observed)}) + report["turns"].append({"id": turn.id, "status": turn.status, "event_count": len(observed), + "input_to_first_text_ms": round((first_text_at - submitted_at) * 1000, 3) if first_text_at is not None else None, + "input_to_terminal_ms": round((terminal_at - submitted_at) * 1000, 3) if terminal_at is not None else None}) + if turn_observer is not None: + report["turns"][-1]["runtime_observation"] = safe(turn_observer(number, turn.id)) return turn, current except BaseException: # No model request is retried; an already started tool is cancelled for cleanup. @@ -236,7 +249,8 @@ def check_artifacts(): safe([item.to_dict() for item in values]) try: - assert session.environment.type == "self_hosted" and session.environment.workspace_directory == "/workspace" + assert expected_environment_type in ("self_hosted", "openai_hosted") + assert session.environment.type == expected_environment_type and session.environment.workspace_directory == "/workspace" assert client.beta.agents.environments.retrieve(eid).status == "connected", "Runtime is not connected" assert not list(sessions.turns.list(sid)) and not list(sessions.items.list(sid)), "Use an unused Session" assert not list(sessions.artifacts.list(sid)), "Use a Session without existing Artifacts" @@ -299,6 +313,12 @@ def isolation_proof(phase): pass else: raise AssertionError("Foreign tenant accessed SDK Items") + warm, items = run_turn("Return the entire exact conversation-only remember- token from our previous turn, including its prefix. " + "Do not use tools, read files, or repeat any earlier command.", 2) + assert memory in answer(items), "Warm continuation lost native conversation history" + assert runtime.read(prefix + "-published") == b"published\n", "Warm continuation repeated side effects" + check_artifacts() + report["checks"].append("ordinary_warm_turn_preserves_history_and_effect_counts") committed = snapshot_items("before-restart") report["checks"].append("native_outputs_files_artifacts_items_and_tenant_isolation") runtime.restart() @@ -307,7 +327,7 @@ def isolation_proof(phase): assert snapshot_items("after-restart") == committed, "Restart changed committed Items" second, items = run_turn("Run exactly `python3 " + prefix + "-recover.py` once with your native shell tool; do not edit or retry it. " "Then return the entire exact conversation-only remember- token from our previous turn, including its prefix. " - "Do not look for the token in files or rerun any earlier command.", 2) + "Do not look for the token in files or rerun any earlier command.", 3) assert any(item.get("type") == "command_execution" and item.get("status") == "completed" and prefix + "-recover.py" in item.get("command", "") for item in items), "Missing recovered native isolation command" isolation_proof("recovered") assert memory in answer(items), "Cold continuation lost native conversation history" @@ -316,18 +336,18 @@ def isolation_proof(phase): check_artifacts() report["checks"].append("runtime_and_core_restart_preserve_history_and_outputs") third, items = run_turn("Use your native shell tool to run exactly `python3 " + prefix + "-hold.py` once and wait in the foreground. " - "It intentionally runs until cancelled. Do not background, delegate, retry, edit the script, or restart the command.", 3, cancellation=True) + "It intentionally runs until cancelled. Do not background, delegate, retry, edit the script, or restart the command.", 4, cancellation=True) commands = [item for item in items if item.get("type") == "command_execution" and prefix + "-hold.py" in item.get("command", "")] assert len(commands) == 1 and commands[0].get("status") in ("failed", "incomplete"), "Cancelled command lost its native outcome" stopped = runtime.read(ticks) time.sleep(2) assert runtime.read(ticks) == stopped and runtime.read(starts) == b"started\n", "Effects continued after cancellation settled" sessions.events.create(sid, events=[{"type": "agent.session.input.cancel"}], idempotency_key=nonce + "-cancel") - assert len(list(sessions.turns.list(sid))) == 3, "Duplicate cancellation created work" + assert len(list(sessions.turns.list(sid))) == 4, "Duplicate cancellation created work" check_artifacts() fourth, items = run_turn("Continue after the cancelled command. Never restart it or rerun the publish script. " "Run exactly `python3 " + prefix + "-resume.py` once with your native shell tool, without retries. " - "Then return the entire original conversation-only remember- token, including its prefix.", 4) + "Then return the entire original conversation-only remember- token, including its prefix.", 5) assert any(item.get("type") == "command_execution" and item.get("status") == "completed" and prefix + "-resume.py" in item.get("command", "") for item in items), "Missing resumed native command observation" assert memory in answer(items) and runtime.read(prefix + "-resumed") == b"resumed\n", "Post-cancel continuation failed" assert runtime.read(starts) == b"started\n" and runtime.read(ticks) == stopped, "Continuation repeated cancelled effects" From cd6a32c3de31d26b73ab92d21ba2aa7b067ba226 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 15:02:06 +0000 Subject: [PATCH 10/78] Remove remaining historical upgrade instructions from contracts --- contracts/agents-api/README.md | 8 +-- contracts/agents-api/harness-selection.md | 11 ++-- contracts/agents-api/model-execution.md | 32 +++++------ contracts/agents-api/sandbox-deployment.md | 55 +++++++------------ services/agents-api/HOSTED-SANDBOX-MANAGER.md | 34 +++++------- 5 files changed, 58 insertions(+), 82 deletions(-) diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index cbcf7e39c..4c48e7b3b 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -586,9 +586,9 @@ tool-output deltas remain unsupported. Tool output is visible to the Session's authenticated tenant and may include the command's or tool's own diagnostic text. Reads use the durable index without reconstructing native journals. Existing -indexed history is preserved. Migration 15 requires old unindexed archives to be -prepared by release `906069e` before upgrade; see the -[upgrade procedure](../../services/agents-api/README.md#upgrading-archived-item-history). +indexed history is preserved. Migration 15 refuses unindexed historical Turns. +Historical database conversion is unsupported; preserve the old data and install +separately. See [historical Item storage](../../services/agents-api/README.md#historical-item-storage). The retired archive format could not recover unrecorded message boundaries or outcomes; those limitations remain in already indexed historical Items. Other native variants, full reasoning coverage and Items mutation remain gaps. @@ -603,7 +603,7 @@ Pagination orders by first-observation timestamp, then the Item's immutable Session position and public ID. New Items retain observation order even when timestamps match. The index also stores a zero-based output index per Turn for streaming; inputs do not consume it. Updates and retries do not move Items -or change output indexes. Existing indexed history retains its pre-upgrade +or change output indexes. Existing indexed history retains its recorded deterministic order rather than guessing an unavailable original source order. ### No-environment execution diff --git a/contracts/agents-api/harness-selection.md b/contracts/agents-api/harness-selection.md index 341f0ecd3..33a0d0ce8 100644 --- a/contracts/agents-api/harness-selection.md +++ b/contracts/agents-api/harness-selection.md @@ -55,10 +55,13 @@ another harness does not choose another image or backend; the selected Runtime image must contain and qualify each enabled harness. Capability checks still apply. -Node resource/Runtime edits still require no reset and verified zero held resources. -E2B same-team changes apply online with immutable per-allocation generations. Changing provider requires explicit durable reset, confirmed -cleanup and a new setup at the resulting generation. Existing Sessions never move -providers. See the [deployment procedure](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset). +Same-provider node resource/Runtime edits advance the target online without an +execution drain or reenrollment. Version 2 nodes prepare independently; retained +allocations keep their generation, and a qualified older serving generation can +still accept new Sessions. E2B same-team changes also apply online with immutable +per-allocation generations. Changing provider requires explicit durable reset, +confirmed cleanup and a new setup at the resulting generation. Existing Sessions +never move providers. See the [deployment procedure](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset). Each engine has at most one deployment default model provider, stored encrypted in PostgreSQL and managed with the Core key in Web or through diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index 87d4ab7b1..244d542a5 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -158,25 +158,19 @@ default never reaches existing Sessions, so a Session's first and later Turns al use the same provider. The execution-configuration read shows the frozen safe view with source `deployment`. -The former `AGENTS_API_EXECUTION_OPTIONS_FILE` is retired: setting it stops Core at -startup with the replacement named; remove it and set the deployment defaults -in Web (System) or with `PUT /core/v1/harnesses/{harness}/model-provider`. Only its provider identity (endpoint, key, -protocol and MiniMax Code limits) has a home in the deployment default; its other -native options (headers, query parameters, environment, MCP servers, feature and -permission settings) are dropped. Sessions frozen from that file keep their -complete private snapshot. Historical `openai_hosted` and `self_hosted` Sessions -created without any snapshot cannot start new work: message input returns 400 -`model_provider_required`, while cancellation and history reads keep working. -Input they reserved before the upgrade settles as failed with that reason, and the -Session reports it, instead of waiting for its deadline. A retry of a Session that -carried its own provider key, first sent before this release, conflicts once after -the upgrade, because its retry hash now holds a keyed fingerprint instead of the -key. Key-bearing retries likewise conflict after a credential key change, which is -not supported anyway. -Recreate them with a bundle. Run `deploy/install/model_provider_sessions.py` -against an installation before upgrading to count them; it only reads. Historical -`none` Sessions that relied on the retired options file now run with the device's -own environment, and the check does not count them. +The former `AGENTS_API_EXECUTION_OPTIONS_FILE` is rejected at startup. Configure +a fresh installation through Web (System) or +`PUT /core/v1/harnesses/{harness}/model-provider`; deployment defaults contain only +provider identity (endpoint, key, protocol and MiniMax Code limits). They do not +accept native headers, query parameters, environment, MCP servers, feature or +permission settings. + +Historical installations and Session snapshots have no supported conversion or +cross-version continuation procedure. Preserve their database, credentials and +history, and install the current release separately. Do not run a historical +pre-upgrade utility to make old Sessions eligible for the current release. Current +hosted and self-hosted Sessions require a provider bundle at creation, and credential +key replacement is unsupported. Parsar manages its own workspace catalog and encrypted keys, sends this extension only on the first Core Session request, and retains a private encrypted snapshot diff --git a/contracts/agents-api/sandbox-deployment.md b/contracts/agents-api/sandbox-deployment.md index d7ced7fd4..b22233ea9 100644 --- a/contracts/agents-api/sandbox-deployment.md +++ b/contracts/agents-api/sandbox-deployment.md @@ -433,7 +433,7 @@ fields from the administrator node routes; runtime fields from the | Runtime observation `lifecycle_state: sleeping` | Never | Never | While suspended | | Runtime history CPU | Mean of the utilization ratios E2B reported in each bucket | Derived from cumulative CPU time | Derived from cumulative CPU time | -## Failure and upgrade boundaries +## Failure and version boundaries Malformed selections return 400; validated configuration diagnostics use `invalid_sandbox_configuration`. Stale generation returns 409 `sandbox_generation_stale` with safe `current_generation`; @@ -459,53 +459,38 @@ fixed `diagnostic` code: `docker_unavailable`, `docker_limits_unsupported`, `provider_unavailable`. It is absent while the provider is ready. The node classifies the first failed readiness check and sends only the code; Core stores any other value as `provider_unavailable` and never stores or returns probe error -text or host paths. Older nodes remain compatible, but an older Core rejects the -new codes, so upgrade Core first. See +text or host paths. Core and nodes must use the same distribution; unknown-code +handling does not establish cross-version compatibility. See [Node readiness diagnostics](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#node-readiness-diagnostics) for causes, precedence and operator actions. Core rejects `AGENTS_API_MANAGED_RUNTIMES_FILE`. A node provider file remains an installed copy of the database selection, not a Core startup configuration source. -For an older file-managed deployment, use the previous release and original -backend to settle work and confirm cleanup before retiring its file configuration. -Preserve identities, provider receipts, history and storage. This version does not -automatically adopt the old database or delete its resources; removing an -environment variable alone does not complete that migration. - -A Web-managed Docker or microsandbox selection saved before specifications has -the empty migration default. Core loads it for draining only. GET omits -`specification` and `specification_digest`. Fresh hosted sandboxes are refused -until a complete configuration is saved. Enrollment tokens return 409 -`sandbox_deployment_conflict`; node configuration reads and enrollment are refused. -Retained nodes without a recorded digest or generation authenticate while the -deployment is in this state, but those nodes use the removed `/core/v1/sandbox` -node paths. They can reach only a Core that has this drain mode (pull request -#114) and still serves those paths. No such release is published: build one from -main commit `7b66be236a627246c85658722314285e6b39d9b8`, or another commit with -#114 but without `/api/v1/sandbox-node`. Run the maintenance and archive steps there. On this -release, reset that drained selection before POSTing its complete specification; -reset completion retires those nodes. See the -[operator upgrade notes](../../docs/getting-started/operations.md#data-and-upgrades). +Historical file-managed deployments and selections without a complete specification +are unsupported. Preserve their database, identities, provider receipts, Runtime +resources and history; install the current release separately. Do not clear state, +run a historical service to convert it, or treat removal of an environment variable +as a transfer of ownership. See the +[installation version policy](../../docs/getting-started/operations.md#installation-version-policy). + +Landed migrations and their refusal conditions remain historical schema evidence. +They do not establish an operator upgrade, downgrade or conversion procedure. +Fresh database initialization uses the ordinary migration runner. Unit tests, database tests and provider inspection are separate from live execution acceptance. This contract does not assert that every resource profile, provider deployment or host-reboot recovery path has been qualified. -### OpenAgentCore Runtime rename +### Current Runtime identity The immutable microsandbox reference is `oac-runtime@sha256:<64 lowercase hex>`. -Before upgrading from the former names, drain the deployment with the previous -release and remove its nodes. The database migration refuses any allocation with -no `released_at`, including retained snapshots and uncertain cleanup, then rewrites -only the former `parsar-core-runtime@` reference prefix. The specification digest -changes, requiring new node enrollment. Historical Session data remains unchanged. -Old E2B templates must be rebuilt with the matching Runtime release. This release -refuses E2B rename conversion before mutations until a Core-owned safe upgrade -transition is implemented. Ordinary migration retires maintenance and resumes -admission; it never interprets old maintenance as authorization to clear. Downgrade -refuses an active reset or an unconfigured completed reset with generation above -zero; configure a provider before downgrading, never erase the generation. +Build new E2B templates with the matching current Runtime release. Historical +Runtime names and installations have no supported upgrade, downgrade or conversion +path; preserve their data and resources and install separately. Current Runtime +generation rollout, coexistence and ownership-scoped garbage collection remain +supported and are independent of installed program version changes. See +[generation ownership and rollout](#generation-ownership-and-rollout). `runtime_download_failed` means the exact Runtime artifacts could not be transferred or verified. It is distinct from provider probe and image availability failures; diff --git a/services/agents-api/HOSTED-SANDBOX-MANAGER.md b/services/agents-api/HOSTED-SANDBOX-MANAGER.md index 447b2a4bb..8f3be9eab 100644 --- a/services/agents-api/HOSTED-SANDBOX-MANAGER.md +++ b/services/agents-api/HOSTED-SANDBOX-MANAGER.md @@ -120,18 +120,13 @@ identity mount. ## Older file-managed installations -Core rejects `AGENTS_API_MANAGED_RUNTIMES_FILE`. It does not automatically convert -an old file-managed database into a Web-managed deployment. Removing the setting -alone is insufficient: the database retains its original configuration ownership. - -Keep the previous release, original database and backend available to settle work -and confirm cleanup. Stopped compute, retained snapshots, pending Environments and -unknown operations remain owned until their normal cleanup completes. Preserve -business data, Runtime history, private receipts and node state; never clear rows -or prune provider storage to bypass the guard. Retire the old file configuration -only as part of an explicit deployment transition. Automatic old-database adoption, -cross-provider Session migration and a force-reset operation are not supported. -The current installation path uses a database-managed deployment. +Core rejects `AGENTS_API_MANAGED_RUNTIMES_FILE`. Historical file-managed +installations are unsupported and have no conversion procedure. Preserve their +original database, backend resources, Runtime history, private receipts and node +state; do not clear rows or prune provider storage to bypass a refusal. Install the +current release separately using its database-managed deployment. Removing an old +setting does not transfer resource ownership or authorize data deletion. See the +[installation version policy](../../docs/getting-started/operations.md#installation-version-policy). ## Register a host @@ -165,10 +160,10 @@ an address Core no longer uses. `POST /core/v1/sandbox/enrollment-tokens` also r a non-secret `enrollment_id`; the node it registers reports the same value in `/core/v1/sandbox/nodes`, and nodes enrolled before Core recorded it report null. -A node and its Core must come from the same distribution. Nodes from releases -that used the removed `/core/v1/sandbox` node paths cannot connect to this Core: -drain with the previous release, then upgrade and enroll new nodes, as described -in [Upgrade notes](../../docs/getting-started/operations.md#node-connections-at-apiv1). +A node and its Core must come from the same distribution. Historical machine +protocols and in-place version upgrades are unsupported. Preserve older +installations and resources; provision current-release nodes separately. See the +[installation version policy](../../docs/getting-started/operations.md#installation-version-policy). Rerunning the same command preserves the node's private identity. A registered retry reads configuration with its retained node credential and `X-OAC-Node-ID`; it does not enroll again. Changes to the Core origin, installation, generation, specification @@ -390,10 +385,9 @@ that text never leaves the host. Only the code crosses the node connection. Probe errors can name host paths or contain daemon messages; they are not sent to Core, stored or returned. Core stores -any other reported value as `provider_unavailable`. Nodes from older releases send -`provider_unavailable` or no code and keep working unchanged. Upgrade Core before -its nodes: an older Core rejects the new codes and closes an unready newer node's -connection until its provider is ready again. +any other reported value as `provider_unavailable`. Core and nodes must use the +same distribution; this defensive mapping does not establish support for mixed +program versions or an in-place upgrade sequence. ## Runtime observations From db57eb1bbad392eab9462e55e983f00bfabebbfe Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 15:02:21 +0000 Subject: [PATCH 11/78] Pin generated guides to the corrected canonical documentation --- apps/docs/content/docs/admin-api.mdx | 24 +++---- apps/docs/content/docs/agents-and-tools.mdx | 40 ++++++------ .../content/docs/bootstrap-projects-keys.mdx | 4 +- apps/docs/content/docs/concepts.mdx | 6 +- apps/docs/content/docs/configure.mdx | 16 ++--- apps/docs/content/docs/console.mdx | 18 +++--- .../content/docs/environments-and-files.mdx | 32 +++++----- apps/docs/content/docs/execution-model.mdx | 6 +- apps/docs/content/docs/hosted-providers.mdx | 8 +-- apps/docs/content/docs/index.mdx | 8 +-- apps/docs/content/docs/install.mdx | 6 +- apps/docs/content/docs/observability.mdx | 10 +-- apps/docs/content/docs/public-api.mdx | 64 +++++++++---------- apps/docs/content/docs/quickstart.mdx | 8 +-- .../content/docs/self-hosted-execution.mdx | 4 +- apps/docs/content/docs/sessions.mdx | 26 ++++---- apps/docs/content/docs/troubleshooting.mdx | 8 +-- apps/docs/content/guide-sources.json | 36 +++++------ apps/docs/scripts/generate-guides.mjs | 2 +- 19 files changed, 163 insertions(+), 163 deletions(-) diff --git a/apps/docs/content/docs/admin-api.mdx b/apps/docs/content/docs/admin-api.mdx index 81b78802b..ec9674f6b 100644 --- a/apps/docs/content/docs/admin-api.mdx +++ b/apps/docs/content/docs/admin-api.mdx @@ -8,7 +8,7 @@ resource inspection, Project and key management, credential issuance, audit, usa and sandbox operations. It never calls `/v1` or `/api/v1`, and has no Agent execution, copy or arbitrary asset editing operation. -Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/core-errors.md), +Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/core-errors.md), including optional typed safe details. Console sign-in endpoints retain their separate error shape described below. @@ -49,9 +49,9 @@ label, but could set any value. Never use it for authorization or as proof of origin. Web calls Core only through the typed Core clients in `packages/agents-client`: -[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/packages/agents-client/src/admin-client.ts) (`/core/v1`), +[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/packages/agents-client/src/admin-client.ts) (`/core/v1`), `SandboxAdminClient` (`/core/v1/sandbox`) and `CoreMetricsClient` -(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md) +(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md) for methods, fields, filters, pagination and response shapes. Routes below are relative to `/core/v1`: @@ -62,8 +62,8 @@ Routes below are relative to `/core/v1`: | Resource lists/details/deletion | `/projects/{id}/agents`, `/sessions`, `/environment-templates`, `/skills`, `/files`, `/vaults`, including the documented nested reads | | Asset ownership | `GET /projects/{id}/resource-owners` with batched resource IDs | | Key operation history | `GET /projects/{id}/write-operations` with key/resource/time filters | -| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-executor-credentials.md)) | -| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/model-execution.md#deployment-defaults)) | +| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-executor-credentials.md)) | +| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/model-execution.md#deployment-defaults)) | | Usage and health | `GET /summary`, `/sandbox/runtime-observations`, `/metrics` | | Administrator audit | `GET /audit-log`; deployment-wide entries have `project_id: null` | @@ -76,9 +76,9 @@ it is operational attribution, not per-key billing. ## Sandbox administration -The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/sandbox-deployment.md), -[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md) -and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/core.openapi.yaml) +The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/sandbox-deployment.md), +[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/core.openapi.yaml) define deployment and node operations: - `GET/POST/PUT /core/v1/sandbox/deployment` and @@ -115,7 +115,7 @@ is a durable serving pin, not proof of current connectivity. Target unknown, fai or update-required state does not by itself invalidate confirmed old-generation service; consume Core's connection/provider facts separately. -Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md#administrative-session-archive) +Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md#administrative-session-archive) at the current generation without reset. History and persisted Files/Artifacts survive; unpersisted workspace is lost and the original Session cannot resume. Cancel reset stops further archives, not cleanup already requested. After zero @@ -156,7 +156,7 @@ generated schema; do not interpret every empty or failed read as an absent resou `GET /core/v1/metrics?range=1h|6h|24h|7d` returns Core process, execution queue/slots, PostgreSQL and background-job measurements. It requires the Core key, rejects arbitrary query filters and never grants -Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/core-metrics.md) +Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/core-metrics.md) for complete buckets, null values, units and process-local retention. Frontend implementation is maintained separately; this backend change does not modify Agent metrics or the public Agent API. @@ -165,7 +165,7 @@ Agent metrics or the public Agent API. The Core key reads a node and its host history through `GET /core/v1/sandbox/nodes/{node_id}?range=1h|6h|24h`. See the -[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/node-host-history.md) +[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/node-host-history.md) for nullable observations, freshness and aggregation. The node list is unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/api/web-management.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/api/web-management.md) diff --git a/apps/docs/content/docs/agents-and-tools.mdx b/apps/docs/content/docs/agents-and-tools.mdx index a469cdf46..100956bd9 100644 --- a/apps/docs/content/docs/agents-and-tools.mdx +++ b/apps/docs/content/docs/agents-and-tools.mdx @@ -4,7 +4,7 @@ description: "Native harness tools, shared declarations and the limits of execut --- Assessed against Core `206474a4c10901442b1faa094281bfb5559082b5` on 2026-09-22. -The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/upstream.json), Beta +The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/upstream.json), Beta `agents=v1`. This matrix records the bounded execution/tools milestone. It does not establish complete protocol compatibility; the remaining limits below stay open. @@ -22,20 +22,20 @@ and Environment Plugin MCP have separate inventories and qualification. | Operation | Implemented behavior and real qualification | Unsupported or unverified boundary | | --- | --- | --- | -| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | -| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | -| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | -| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | -| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | -| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | +| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | +| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | +| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | +| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | +| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | +| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | | Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. MiniMax functions and self-hosted image workflows are unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | -| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | -| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | -| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | -| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | -| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | -| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | -| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | +| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | +| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | +| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | +| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | +| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | +| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | +| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | ## Required-action recovery contract @@ -52,7 +52,7 @@ application already performed the function, keep and submit that saved result; do not run an external effect again merely because an action remains visible. Core's acknowledgement boundary is native application. For an environment action, connect its exact Environment using the existing enrollment authorization. -[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/store/environment_connections.go) +[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/store/environment_connections.go) fence stale generations; transport connection can clear pre-Turn activity before native preparation. Neither registration nor an action disappearing proves that a model ran successfully. Query the matching Turn and Items for its outcome. @@ -61,8 +61,8 @@ Two timing questions remain open. Repeated `requires_action` notification order and acknowledgement timing are local implementation choices, not proven upstream semantics. Also, an admitted result that is cancelled before native observation can remain internally saved without a public output Item or `item.added`. -[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/internal/store/item_projection.go) and -[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md#public-function-configuration) preserve +[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/store/item_projection.go) and +[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md#public-function-configuration) preserve this gap. Successful retry cannot manufacture the missing observation or establish that the model consumed the result. @@ -82,12 +82,12 @@ not a claim that every historical workflow was rerun at the current baseline. | M2 | `20260922/workspace-images/validation-summary.json`; Codex `public-run-_lr5uiy_`, Claude `public-run-sb65p9e9`; candidate `2c295116d66ed9aafc808ec49a8cb6dcb5c674c2`, merge `1adb2489bc3415039440224e9a7905c53e68a557` ([#17](https://github.com/MiniMax-AI/parsar-core/pull/17)). Kimi K3, Codex 0.153.4, Claude SDK 0.3.269/native 2.1.269; Docker seven-Turn image/workspace workflow. Codex's receipt conclusion is superseded by F2. | | F1 | `20260922/function-result-images/validation-summary.json`, `validated/function-image-public-2567456666/public.json`; candidate `d3cdb225bc7628b968b76c7e1b400101894ce8cd`, merge `fd23f169e1ede1b2f1c39a1d9dcce71886e3c006` ([#16](https://github.com/MiniMax-AI/parsar-core/pull/16)). Claude SDK 0.3.269/native 2.1.269 with Kimi K3 on `none`; success PNG/JPEG, failed text, retries, cancellation, history continuation. Earlier Codex transport-only evidence does not qualify current receipts. | | F2 | `20260922/function-result-receipts/validation-summary.json`, `candidate-public-owner-sdk.log`; hosted `public-run-4fn70foy` (104.13s), `none` `function-image-public-945075091` (83.918s). Candidate `6dceb98e1c56469fcc70cd82025ce77fc870ed3d`, merge `206474a4c10901442b1faa094281bfb5559082b5` ([#20](https://github.com/MiniMax-AI/parsar-core/pull/20)); real Kimi, Codex 0.153.4. Qualifies native receipts, not completed provider consumption. | -| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | +| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | | S1 | `20260922/structured-output/acceptance-summary.json`, `structured-public-1905281777/public.json`; real candidate `7f533d46c472f3cdf7c16ce9471c225a2ba7eded`, merge `8716e699dbc34904499c94b6b0b03857bbebfaad` ([#11](https://github.com/MiniMax-AI/parsar-core/pull/11)). Claude/Kimi `none` schema/function execution and cold continuation. Schema-specific active steering was not separately qualified here. | | S2 | `20260922/hosted-structured-output/validation-summary.json`, `validated/public-inline.log`, `public-run-qvq3csf1` (186.09s); candidate `f6d2c3f2a1dc2ff5f177213bd2b20d78496011d0`, merge `aa85d8ecc60e0a8b7f2494b73caa81216ee197e2` ([#18](https://github.com/MiniMax-AI/parsar-core/pull/18)). Claude SDK 0.3.269/native 2.1.269, real Kimi; Docker saved/inline schema, prepared/active input, native files and four completed structured answers. | | D1 | `20260922/deferred-tools/tool-search-public-2039155387/public.json`, `public-image-isolated-tests.log` (86.71s); merge `178507ae7a63d4068e1e82bef9cd256ba398ae00` ([#13](https://github.com/MiniMax-AI/parsar-core/pull/13)). Claude SDK 0.3.269/native 2.1.269/Kimi K3 `none`; text results with initial/active PNGs, cancellation and cold continuation. `claude-native-1790052750` separately records provider schema inventories and native feasibility. | | P1 | `20260922/tool-policy/acceptance.json`, `server-evidence/tool-policy-{codex-2495445746,claude_sdk-2539346823,mcode-3747575401}/public.json`; candidate `eeb432c7495265ae3a9309e32f5b4323611c3245`, merge `4b8754a6eda6696d9b18eb8d583953ac16c6800f` ([#14](https://github.com/MiniMax-AI/parsar-core/pull/14)). Codex/Claude Kimi K3, MiniMax M2.7; four `none` Sessions per harness, native disable controls and cold continuation. | -| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | +| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | | E2 | `20260922/execution-tools-closure/environment-actions/result.json` contains Codex/Claude Kimi passes (13 checks each) and the retained MiniMax direct-network failure; `mcode-relay-retry/result.json` separately passes MiniMax M2.7 (13 checks) after correcting provider routing. Source `206474a`; same shared SDK/raw workflow, isolated Core/PostgreSQL and user-managed Docker. Covers initial creation SSE, client disconnect, exact pending Environment/no Turn or Items, scoped enrollment, one actual completed model Turn and creation retries preserving history. This is connection-action qualification, not another full lifecycle or E2B regression. | Controlled tests establish parser, projection, atomicity and race behavior; native @@ -121,4 +121,4 @@ remain separate limitations. They do not authorize silently relabeling an unprov principal workflow as deferred. OAuth, full Usage, release publication, new tool kinds and new execution loops are outside this closure batch. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/execution-tools.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/execution-tools.md) diff --git a/apps/docs/content/docs/bootstrap-projects-keys.mdx b/apps/docs/content/docs/bootstrap-projects-keys.mdx index 834a766d5..c99088ea3 100644 --- a/apps/docs/content/docs/bootstrap-projects-keys.mdx +++ b/apps/docs/content/docs/bootstrap-projects-keys.mdx @@ -69,7 +69,7 @@ and display only. Keep deployment, application, node and provider credentials ou Installation creates no Project or application key. An administrator creates a Project and issues named keys on the console's **Projects and keys** page, which -uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md), or with that +uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md), or with that API directly. The Project owns one tenant and one principal; its keys share assets and permissions. Writes retain each key's provenance. All Projects and application keys live in @@ -98,4 +98,4 @@ management request points to the console's Core key or Core connection. A resour deletion conflict must remain visible; it does not authorize an execution call. Node and daemon `/api/v1` routes and native Runtime interfaces retain their own authentication. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/web/core-connection.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/web/core-connection.md) diff --git a/apps/docs/content/docs/concepts.mdx b/apps/docs/content/docs/concepts.mdx index 9287e6c7f..e48f1a37f 100644 --- a/apps/docs/content/docs/concepts.mdx +++ b/apps/docs/content/docs/concepts.mdx @@ -83,12 +83,12 @@ ownership. No request bodies, secrets or file contents enter audit records. Do not add product users, RBAC, cross-Project shared assets, administrator execution, or old private-protocol compatibility to this management model. Existing public -execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/CONTRIBUTING.md). +execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/CONTRIBUTING.md). Native failure classification is adapter-owned and uses finite structured native values. Optional Runtime error metadata is normalized once and retained through normal delivery and journal draining; it never replaces Core terminal authority, cancellation receipts, Usage or native identity. See -[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/native-error-classification.md). +[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/native-error-classification.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/design-principles.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/design-principles.md) diff --git a/apps/docs/content/docs/configure.mdx b/apps/docs/content/docs/configure.mdx index ccb1d8e31..798ea32fe 100644 --- a/apps/docs/content/docs/configure.mdx +++ b/apps/docs/content/docs/configure.mdx @@ -116,7 +116,7 @@ output or in Core's settings snapshot. Model providers are not process settings; The schema is -[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/deploy/install/config.schema.json); each +[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/deploy/install/config.schema.json); each installation keeps a copy in `generated/config.schema.json` for editors. Core serves the non-secret settings snapshot, with the path of `config.json` and the apply command, at `GET /core/v1/installation`. @@ -145,7 +145,7 @@ Which harnesses are enabled, and the default one, are process settings Exactly one provider serves the deployment: Docker, microsandbox or E2B. The deployment specification holds the per-sandbox resources and, for Docker and microsandbox, the Runtime release. Web's setup proposes the Standard size from -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/apps/web/src/features/sandbox/standard-sizes.json) and +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/apps/web/src/features/sandbox/standard-sizes.json) and derives Small and Large from it; the installer applies the bundle's copy of the same file. The database owns the saved values. @@ -183,8 +183,8 @@ Current Runtime generation coexistence and ownership-scoped garbage collection remain supported. They do not upgrade the installed node program or convert an old installation. History stays, and existing Sessions never move between providers. See the [nodes guide](/hosted-providers#change-the-sandbox-backend-or-size), -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) -and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/sandbox-deployment.md). +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/sandbox-deployment.md). ### Node capacity @@ -211,7 +211,7 @@ Session freezes the provider it starts with, so a later change affects only new Sessions. Self-hosted Sessions never use it. A hosted or self-hosted Session with no provider is refused with 400 `model_provider_required`. See [model providers](/quickstart#model-providers) and the -[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/model-execution.md#deployment-defaults). +[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/model-execution.md#deployment-defaults). The operator file `AGENTS_API_EXECUTION_OPTIONS_FILE` is retired; see [installation version policy](/troubleshooting#installation-version-policy). @@ -231,7 +231,7 @@ A Web-only installation has only `secrets/core.key`, a copy of its Core's key. Core reads only its environment. The installer renders `generated/core.env` from `config.json`; if you run Core yourself (see -[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/maintainers.md)), set these variables. Compose +[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/maintainers.md)), set these variables. Compose loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose must be 2.26.0 or newer. @@ -246,7 +246,7 @@ loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose mu | `OAC_INSTALLATION_ID` | `state.json`; pinned to the database | | `OAC_SETTINGS_FILE` | `generated/settings.json`, the snapshot Core serves at `GET /core/v1/installation`; Core does not act on it | | `OAC_EXECUTION_CONCURRENCY`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS` | The matching `core.*` settings | -| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime-history-api.md) | +| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime-history-api.md) | | `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | `log.*`; Web reads the same three | | `OAC_E2B_STATE_DIR`, `OAC_E2B_PROVIDER_BIN` | The E2B receipt directory and, for native Core, the bundled helper. The E2B key and template live in the database | @@ -268,4 +268,4 @@ paths must be canonical absolute paths without control characters, quotes, backs or wildcards. Keep the installation ID and the database together; Core refuses a missing installation ID when its database already has a deployment. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/configuration.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/configuration.md) diff --git a/apps/docs/content/docs/console.mdx b/apps/docs/content/docs/console.mdx index 49a2a64a9..544c13e9a 100644 --- a/apps/docs/content/docs/console.mdx +++ b/apps/docs/content/docs/console.mdx @@ -27,7 +27,7 @@ management client, and it sends nothing to `/v1`. | Platform | Projects and keys, Nodes, System | Project and key lifecycle; sandbox deployment and nodes; System: the installation's public address, API base URL, ID and source commit (read-only), each harness's default model provider (write-only key) beside its read-only startup state, the sandbox configuration every Project shares: provider, sandbox size, Runtime or E2B template build, idle suspension and reset, and Core's config.json startup settings with where to change them | Missing data is shown as missing (—), never as zero. How each figure is read and -bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/web/protocol-coverage.md). +bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/web/protocol-coverage.md). ## Management scope @@ -60,15 +60,15 @@ only the console server sends it to Core. - [Connection and authentication](/bootstrap-projects-keys) - [Architecture and ownership](/execution-model) -- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/web/protocol-coverage.md) -- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/web/roadmap.md) -- [React application](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/apps/web/README.md) +- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/web/protocol-coverage.md) +- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/web/roadmap.md) +- [React application](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/apps/web/README.md) -The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md) defines -management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md) +The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md) defines +management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md) define the separate application interface. See the [design principles](/concepts) -for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/CONTRIBUTING.md) for required checks. +for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/CONTRIBUTING.md) for required checks. -OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/LICENSE). +OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/LICENSE). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/web/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/web/README.md) diff --git a/apps/docs/content/docs/environments-and-files.mdx b/apps/docs/content/docs/environments-and-files.mdx index dab4c4eea..ff296f227 100644 --- a/apps/docs/content/docs/environments-and-files.mdx +++ b/apps/docs/content/docs/environments-and-files.mdx @@ -3,16 +3,16 @@ title: "Environments and files" description: "Execution placement, Environment resources and workspace ownership." --- -This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/upstream.json), with partial +This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/upstream.json), with partial coverage. Core-managed Docker runs the three qualified native harnesses. V1 user-managed `self_hosted` enrollment uses the same colocated Runtime for Codex, -Claude SDK and MiniMax at `/workspace`; see its [real deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/user-managed-runtime-v1.md). +Claude SDK and MiniMax at `/workspace`; see its [real deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/user-managed-runtime-v1.md). For hosted compute, the deployment selects E2B, Docker or microsandbox through -[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md). +[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md). For the separate caller-managed E2B path, the user owns allocation, renewal and -cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/deploy/e2b/README.md). -[Templates](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; -[Files](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. +cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/deploy/e2b/README.md). +[Templates](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; +[Files](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. The internal Store now owns a durable Environment association for newly created `self_hosted` and `openai_hosted` snapshots, atomically with Session creation. @@ -30,15 +30,15 @@ readiness. Rotation/revocation, Session deletion and ownership loss deny further access without promising immediate cessation of native effects. Native history remains local to the bound Runtime and cannot be replaced on retry. There is no registry/Noise relay or transient service-side harness credential. -See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/README.md#user-managed-runtime-enrollment). +See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/README.md#user-managed-runtime-enrollment). ## Basic public Docker-hosted profile An explicitly configured default managed Docker provider enables `type=openai_hosted` for the qualified Codex, Claude Code and MiniMax Code profiles. Each uses the same Runtime lifecycle and workspace interfaces with its own native adapter/isolation. -See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. -The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) +See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. +The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) selects the qualified immutable Runtime image; advertised capabilities alone do not enable admission. An idle or initial-text creation commits Session, Environment and retry identity before the existing leased Worker provisions its allocation. @@ -50,7 +50,7 @@ policies use the same qualified image with adapter-selected immutable native pol Templates and inline configuration share initial files, env, packages, ordered setup and inline or tenant-owned referenced Skills through the hosted initializer. Unsupported hostname forms and installation combinations reject explicitly; see -the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-templates.md). Empty/null installation +the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Service-origin hosted MCP remains unsupported; Environment Plugin MCP has its own qualified transport matrix. @@ -70,11 +70,11 @@ semantics; this profile does not establish complete Environment compatibility. The application creates, renews and destroys its E2B sandbox through the official SDK. It deploys the shared Runtime, then enrolls that Runtime into a `self_hosted` Session. Core neither keeps an E2B allocation nor issues Provider renew/kill calls. -The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/deploy/e2b/README.md) owns packaging and +The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/deploy/e2b/README.md) owns packaging and user-side lifecycle instructions. Expiry or lost workspace/history must not trigger -transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/user-managed-runtime-v1.md) +transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/user-managed-runtime-v1.md) records its own real deployment evidence. -The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md#e2b-v1-qualification) records a historical +The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md#e2b-v1-qualification) records a historical Core-managed route; it does not establish this caller-managed enrollment result. Current deployment-managed E2B is a separate hosted configuration in the manager guide. @@ -111,7 +111,7 @@ connection alone is not readiness. Retries preserve identity and the five-minute database deadline. HTTP disconnect retains the reservation. Local expired/cancelled outcomes return 409, lost ownership 503, and deletion 404; exact hosted error status/body and pending-input crash recovery are unverified. See the -[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/CONTRIBUTING.md#environment-ownership-and-placement). +[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/CONTRIBUTING.md#environment-ownership-and-placement). Cancellation-only batches use the existing locked admission and native delivery. An idle cancellation creates no Turn, and retry identity preserves the original @@ -191,7 +191,7 @@ from Session and Turn status. ## Ownership and placement decision -The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/CONTRIBUTING.md#environment-ownership-and-placement) +The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/CONTRIBUTING.md#environment-ownership-and-placement) keep Environment lifecycle common while leaving process placement and native transport to adapters. Logical ownership does not require a machine per object. @@ -215,4 +215,4 @@ native history independently of disposable compute, or prove native restoration; never treat an Environment ID as a filesystem or history backup. Do not silently move an existing Session away from its bound device. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environments.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environments.md) diff --git a/apps/docs/content/docs/execution-model.mdx b/apps/docs/content/docs/execution-model.mdx index 81991c732..54e8f52ed 100644 --- a/apps/docs/content/docs/execution-model.mdx +++ b/apps/docs/content/docs/execution-model.mdx @@ -10,7 +10,7 @@ Agents API independently with their own Project keys. The management backend, `AdminClient` and the React console built on them are implemented. The [design principles](/concepts) define identity and authority. -The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md) defines exact +The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md) defines exact routes, payloads, pagination and audit records. ## Request boundaries @@ -106,6 +106,6 @@ comes from the deployment's allocations; the hosted Runtime total counts hosted observation records across projects and reported lifecycle states. These sources have different coverage and refresh independently, so the console does not infer resource retention or cleanup from their difference. -Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/CONTRIBUTING.md). +Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/CONTRIBUTING.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/web/architecture.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/web/architecture.md) diff --git a/apps/docs/content/docs/hosted-providers.mdx b/apps/docs/content/docs/hosted-providers.mdx index 917360f38..c781c135a 100644 --- a/apps/docs/content/docs/hosted-providers.mdx +++ b/apps/docs/content/docs/hosted-providers.mdx @@ -10,7 +10,7 @@ With E2B you need no nodes. Self-hosted executors, which applications run for th own Sessions, are a different thing; see [Self-hosted executors](/self-hosted-execution). You add a node by generating a command in Web and pasting it on the host. The -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the node protocol, manual registration, placement and failure handling. ## Before you add a node @@ -187,7 +187,7 @@ The installation ID is in the command (`--installation-id`) and on the **System* 1. In Web, open **Nodes** and choose **Remove node** on the node's page, or **Remove** in its list row, then **Confirm removal**. Core refuses while the node still holds sandboxes, snapshots or pending cleanup; let them finish, or - [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) + [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) through the Core API. Removal is permanent: the host can come back only as a new node. 2. Web then shows **Clean up the host** with the uninstall command. Run it on the host: @@ -290,7 +290,7 @@ files. Shared daemon images remain; a host administrator may remove them only af confirming no installation still needs them. Microsandbox image cleanup remains scoped to the installation's private native store and verified ownership receipts. -The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) describes reset and API behavior. Same-team E2B edits have a separate online Core API path and do not involve node installation. @@ -303,4 +303,4 @@ convert or delete it. Preserve the old installation, Runtime resources and histo provision a fresh node separately through **Add node**. The current installer does not adopt historical resources or remove another installation's state. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/getting-started/nodes.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/getting-started/nodes.md) diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 691c47b46..00c7aa681 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -19,10 +19,10 @@ applications call Core with. | [API reference](/public-api) | Developers | The `/v1`, `/core/v1` and `/api/v1` namespaces and their contracts | Deeper references: the -[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md) reference for operators, the -[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md), and -[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/maintainers.md) for building distributions +[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md), and +[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/maintainers.md) for building distributions and running Core without the installer. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/getting-started/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/getting-started/README.md) diff --git a/apps/docs/content/docs/install.mdx b/apps/docs/content/docs/install.mdx index d91e71310..e32668a5b 100644 --- a/apps/docs/content/docs/install.mdx +++ b/apps/docs/content/docs/install.mdx @@ -140,7 +140,7 @@ A deployment runs its sandboxes on exactly one backend: | `none` | Nothing yet | Choose the backend on the **Nodes** page in Web | The Standard sizes come from the bundle's copy of Web's -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/apps/web/src/features/sandbox/standard-sizes.json). The +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/apps/web/src/features/sandbox/standard-sizes.json). The installer saves the choice through the same administrator API that Web uses once the services are healthy. If Core refuses it, for example because E2B rejects the key, the installer prints Core's message and exits with an error; the services keep @@ -164,7 +164,7 @@ it installs anything: ``` Prepare the template build with the -[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/deploy/e2b/README.md). To change the backend or +[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/deploy/e2b/README.md). To change the backend or the sandbox size later, use the Nodes page in Web; see [Nodes](/hosted-providers#change-the-sandbox-backend-or-size). @@ -531,4 +531,4 @@ missing images, restores a missing `oac` command, copies node files newly placed `--install-dir`. It refuses a bundle from another release; see [installation version policy](/troubleshooting#installation-version-policy). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/getting-started/install.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/getting-started/install.md) diff --git a/apps/docs/content/docs/observability.mdx b/apps/docs/content/docs/observability.mdx index c3eb9c45e..6b12575e5 100644 --- a/apps/docs/content/docs/observability.mdx +++ b/apps/docs/content/docs/observability.mdx @@ -7,7 +7,7 @@ Status: Phase 2 and initial Core Web consumption implemented. The current-snapsh `packages/agents-client` projection, and generated `core.openapi.yaml` contract are implemented and consumed by the Dashboard through complete Session/observation identity joins. Durable history uses the separate optional -[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside +[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside this phase. These are administrator reads under `/core/v1`, authenticated by the Core key, @@ -92,7 +92,7 @@ Authorization: Bearer ... ``` This returns the same object shape as a list item's `observation`, without the -administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md)). It never starts a Turn, creates +administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md)). It never starts a Turn, creates an Environment, provisions compute, renews a lease, or changes lifecycle state. A valid `environment:none` Session returns `200` with status `unsupported`; the @@ -193,7 +193,7 @@ Use the existing Agents API error envelope. | 400 | `invalid_request_error` / `invalid_request_error` | List: a repeated supported query key, or an empty or invalid limit or order, with the shared Beta list messages. Unknown list query keys are ignored. | | 400 | `invalid_request_error` / `unsupported_parameter` | Single-Session retrieval with any query parameter. | | 401 | `invalid_request_error` / `invalid_admin_key` | Missing or invalid Core key. | -| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | +| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | | 500 | `server_error` / `internal_error` | Integrity, ownership, or invalid provider evidence. | | 503 | `server_error` / `execution_unavailable` | Required Runtime observation service is not configured, or list collection exceeded its request budget. | @@ -273,10 +273,10 @@ deletion makes the candidate incomplete and prevents publication. - Token usage: use existing Session/Turn Usage. - Billing and cost: product/backend concern. - Historical series in these routes: the optional capability uses the separate - [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime-history-api.md). + [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime-history-api.md). - Container logs and command output. - Provider credentials or native configuration. - Start, stop, pause, resume, restart, renew, or delete operations. - Idle classification and automatic shutdown. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime-observability-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime-observability-api.md) diff --git a/apps/docs/content/docs/public-api.mdx b/apps/docs/content/docs/public-api.mdx index 369730108..13597b96a 100644 --- a/apps/docs/content/docs/public-api.mdx +++ b/apps/docs/content/docs/public-api.mdx @@ -9,8 +9,8 @@ no credential works in another namespace. | Namespace | Caller | Credential | Contents | Reference | | --- | --- | --- | --- | --- | | `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`) | [Public API](/sessions) | -| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/core.openapi.yaml) | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime.openapi.yaml) | +| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/core.openapi.yaml) | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime.openapi.yaml) | A Project API key gets 401 on `/core/v1` and `/api/v1`; the Core key gets 401 on `/v1` and `/api/v1`. Projects own assets. Multiple equally privileged keys share @@ -34,7 +34,7 @@ with the Core key on Core's loopback port, not through the public entry. Applications call every `/v1` route with a Project API key. The routes are exactly the 58 method and path pairs in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/upstream-routes.json); the +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/upstream-routes.json); the [Public API guide](/sessions) groups them by resource. `/v1` has no Core-only route. @@ -45,18 +45,18 @@ with the Core key. | Routes | Contents | Contract | | --- | --- | --- | -| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/execution-configuration.md) | -| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/session-diagnostics.md) | +| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/execution-configuration.md) | +| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/session-diagnostics.md) | | `projects/{project_id}/sessions/{session_id}/runtime-observation`, `sandbox/runtime-observations` | Current Runtime observations | [Runtime observations](/observability) | -| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime-history-api.md) | -| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-executor-credentials.md) | -| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/installation.md) | -| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/core-metrics.md) | -| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/node-host-history.md) | -| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/model-execution.md#deployment-defaults) | +| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime-history-api.md) | +| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-executor-credentials.md) | +| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/installation.md) | +| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/core-metrics.md) | +| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/node-host-history.md) | +| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/model-execution.md#deployment-defaults) | ## Machine connection API @@ -65,42 +65,42 @@ the Core key or a Project API key. | Routes | Caller | Credential | Contract | | --- | --- | --- | --- | -| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | -| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-executor-credentials.md) | -| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/services/agents-api/README.md#user-managed-runtime-enrollment) | +| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | +| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-executor-credentials.md) | +| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/README.md#user-managed-runtime-enrollment) | ## Contract sources -- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/upstream.json): OpenAI +- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/upstream.json): OpenAI Python SDK 3.13.0, exact upstream commit and `agents=v1`. -- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/upstream-routes.json) and - [fields](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/upstream-fields.json): extracted from the +- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/upstream-routes.json) and + [fields](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/upstream-fields.json): extracted from the pinned SDK by `scripts/extract-agents-api-upstream.py`. Contract tests require the public OpenAPI to have exactly these routes and to keep every other field inside `x_agents_core`. -- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/openapi.yaml): public schema snapshot; - combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/operation-evidence.md). -- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/core.openapi.yaml): generated `/core/v1` +- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/openapi.yaml): public schema snapshot; + combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/operation-evidence.md). +- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/core.openapi.yaml): generated `/core/v1` routes, all authenticated by the Core key. -- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/runtime.openapi.yaml): - generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/node-generation-protocol.md); the private +- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime.openapi.yaml): + generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/node-generation-protocol.md); the private daemon transport is described in the Runtime credential guide. -- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md): Project/key +- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md): Project/key lifecycle, resources, explicit hosted Session archive, summary, errors/deletion preconditions, audit and historical copy provenance. - [Web integration](/admin-api): browser, console and Core boundaries and frontend handoff. -- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/CONTRIBUTING.md): +- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/CONTRIBUTING.md): ownership, security and change requirements. Generated schemas do not establish complete compatibility or real execution -support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md) identifies +support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md) identifies qualified workflows, native differences and unresolved behavior. Update the relevant contract and this index when adding or moving an API surface. -Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/core-errors.md), +Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/core-errors.md), including typed optional safe details and distinct console proxy rejection codes. The public and machine error contracts remain unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/api/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/api/README.md) diff --git a/apps/docs/content/docs/quickstart.mdx b/apps/docs/content/docs/quickstart.mdx index 3606c172e..3df89ed80 100644 --- a/apps/docs/content/docs/quickstart.mdx +++ b/apps/docs/content/docs/quickstart.mdx @@ -101,8 +101,8 @@ The provider's protocol must match the harness: | Claude Code (`claude_sdk`) | `anthropic` | The provider's exact model ID | | MiniMax Code (`mcode`) | `anthropic` | `context_window` and `max_output_tokens` | -See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/harness-selection.md) and -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/model-execution.md) for the full contract. +See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/harness-selection.md) and +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/model-execution.md) for the full contract. Selecting a harness doesn't make an unsupported model or operation work. ## Model providers @@ -142,8 +142,8 @@ not replay history. Don't submit the same work as a new Session when a response lost. Files, Artifacts, cancellation and their current limits are in the -[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md). MCP credentials use the +[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md). MCP credentials use the separate Vault API; they are neither Project API keys nor model provider keys. The [API index](/public-api) lists every route. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/getting-started/quickstart.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/getting-started/quickstart.md) diff --git a/apps/docs/content/docs/self-hosted-execution.mdx b/apps/docs/content/docs/self-hosted-execution.mdx index 38575ed5a..3c6ae812e 100644 --- a/apps/docs/content/docs/self-hosted-execution.mdx +++ b/apps/docs/content/docs/self-hosted-execution.mdx @@ -143,7 +143,7 @@ key_id=$(python3 -c 'import uuid; print(uuid.uuid4())'); echo "credential ID: $k Rotate with `{"key_id":"…","rotate":true}` on the same route; revoke with `DELETE …/executor-credentials/`. After an uncertain response, list the credentials with `GET` before trying again. The -[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-executor-credentials.md) +[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-executor-credentials.md) has every rule and error. @@ -159,4 +159,4 @@ continuation or automatic data deletion. Credential replacement for executors created by the current installer updates and restarts the same owned container. It does not authorize adopting an older executor. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/getting-started/self-hosted.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/getting-started/self-hosted.md) diff --git a/apps/docs/content/docs/sessions.mdx b/apps/docs/content/docs/sessions.mdx index 83cf6f516..28f6e5292 100644 --- a/apps/docs/content/docs/sessions.mdx +++ b/apps/docs/content/docs/sessions.mdx @@ -34,8 +34,8 @@ leaves the others valid; archiving the Project revokes all its keys. The optiona ## Public resources Paths are relative to `/v1`. Detailed fields, union types and response shapes are -in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/openapi.yaml), fixed SDK source -and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/README.md#upstream-resource-inventory) +in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/openapi.yaml), fixed SDK source +and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md#upstream-resource-inventory) lists each operation and its coverage. | Resource | Route family | Semantics | @@ -44,11 +44,11 @@ lists each operation and its coverage. | Sessions and Turns | `/agents/sessions`, nested `/turns` | Admission, metadata, state and recovery queries | | Items and events | Session `/items`, `/events` | Persisted history and live SSE | | Artifacts | Session `/artifacts` | Captured output metadata, immutable content and deletion | -| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/subagents.md) | -| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-files.md) | -| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-templates.md) | -| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/source-files.md), distinct from live workspace files | -| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/file-resource-semantics.md) | +| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/subagents.md) | +| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-files.md) | +| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-templates.md) | +| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/source-files.md), distinct from live workspace files | +| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/file-resource-semantics.md) | | Vaults and Credentials | `/vaults`, nested `/credentials` | [Scoped write-only credential storage](/environments-and-files) | SSE is live, not a historical replay service. After a disconnect, query the Session, @@ -58,14 +58,14 @@ native execution. Do not automatically replay uncertain tool or file effects. ## Core extension fields The `/v1` route set is exactly the pinned SDK's operations, listed in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/upstream-routes.json); Core adds +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/upstream-routes.json); Core adds no route. It adds fields only inside `x_agents_core`, because it runs several harnesses and accepts custom model access: | Field | Where | Reference | | --- | --- | --- | -| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/harness-selection.md) | -| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/model-execution.md) | +| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/harness-selection.md) | +| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/model-execution.md) | Any other member of `x_agents_core` is rejected with 400. Deployment, placement, credential issuance and operational reads are not part of `/v1`; they belong to @@ -78,7 +78,7 @@ never to `self_hosted`; `none` Sessions accept only the deployment default. `openai_hosted` and `self_hosted` Sessions must end up with one: otherwise creation returns 400 with code `model_provider_required` and param `x_agents_core.model_provider`, and nothing is written. See -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/model-execution.md). +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/model-execution.md). An application reads a Session's model from `agent.model` and an explicitly selected harness from `agent.x_agents_core.harness`; a Session on the deployment @@ -90,8 +90,8 @@ The deployment chooses E2B or its own Docker/microsandbox nodes. A public `self_hosted` Environment uses caller-owned compute and the same colocated Runtime. For a `self_hosted` Session, the deployment operator issues the Environment's executor credential with the Core key; see -[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/environment-executor-credentials.md). +[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-executor-credentials.md). Our daemon transport and the supported native harness differences are explicit; they do not imply interoperability with OpenAI's stock executor transport. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/api/public-agent-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/api/public-agent-api.md) diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx index cc17f119d..5e72d6770 100644 --- a/apps/docs/content/docs/troubleshooting.mdx +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -123,7 +123,7 @@ refuses. `rotate-core-key` refuses to run on a Web-only installation. ## Projects and API keys Create Projects and issue their keys in Web, on **Projects and keys**, or with the -[Core API](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's +[Core API](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's plaintext is shown once; Core stores only its digest. All keys of a Project share its assets and execution principal, and each write records the key that made it. @@ -241,9 +241,9 @@ Core records which key made each public resource write, for the console. Set `core.write_audit_retention` in `config.json` (Go duration, at least `1h`, default `2160h`) to control how long non-creation history is kept; creation ownership is kept for good. Removing keys or resources does not delete these records. See the -[query contract](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/write-audit.md); administrator mutations +[query contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/write-audit.md); administrator mutations have a separate -[audit log](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither +[audit log](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither logs bodies or secrets. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9871472eb49e6f8f2d7fa61d4345354588751c7/docs/getting-started/operations.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/getting-started/operations.md) diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index c0cf11c74..4459951fd 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -1,5 +1,5 @@ { - "revision": "f9871472eb49e6f8f2d7fa61d4345354588751c7", + "revision": "cd6a32c3de31d26b73ab92d21ba2aa7b067ba226", "sources": { "docs/getting-started/README.md": "4eb3d21e073b25e2e6ffb8800e13b33032104ba44d8d93ea3e59394549dc7962", "docs/design-principles.md": "71285ff0b9a7796481b9fe42f7c4b71d0377c767157980ca6386792d9ca8951b", @@ -22,40 +22,40 @@ "apps/docs/scripts/guides.json": "1f6036e3cccaf5a71cdd9ea790be6f395acdf871c4552c490b6747957a10b174" }, "outputs": { - "content/docs/index.mdx": "abf2243197aec9d6510a2804f320ff049f45aebc2991e18fa7682b6088d308e7", + "content/docs/index.mdx": "0e0f91fcd4a2cdb65a85c39d81c4cc508a5750829380e2c8ad7d2955f16f1330", "content/docs/index.zh.mdx": "5bdd41893946f1d229eb4cf1599a97f2e38cb9e09722d2a69f62053376ac88a9", - "content/docs/concepts.mdx": "94ab4c7ca0b9dde2dd08786846656b59e6c1e269b62cd1496ccf0b680efa02df", + "content/docs/concepts.mdx": "5e20f86486bf9d8b4147241ca124d3fbdf7edee59e5c6bd2ec1f82b4e2adcf10", "content/docs/concepts.zh.mdx": "c60644f395dbac5ee607cdb053249e05ec8352d28de97ef1c38d6c1e4443bbb1", - "content/docs/execution-model.mdx": "f35d0fc76b4c8527f1b35274294c05d729333b767124c2e4657be294a75ea27c", + "content/docs/execution-model.mdx": "ac5bd0d655c5737b00019498be900fe2bf8a680f34e9d34ac08cbc2718ab1d3f", "content/docs/execution-model.zh.mdx": "9976a9203c6a83d49c2c753e3a8528a369078e87aeb88850654da948ac8c17e1", - "content/docs/install.mdx": "6b0800941af869cef695024e316e539a97847ddf813665e1f2e04f6f5e87f777", + "content/docs/install.mdx": "32d58f72107f79e74f0ab291bc39a6d83908f57330f1ca5ee5c9bcc9d44f9bad", "content/docs/install.zh.mdx": "f207607f0cfceef2012a4e772fc347ef254486327584289fee1c85414dffc907", - "content/docs/configure.mdx": "fcadee7c570c271299da340be0c82c1220500a33a8e6f4ea511e7ceabf4e7100", + "content/docs/configure.mdx": "523a8ed974e82634df690b3396e33e5645db55551e4f34d4e131aa411ff4d218", "content/docs/configure.zh.mdx": "1c4b149c332ade6f2eeca52319e3e7ccfaac3213db2fd084d66e2da517b6bbd6", - "content/docs/bootstrap-projects-keys.mdx": "d0f7e3bd226dd5ead429f17e2bbb6335b2ee29f3bbe4e0cb59ce3cc48fedb045", + "content/docs/bootstrap-projects-keys.mdx": "a88aac6c38abf5237b67a54f59a68b272ecbcd7d53280bb60efb566b14a1b906", "content/docs/bootstrap-projects-keys.zh.mdx": "509b4dcea4c865d6834bcaef78baa681180d48274931230f958a39e46011c88f", - "content/docs/quickstart.mdx": "65236a534fe288fa8a9b002f542d448f972259bd006f847ac9762c8d0da4d6e2", + "content/docs/quickstart.mdx": "23525ecf2c7d35c8920545a8490ad3f8b3ed7b28ba8eed6f0f794a8d179211c5", "content/docs/quickstart.zh.mdx": "c5f119b100b102918a45763834d0ed7fd436479fb411e16a62a408411dbcb3e1", - "content/docs/public-api.mdx": "0fb8676848006aceaabab2833da50f0a1bd581911db026b1cbb124ed0ae58f2d", + "content/docs/public-api.mdx": "1f1270ee51840ea1a61f5d6010a1d9b344ab13c800d6f4c14419081cb0fd3ba0", "content/docs/public-api.zh.mdx": "fb0efb9be2fba3f4a3dc71bf053d2c7eddffbfe010f59fa5a1ae60a3f068545b", - "content/docs/agents-and-tools.mdx": "fb6bd148a0828203ef007e44d2bfec143af7ba9f9a0d3737d8e57a10c1a124fb", + "content/docs/agents-and-tools.mdx": "ebd8239cb0cef20b9104abacd75cb6d6dc37f143347d7e370fa072b9569b1e05", "content/docs/agents-and-tools.zh.mdx": "cc8d4e0ef0ea894f068f3bbd143d5ee70c6346bf98f425b1d5fe07ec3931f128", - "content/docs/sessions.mdx": "4a198e8c2a8a12ef2df4412d776e33522ca93ff94b83ddcda1047d0239d6a800", + "content/docs/sessions.mdx": "d742bdb741475e68620a312ca604b13c758aa32b1f7c7fb8ecbde65465fd1ce3", "content/docs/sessions.zh.mdx": "b1b1a7f8aacd9a902320f9692173ede24036124f85761cf02f23ef3ef2bcc4c1", - "content/docs/environments-and-files.mdx": "5a15b298e6dc80324d01fdc2d35e8b3fc5f9fb7be30c895bab5095ccd4d11939", + "content/docs/environments-and-files.mdx": "324b055462ad01e505d225f501bdfb7ccbe0f60f92d583b3a018c08ae2bcc9bd", "content/docs/environments-and-files.zh.mdx": "ee64577220a798f023b330c65302c14438f25a34a78c4e36144bfdb4304a3ae5", - "content/docs/hosted-providers.mdx": "d5e1f5affbd43a1d21262a3a3390a62f4813a85b9ab8ad473bc6c44628ca4a6f", + "content/docs/hosted-providers.mdx": "ce582156fe078421312e0ef2e09ea134b98494ab498b6c6b1ca0174c79133297", "content/docs/hosted-providers.zh.mdx": "d7f6115f052a09dc943f0ed516ff30a27da5847ed24d387d9d1c7e7faa006ab0", - "content/docs/self-hosted-execution.mdx": "4c300708415b349799397222f0790acba240563d5cd5cbb54c59fc13b4430d98", + "content/docs/self-hosted-execution.mdx": "f4f93d8d29a37959b1fdf6f1d0c2e472f72738586990ee74a42901eb4d3f688e", "content/docs/self-hosted-execution.zh.mdx": "f11d34ce30d48039376256474bc9509a508a2f6b9906f49b5cf6e396707ae5c0", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", - "content/docs/console.mdx": "abefa9328aee86eb99357286a7efd90198c026833346c77a94ff4d8af72266b3", + "content/docs/console.mdx": "c4d80ef14420f2206beefa0d6d85bb3e0fbd982f40123f05773d469c4cd238e4", "content/docs/console.zh.mdx": "72f953ba46dcc95933773ac0c4dffdc0d0a499be9d9a1cb9467db2659f1fa10d", - "content/docs/admin-api.mdx": "b140c4026926820dedb6a3d94cad29918e5d9a0e1e9093b942e911b96174904d", + "content/docs/admin-api.mdx": "867eb84df53abf071fadef346d7f9795bfc1be6bad4a0e69475e687a5b0ae16c", "content/docs/admin-api.zh.mdx": "07006de30df3080e54c3cd79ed0ea5337dce557aa2f11afea84edb7f92884d38", - "content/docs/observability.mdx": "08f662d75a56934ca5096be6a0e73b29803f59b39ce1807426719bd5e6629166", + "content/docs/observability.mdx": "60a13191cc35fb7ca0ae8ed411988e085a0cdb9ada642adc3615ef603353ca86", "content/docs/observability.zh.mdx": "9574b3412a5754f41ab2d3a16062ac1cad06766be481a8e6bff1b84acbde7e7e", - "content/docs/troubleshooting.mdx": "906cfe5130b49384abdec6b312ff58a31acb093fd26e73e1a8a5a5740a49c4ed", + "content/docs/troubleshooting.mdx": "5fd41876e66537ab7631da818a0c3a3b9cbcc17ce2032da5debf5d7d297ca5ce", "content/docs/troubleshooting.zh.mdx": "6d8b4668e8c371120a9275daabbebc0d1ecf21a42ba32beeb92853c14b5a459a" } } diff --git a/apps/docs/scripts/generate-guides.mjs b/apps/docs/scripts/generate-guides.mjs index 81d195247..5e8e157a0 100644 --- a/apps/docs/scripts/generate-guides.mjs +++ b/apps/docs/scripts/generate-guides.mjs @@ -8,7 +8,7 @@ const app = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const repo = path.resolve(app, '../..') const guides = JSON.parse(fs.readFileSync(path.join(app, 'scripts/guides.json'), 'utf8')) const routes = new Map(guides.map(g => [g.source, g.slug === 'index' ? '/' : '/' + g.slug])) -const sourceRevision = 'f9871472eb49e6f8f2d7fa61d4345354588751c7' +const sourceRevision = 'cd6a32c3de31d26b73ab92d21ba2aa7b067ba226' const sourceURL = relative => `https://github.com/MiniMax-AI/parsar-core/blob/${sourceRevision}/${relative}` const digest = text => crypto.createHash('sha256').update(text).digest('hex') function mdx(text) { From ea1591af702901202c3559fb48a72715e9ff2fd3 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 15:15:46 +0000 Subject: [PATCH 12/78] Settle executor cleanup and terminal handoff independently --- CONTRIBUTING.md | 11 +- .../internal/agent/codex/executor.go | 18 +- .../internal/agent/codex/executor_test.go | 60 +++++ .../agent/codex/preparation_helpers_test.go | 8 + .../dispatch/executor_handoff_test.go | 228 ++++++++++++++++++ .../internal/dispatch/prepared_handoff.go | 31 ++- 6 files changed, 336 insertions(+), 20 deletions(-) create mode 100644 apps/parsar-daemon/internal/dispatch/executor_handoff_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 769981bcf..ff5d92c26 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1606,7 +1606,10 @@ confirms that the native owner can accept the next Turn. `Reusable=false` requir a reason and subsequent confirmed Executor close. An error means settlement is unconfirmed; it cannot free ownership or capacity. Caller deadlines stop waiting, not tracked cleanup. Retry the same cleanup target serially. Failed cleanup -blocks replacement and retains its resource slot. +blocks replacement and retains its resource slot. Executor Close confirms resource +retirement independently of the Turn outcome: an immutable Turn error must not +prevent closing the native transport and releasing resources once their work and +output have stopped. One output consumer starts before native Start, drains the bounded 64-frame channel, and retains the terminal observation until Start publication, Turn @@ -1614,7 +1617,11 @@ settlement and admitted operation receipts finish. Natural Done never calls Cancel. Input, function and interaction admission close before settlement, and operations already admitted hold their barrier through native receipts and outbound acknowledgement. Only then forward Done or an applied cancellation -receipt. Preserve the ten-second settlement wait and separate five-second receipt +receipt. Commit native continuity and release the old Run admission before +publishing Done, since the receiver may immediately start another Turn. A late +terminal-send failure belongs to the old Run; it cannot invalidate a successor +that already owns the Executor. Connection shutdown owns transport-loss cleanup. +Preserve the ten-second settlement wait and separate five-second receipt send budget; timeout is not proof of quiescence. The observed cancellation outcome retains native identity, Usage and output without fabricating missing evidence. diff --git a/apps/parsar-daemon/internal/agent/codex/executor.go b/apps/parsar-daemon/internal/agent/codex/executor.go index 954a29d0f..8692b3ce2 100644 --- a/apps/parsar-daemon/internal/agent/codex/executor.go +++ b/apps/parsar-daemon/internal/agent/codex/executor.go @@ -5,6 +5,7 @@ import ( "errors" "strings" "sync" + "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -121,10 +122,11 @@ func (e *Executor) Close(ctx context.Context) error { select { case <-running.waitDone: default: - if err := running.Cancel(ctx); err != nil { - done <- err - return - } + // Try native cancellation before closing its transport, but never + // let an absent terminal receipt prevent resource retirement. + cancelCtx, stop := context.WithTimeout(ctx, 10*time.Second) + _ = running.Cancel(cancelCtx) + stop() } } base.cancelFn() @@ -133,7 +135,13 @@ func (e *Executor) Close(ctx context.Context) error { active := e.active e.mu.Unlock() if err == nil && active != nil { - _, err = active.AwaitSettlement(ctx) + // A Turn's permanent outcome error survives Close. Cleanup succeeds + // when its work and output have stopped, not when that outcome changes. + select { + case <-active.waitDone: + case <-ctx.Done(): + err = ctx.Err() + } } if err == nil { err = base.rpc.awaitReaders(ctx) diff --git a/apps/parsar-daemon/internal/agent/codex/executor_test.go b/apps/parsar-daemon/internal/agent/codex/executor_test.go index 39413ecfa..c991dd626 100644 --- a/apps/parsar-daemon/internal/agent/codex/executor_test.go +++ b/apps/parsar-daemon/internal/agent/codex/executor_test.go @@ -185,3 +185,63 @@ func TestExecutorCloseRetainsPlanUntilReaped(t *testing.T) { t.Fatal("cleanup retry did not release plan") } } + +func TestExecutorCloseSeparatesTurnFailureFromResourceCleanup(t *testing.T) { + e, root := executorFixture(t, "interrupt-error") + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), "cancel-failure", proto.TextInput("hold"), out) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if err := turn.Cancel(ctx); err == nil { + t.Fatal("native interrupt failure was hidden") + } + if _, err := turn.AwaitSettlement(ctx); err == nil { + t.Fatal("failed Turn unexpectedly settled successfully") + } + if err := e.Close(ctx); err != nil { + t.Fatal("failed Turn prevented confirmed resource cleanup", err) + } + if e.prepared.session.rpc.Alive() || len(preparedCatalogs(t, root)) != 0 { + t.Fatal("Close did not release native process and plan") + } + if _, err := turn.AwaitSettlement(ctx); err == nil { + t.Fatal("resource cleanup fabricated successful Turn settlement") + } +} + +func TestExecutorCloseTerminatesAfterMissingCancellationTerminal(t *testing.T) { + e, root := executorFixture(t, "interrupt-no-terminal") + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), "missing-terminal", proto.TextInput("hold"), out) + if err != nil { + t.Fatal(err) + } + cancelCtx, stopCancel := context.WithTimeout(t.Context(), 5*time.Second) + defer stopCancel() + cancelled := make(chan error, 1) + go func() { cancelled <- turn.Cancel(cancelCtx) }() + waitPreparationMethod(t, root, "turn/interrupt") + closeCtx, stopClose := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer stopClose() + if err := e.Close(closeCtx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatal("missing receipt did not reach the cleanup deadline", err) + } + // Retrying waits on the same owner; the expired observation must not skip + // the transport close that can actually stop an unresponsive native Turn. + retry, stopRetry := context.WithTimeout(t.Context(), 2*time.Second) + defer stopRetry() + if err := e.Close(retry); err != nil { + t.Fatal("Close never retired the native process", err) + } + if e.prepared.session.rpc.Alive() || len(preparedCatalogs(t, root)) != 0 { + t.Fatal("unresponsive native process or plan still owned after Close") + } + select { + case <-cancelled: + case <-retry.Done(): + t.Fatal("original cancellation waiter was abandoned") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go b/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go index 237d4f329..857e0082d 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go @@ -185,6 +185,11 @@ func TestPreparationFakeCodexProcess(t *testing.T) { } } result = map[string]any{"thread": map[string]string{"id": "fixture-native-thread"}, "model": "fixture-model"} + case "turn/interrupt": + if executorMode == "interrupt-error" { + _ = output.Encode(map[string]any{"id": frame.ID, "error": map[string]any{"code": -32603, "message": "interrupt rejected"}}) + continue + } case "turn/start": if executorMode != "" { turnNumber++ @@ -209,6 +214,9 @@ func TestPreparationFakeCodexProcess(t *testing.T) { } if executorMode != "" && (frame.Method == "turn/start" || frame.Method == "turn/interrupt") { + if frame.Method == "turn/interrupt" && executorMode == "interrupt-no-terminal" { + continue + } if frame.Method == "turn/start" { _ = output.Encode(map[string]any{"method": "turn/started", "params": map[string]any{"threadId": "fixture-native-thread", "turn": map[string]string{"id": currentTurn}}}) } diff --git a/apps/parsar-daemon/internal/dispatch/executor_handoff_test.go b/apps/parsar-daemon/internal/dispatch/executor_handoff_test.go new file mode 100644 index 000000000..730698ef6 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/executor_handoff_test.go @@ -0,0 +1,228 @@ +package dispatch + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type terminalHandoffSender struct { + frames chan proto.Envelope + visible chan struct{} + release chan struct{} + failure error +} + +func (s *terminalHandoffSender) Send(ctx context.Context, env proto.Envelope) error { + select { + case s.frames <- env: + case <-ctx.Done(): + return ctx.Err() + } + if env.Type == proto.TypeDone && env.ID == "first" { + close(s.visible) + select { + case <-s.release: + return s.failure + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} + +type terminalHandoffExecutor struct { + turns chan *terminalHandoffTurn + closes atomic.Int32 +} + +func (e *terminalHandoffExecutor) Close(context.Context) error { e.closes.Add(1); return nil } +func (e *terminalHandoffExecutor) StartTurn(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + turn := &terminalHandoffTurn{id: id, out: out, settled: make(chan struct{})} + e.turns <- turn + return turn, nil +} + +type terminalHandoffTurn struct { + id string + out chan<- proto.Envelope + settled chan struct{} + once sync.Once +} + +func (t *terminalHandoffTurn) finish() { + t.once.Do(func() { + env, _ := proto.NewEnvelope(proto.TypeDone, t.id, t.CancellationOutcome()) + t.out <- env + close(t.out) + close(t.settled) + }) +} +func (t *terminalHandoffTurn) Cancel(context.Context) error { t.finish(); return nil } +func (t *terminalHandoffTurn) CancellationOutcome() proto.DonePayload { + return proto.DonePayload{Content: t.id, Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native"}} +} +func (t *terminalHandoffTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + select { + case <-t.settled: + return agent.TurnSettlement{Reusable: true}, nil + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} + +func TestPreparedDonePublishesAfterExecutorHandoff(t *testing.T) { + for _, fail := range []bool{false, true} { + name := "delivered" + if fail { + name = "late_delivery_error" + } + t.Run(name, func(t *testing.T) { + sender := &terminalHandoffSender{frames: make(chan proto.Envelope, 32), visible: make(chan struct{}), release: make(chan struct{})} + if fail { + sender.failure = errors.New("old terminal delivery failed") + } + owner := &terminalHandoffExecutor{turns: make(chan *terminalHandoffTurn, 3)} + registry := agent.NewRegistry() + registry.RegisterKind(proto.SupportedAgentKind{Kind: "handoff", Available: true, Capabilities: proto.AgentKindCapabilities{EnvironmentNone: true}}, + func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("legacy path forbidden") + }) + var creates atomic.Int32 + registry.RegisterExecutor("handoff", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + creates.Add(1) + return owner, nil + }) + r, err := New(Config{Registry: registry, Sender: sender, IdleTimeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(sender.release) }) } + t.Cleanup(func() { + unblock() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := r.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + handle := func(kind, id string, payload any) { + t.Helper() + env, err := proto.NewEnvelope(kind, id, payload) + if err != nil { + t.Fatal(err) + } + if err = r.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + } + status := func(id, state string) proto.PreparationStatusPayload { + t.Helper() + deadline := time.After(time.Second) + for { + select { + case env := <-sender.frames: + if env.Type != proto.TypePreparationStatus || env.ID != id { + continue + } + var value proto.PreparationStatusPayload + if err := env.DecodePayload(&value); err != nil { + t.Fatal(err) + } + if value.State == state { + return value + } + if value.State == "rejected" || value.State == "failed" { + t.Fatalf("next admission failed: %+v", value) + } + case <-deadline: + t.Fatal("preparation status missing") + } + } + } + request := proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "handoff", AgentStateKey: "agents-api-session", StrictResume: true, DisableExecutionEnvironment: true}} + admit := func(id string) proto.PreparationStatusPayload { + t.Helper() + handle(proto.TypeExecutionPrepare, id, request) + return status(id, "ready") + } + start := func(id string, p proto.PreparationStatusPayload) *terminalHandoffTurn { + t.Helper() + handle(proto.TypeExecutionStart, id, proto.ExecutionStartPayload{Handle: p.Handle, ExecutorID: p.ExecutorID, RunID: id, Input: proto.TextInput(id)}) + status(id, "started") + select { + case turn := <-owner.turns: + return turn + case <-time.After(time.Second): + t.Fatal("native Turn missing") + return nil + } + } + first := admit("first") + old := start("first", first) + r.mu.Lock() + handoff := r.sessions["first"].preparedHandoff + r.mu.Unlock() + old.finish() + select { + case <-sender.visible: + case <-time.After(time.Second): + t.Fatal("Done not visible") + } + // Simulate Core admitting its successor after seeing Done, while the old + // Send is still blocked. Exact native continuity must already be committed. + request.Configuration.AgentSessionID = "native" + request.Configuration.RequireExistingNativeSession = true + second := admit("second") + if second.ExecutorID != first.ExecutorID || !second.Reused || creates.Load() != 1 { + t.Fatal("settled owner was replaced") + } + next := start("second", second) + unblock() + r.mu.Lock() + settled := handoff.release.settled + r.mu.Unlock() + select { + case <-settled: + case <-time.After(time.Second): + t.Fatal("old publication did not finish") + } + r.mu.Lock() + current := r.executors["session"] + valid := current != nil && !current.invalid && current.run == r.sessions["second"] && current.admission == r.preparations[second.Handle] + deliveryErr := handoff.outputErr + r.mu.Unlock() + if !valid || r.ActiveRuns() != 1 || owner.closes.Load() != 0 { + t.Fatal("old terminal cleanup damaged its successor") + } + if (deliveryErr != nil) != fail { + t.Fatalf("delivery error=%v", deliveryErr) + } + next.finish() + // The second terminal does not block: observing it must likewise permit an + // immediate third admission without losing or recreating the retained owner. + for { + select { + case env := <-sender.frames: + if env.Type == proto.TypeDone && env.ID == "second" { + goto completed + } + case <-time.After(time.Second): + t.Fatal("second Done missing") + } + } + completed: + third := admit("third") + if third.ExecutorID != first.ExecutorID || !third.Reused || creates.Load() != 1 { + t.Fatal("successor failed to retain owner") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff.go index 3df8b2934..1b845b300 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff.go +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff.go @@ -240,18 +240,17 @@ func (r *Router) runPreparedRelease(state *sessionState, handoff *preparedHandof r.mu.Lock() outputErr, terminal, closed := handoff.outputErr, handoff.terminal, r.closed r.mu.Unlock() - var terminalErr error - if outputErr == nil && !closed && turn != nil { - terminalErr = r.forwardPreparedTerminal(state, release.failure, terminal, terminal == nil) - } + // Done can become visible before Sender.Send returns. Commit the settled + // owner and retire this Run before publication so an immediate successor + // observes both the new native identity and an available Executor. r.cleanupSession(state) r.mu.Lock() - handoff.outputErr = errors.Join(handoff.outputErr, terminalErr) p := handoff.preparation p.busy, p.owns, p.handoff = false, false, nil - p.closeErr = terminalErr p.cancel() - owner.run = nil + if owner.run == state { + owner.run = nil + } if owner.invalid && owner.closeDone != nil && owner.closeErr == nil && r.executors[owner.sessionID] == owner { delete(r.executors, owner.sessionID) } @@ -263,17 +262,23 @@ func (r *Router) runPreparedRelease(state *sessionState, handoff *preparedHandof owner.nativeID = id } } - if terminalErr != nil { - owner.invalid = true - } if !owner.invalid { r.scheduleExecutorIdleLocked(owner) } - close(release.settled) r.mu.Unlock() - if terminalErr != nil { - _ = r.closeExecutor(owner) + + var terminalErr error + if outputErr == nil && !closed && turn != nil { + terminalErr = r.forwardPreparedTerminal(state, release.failure, terminal, terminal == nil) } + r.mu.Lock() + // Delivery failure belongs to this Run, not to a successor that may have + // already acquired the settled owner. Connection shutdown owns transport + // failure cleanup; terminal publication does not change native settlement. + handoff.outputErr = errors.Join(handoff.outputErr, terminalErr) + p.closeErr = terminalErr + close(release.settled) + r.mu.Unlock() } func (r *Router) forwardPreparedOutput(state *sessionState) { From f1d4132e543cbe1b8bbef4b3b5084adb3e3f3b9e Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 15:23:14 +0000 Subject: [PATCH 13/78] Distinguish delivered metrics from remaining aggregation proposals --- .../web/admin-metrics-backend-requirements.md | 41 ++++++++----------- ...dmin-metrics-backend-requirements.zh-CN.md | 21 +++------- 2 files changed, 24 insertions(+), 38 deletions(-) diff --git a/docs/web/admin-metrics-backend-requirements.md b/docs/web/admin-metrics-backend-requirements.md index ad1c988f8..0dda44a4d 100644 --- a/docs/web/admin-metrics-backend-requirements.md +++ b/docs/web/admin-metrics-backend-requirements.md @@ -1,7 +1,9 @@ # Administrator metrics: backend requirements -Status: proposal for discussion with Core owners. Nothing in this document is -implemented beyond the Web API routes it names as existing. +Status reviewed 2026-09-28: P0 Agent/Tool aggregates (BE-8) remain unimplemented +proposals. Core process metrics and node host history have landed through their +own contracts, linked below. Other P1/P2 ideas are proposals, not an accepted +implementation backlog or a statement that all observability is missing. [简体中文](admin-metrics-backend-requirements.zh-CN.md) The console is a management tool: Monitor (Overview, Agent metrics, Sandbox @@ -28,7 +30,7 @@ the browser work. | --- | --- | --- | | Overview | `GET /summary` (per project: asset counts, Sessions by status, usage, coverage, last activity); `/core/v1/sandbox` deployment and nodes; each project's Session list for the 24-hour activity chart and the Sessions needing attention | Session lists stop once they pass the 24-hour window and have found every Session the summary counts as needing attention, at most 1,000 per project; projects idle since before the window are not read | | Agent metrics | `GET /summary` to skip idle projects; each project's Session list, then Turns and Items of the most recently active Sessions through the project's scope; `GET /summary?group_by=key` for usage by API key | 2,000 Sessions listed per project; 200 Sessions read per load, 10 Turn pages and 5 Item pages per Session, 15 s per Session and 45 s per load | -| Sandbox metrics | `/core/v1/sandbox` nodes and allocations; `GET /core/v1/sandbox/runtime-observations` (every project); each hosted Session read by ID through its project; Runtime history per hosted Session | 100 hosted Sessions read per refresh; history covers at most 24 hosted Sessions; host figures are free memory/disk and CPU count only | +| Sandbox metrics | `/core/v1/sandbox` nodes and allocations; `GET /core/v1/sandbox/runtime-observations` (every project); each hosted Session read by ID through its project; Runtime history per hosted Session | 100 hosted Sessions read per refresh; history covers at most 24 hosted Sessions; node host observations and history are available through the separate node-detail contract below | Consequences the console states in its help tips and warnings: @@ -75,22 +77,14 @@ Per bucket and tool (`function` name, MCP `server_label` + name, shell command, web search, Subagent): calls, failures, and duration where the Item reports one. This replaces the Item fan-out. -### P1 — Core process and host status +### Delivered — Core process metrics -The Overview shows Core itself beside its sandbox hosts. Core runs no -sandboxes, so it has no slots; the operator asked for its CPU and memory -instead. Nothing reports them, so the console shows the Web API's reachability -and "Not reported" for CPU and memory. - -`GET /core/v1/core-status` - -- Core version and process uptime. -- Process CPU utilization (ratio of one core, averaged over a short window) and - the host's CPU cores and utilization. -- Process resident memory, host memory total and available. -- Database reachability and connection-pool use. -- `null` for any figure the platform cannot read (for example inside a - restricted container), never zero. +`GET /core/v1/metrics?range=1h|6h|24h|7d` reports Core process CPU/RSS and +limits, execution queue/slots, PostgreSQL and background-job measurements. +See [Core metrics](../../contracts/agents-api/core-metrics.md) for exact units, +nulls and retention. The earlier proposed `/core/v1/core-status` route was not +adopted. Whole-host CPU is not a Core process metric; additional host fields are +separate proposals rather than missing parts of BE-8. ### P1 — Session activity and attention @@ -114,12 +108,13 @@ This is what hosted consoles call "requests" and "error rate". Store rollups in the existing PostgreSQL database with bounded retention, following the Runtime history pattern, with the optional OTLP exporter as a secondary sink. -### P1 — Node utilization and history +### Delivered — Node host observations and history -- Extend `GET /core/v1/sandbox/nodes` with host CPU utilization, memory used and - total, disk used and total, node software version and process uptime. -- `GET /core/v1/sandbox/nodes/{id}/history?start=&end=&step=` for active, - retained and reserved sandboxes and host utilization over time. +`GET /core/v1/sandbox/nodes/{node_id}?range=1h|6h|24h` includes node host +history. See [node host history](../../contracts/agents-api/node-host-history.md) +for available fields, nulls, freshness and aggregation. The earlier proposed +`/nodes/{id}/history` route was not adopted. Further list fields and allocation +series require a separate agreed scope; they are not prerequisites for BE-8. ### P2 — Hosted Runtime rows with their Sessions diff --git a/docs/web/admin-metrics-backend-requirements.zh-CN.md b/docs/web/admin-metrics-backend-requirements.zh-CN.md index 5388d667e..87e9e5b14 100644 --- a/docs/web/admin-metrics-backend-requirements.zh-CN.md +++ b/docs/web/admin-metrics-backend-requirements.zh-CN.md @@ -1,6 +1,6 @@ # 管理员指标:后端需求 -状态:待与 Core 负责人讨论的提案。除文中注明已存在的 Web API 路由外,其余内容均未实现。[English](admin-metrics-backend-requirements.md) +状态核对于 2026-09-28:P0 Agent/工具聚合(BE-8)仍是未实现提案。Core 进程指标与节点主机历史已经通过下文链接的独立契约落地。其余 P1/P2 是待讨论设想,不代表已接受的开发清单,也不意味着现有观测能力全部缺失。[English](admin-metrics-backend-requirements.md) 控制台是管理工具,以监控为先:概览、Agent 监控、沙箱监控、Session 日志。它只读取 Web API(`/core/v1/**`,包括 `/core/v1/sandbox/**` 下的沙箱管理路由),从不调用 `/v1`。部分数字来自 Web API 的汇总,其余仍由浏览器对各项目做有上限的读取后自行汇总。本文说明这样做的代价、哪里不完整,以及哪些 Web API 接口可以替代浏览器端的计算。 @@ -15,7 +15,7 @@ | --- | --- | --- | | 概览 | `GET /summary`(按项目:资产数量、各状态 Session 数、用量、覆盖率、最近活跃);`/core/v1/sandbox` 的部署与节点;读取各项目的 Session 列表,用于 24 小时活动图和需要处理的 Session | Session 列表读过 24 小时窗口、并找到汇总所计的全部需要处理的 Session 后即停止,每个项目最多 1,000 个;窗口开始前就不再活跃的项目不读取 | | Agent 监控 | 用 `GET /summary` 跳过不活跃的项目;读取各项目的 Session 列表,再经项目作用域读取最近活跃 Session 的 Turn 与 Item;按 API 密钥的用量来自 `GET /summary?group_by=key` | 每个项目最多列出 2,000 个 Session;每次最多读取 200 个 Session,每个 Session 最多 10 页 Turn、5 页 Item,每个 Session 限时 15 秒、每次加载 45 秒 | -| 沙箱监控 | `/core/v1/sandbox` 的节点与分配;`GET /core/v1/sandbox/runtime-observations`(全部项目);经所属项目按 ID 读取每个托管 Session;按托管 Session 读取运行时历史 | 每次刷新最多读取 100 个托管 Session;历史最多覆盖 24 个托管 Session;主机指标只有可用内存/磁盘和 CPU 核数 | +| 沙箱监控 | `/core/v1/sandbox` 的节点与分配;`GET /core/v1/sandbox/runtime-observations`(全部项目);经所属项目按 ID 读取每个托管 Session;按托管 Session 读取运行时历史 | 每次刷新最多读取 100 个托管 Session;历史最多覆盖 24 个托管 Session;节点主机观测与历史通过下文的节点详情契约提供 | 控制台在问号提示和告警中说明的后果: @@ -51,17 +51,9 @@ 按时间桶和工具(`function` 名称、MCP 的 `server_label` 加名称、Shell 命令、网页搜索、子 Agent)返回调用数、失败数,以及 Item 上报时的耗时。它可以替代逐 Session 读取 Item。 -### P1:Core 进程与主机状态 +### 已实现:Core 进程指标 -概览在沙箱宿主机旁边单独显示 Core 本身。Core 不运行沙箱,因此没有槽位;负责人要求改为显示它的 CPU 和内存。目前没有任何接口上报这些数据,所以控制台只显示 Web API 是否可访问、是否处于维护模式,CPU 和内存显示“未报告”。 - -`GET /core/v1/core-status` - -- Core 版本和进程运行时长。 -- 进程 CPU 利用率(以一个核为单位,按短时间窗口平均),以及主机的 CPU 核数和利用率。 -- 进程常驻内存,主机内存总量和可用量。 -- 数据库是否可达,以及连接池使用情况。 -- 平台读不到的数字(例如受限容器内)返回 `null`,绝不返回 0。 +`GET /core/v1/metrics?range=1h|6h|24h|7d` 提供 Core 进程 CPU/RSS 与限额、执行队列与槽位、PostgreSQL 和后台任务观测。单位、空值和保留范围以 [Core 指标契约](../../contracts/agents-api/core-metrics.md) 为准。原提议的 `/core/v1/core-status` 路由未采用。整机 CPU 不等于 Core 进程指标;其他主机字段是独立设想,不属于 BE-8 的缺失项。 ### P1:Session 活动与需要处理的 Session @@ -76,10 +68,9 @@ 由 API 路由中间件记录:按路由族(Session、事件流、Turn、Item、文件等)统计请求数、4xx/5xx 数和延迟分位数。这就是托管平台控制台里的“请求数”和“错误率”。建议参照运行时历史,把汇总写入现有 PostgreSQL 并设保留期,可选 OTLP 导出作为第二去向。 -### P1:节点利用率与历史 +### 已实现:节点主机观测与历史 -- 在 `GET /core/v1/sandbox/nodes` 中增加:主机 CPU 利用率、内存已用与总量、磁盘已用与总量、节点软件版本、进程运行时长。 -- `GET /core/v1/sandbox/nodes/{id}/history?start=&end=&step=`:活跃、保留、预留沙箱数和主机利用率随时间的变化。 +`GET /core/v1/sandbox/nodes/{node_id}?range=1h|6h|24h` 提供节点主机历史。字段、空值、新鲜度和聚合规则以 [节点主机历史契约](../../contracts/agents-api/node-host-history.md) 为准。原提议的 `/nodes/{id}/history` 路由未采用。进一步扩展列表字段或分配数量的时间序列,需要单独确定范围,不是 BE-8 的前置条件。 ### P2:托管运行时连同 Session 信息 From 5b5c10fc4afeb20a1f5ac52207a9810ece85dbed Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 15:26:26 +0000 Subject: [PATCH 14/78] Preserve native settlement evidence through executor cleanup --- CONTRIBUTING.md | 14 ++- .../agent/claudesdk/commands_session_test.go | 4 +- .../claudesdk/executor_confirmation_test.go | 70 +++++++++++++ .../agent/claudesdk/executor_fixture_test.go | 3 +- .../internal/agent/claudesdk/executor_test.go | 20 +++- .../internal/agent/claudesdk/executor_turn.go | 12 ++- .../agent/claudesdk/functions_test.go | 4 +- .../internal/agent/claudesdk/session.go | 9 +- .../claudesdk/workspace_directory_test.go | 3 +- .../agent/claudesdk/workspace_launch_test.go | 2 +- .../agent/claudesdk/workspace_read_test.go | 3 +- .../internal/agent/codex/executor_test.go | 19 +++- .../internal/agent/codex/executor_turn.go | 11 ++- .../agent/mcode/environment_mcp_test.go | 4 +- .../agent/mcode/executor_backpressure_test.go | 99 +++++++++++++++++++ .../agent/mcode/executor_native_test.go | 10 +- .../internal/agent/mcode/executor_test.go | 8 +- .../internal/agent/mcode/executor_turn.go | 18 ++-- .../internal/agent/mcode/session.go | 8 +- .../internal/agent/mcode/session_test.go | 9 ++ packages/claude-sdk-adapter/src/adapter.ts | 4 +- .../src/executor_protocol.ts | 6 +- .../tests/executor.test.mjs | 14 ++- scripts/name-allowlist.json | 20 ++++ 24 files changed, 315 insertions(+), 59 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/executor_confirmation_test.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/executor_backpressure_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff5d92c26..584f24c6f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1601,7 +1601,10 @@ input was submitted. Recheck the same physical peer and current authorization. `Turn.Cancel` targets only that Turn and does not close a healthy Executor. `AwaitSettlement` applies after both natural completion and cancellation. Success means output can no longer be written and the Turn's native events, input, -functions, interactions and child work have settled. `Reusable=true` additionally +functions, interactions and child work have settled. Native completion or +cancellation confirmation is independent of resource retirement: closing a +transport cannot supply missing native terminal or operation receipts. +`Reusable=true` additionally confirms that the native owner can accept the next Turn. `Reusable=false` requires a reason and subsequent confirmed Executor close. An error means settlement is unconfirmed; it cannot free ownership or capacity. Caller deadlines stop waiting, @@ -3213,7 +3216,14 @@ The SDK's per-block assistant snapshots replace draft block text; only native Thinking/tool-only messages produce no text Items; interrupted messages retain their streamed partial text. No phase is inferred from the final result. Turn-owned native work and output draining precede reuse. Executor close releases -the SDK Query and native process. +the SDK Query and native process. The private `turn_settled` frame requires +`confirmed` independently of `reusable`: confirmed native Turn/cancellation +settlement, confirmed resource cleanup, and reuse eligibility are separate facts. +Unknown or nonempty interrupt receipts and unsettled input/function/tool work +remain unconfirmed even after successful teardown. Go rejects cancellation and +AwaitSettlement when native confirmation is missing or false, or its own receipt +ledger remains unsettled. A confirmed Turn may be non-reusable after cleanup; +that state alone does not turn a verified cancellation into an error. `claudesdk.Config.Workspace` is a private, trusted operator binding for one qualified placement. It enables native Bash/Read/Edit and declared host functions diff --git a/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go b/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go index 5a2d6e41b..ebed0792c 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/commands_session_test.go @@ -119,8 +119,8 @@ func TestWorkspaceCommandCancellationAndBridgeFailuresCloseOnlyPendingCalls(t *t t.Fatal(err) } } else if mode == "commands-cancel" || mode == "commands-drained" { - if err := s.Cancel(ctx); err != nil { - t.Fatal(err) + if err := s.Cancel(ctx); (err == nil) != (mode == "commands-drained") { + t.Fatalf("command cancellation confirmation: %v", err) } } } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_confirmation_test.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_confirmation_test.go new file mode 100644 index 000000000..bf74b120b --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_confirmation_test.go @@ -0,0 +1,70 @@ +//go:build unix + +package claudesdk + +import ( + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestExecutorNativeConfirmationSurvivesCleanup(t *testing.T) { + for _, mode := range []string{"unknown_cancel", "queued_cancel", "missing_confirmation", "pending_function", "pending_input", "confirmed_closed"} { + t.Run(mode, func(t *testing.T) { + config, req := persistentConfig(t, mode) + if mode == "pending_function" { + req.FunctionTools = []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}} + } + owner, err := NewExecutorFactory(config)(t.Context(), req) + if err != nil { + t.Fatal(err) + } + defer owner.Close(context.Background()) + turn, out := consumeExecutorTurn(t, owner, "cancel", "wait") + if event := <-out; event.Type != proto.TypeDelta { + t.Fatal("initial output missing") + } + if mode == "pending_function" { + if event := <-out; event.Type != proto.TypeFunctionCall { + t.Fatal("function obligation missing") + } + } + var inputReceipt chan error + if mode == "pending_input" { + inputReceipt = make(chan error, 1) + go func() { + inputReceipt <- turn.(*session).SteerWithReceipt(t.Context(), proto.PromptSteerPayload{InputID: "input", Input: proto.TextInput("extra")}, nil) + }() + if event := <-out; event.Type != proto.TypeDelta { + t.Fatal("input write barrier missing") + } + } + cancelErr := turn.Cancel(t.Context()) + for range out { + } + settlement, settlementErr := turn.AwaitSettlement(t.Context()) + confirmed := mode == "confirmed_closed" + if (cancelErr == nil) != confirmed || (settlementErr == nil) != confirmed || settlement.Reusable { + t.Fatalf("cancel=%v settlement=%+v err=%v", cancelErr, settlement, settlementErr) + } + if inputReceipt != nil { + if err := <-inputReceipt; err == nil { + t.Fatal("missing native input receipt was confirmed") + } + } + // Resource cleanup succeeds independently. It must not erase the immutable + // native outcome used by repeat cancellation and Runtime acknowledgement. + if err := owner.Close(t.Context()); err != nil { + t.Fatal(err) + } + if (turn.Cancel(t.Context()) == nil) != confirmed { + t.Fatal("Close changed cancellation confirmation") + } + if _, err := turn.AwaitSettlement(t.Context()); (err == nil) != confirmed { + t.Fatal("Close changed Turn confirmation") + } + }) + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_fixture_test.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_fixture_test.go index 5b2c9244d..55768099f 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor_fixture_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_fixture_test.go @@ -46,7 +46,8 @@ func helperTurnOutput(scanner *bufio.Scanner, id string) (func(bridgeEvent), fun if !reusable { reason = "native_error" } - encode(bridgeEvent{Type: "turn_settled", Reusable: &reusable, Reason: reason}) + confirmed := true + encode(bridgeEvent{Type: "turn_settled", Confirmed: &confirmed, Reusable: &reusable, Reason: reason}) for scanner.Scan() { } } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go index 755a0bc53..c699b63ae 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go @@ -63,8 +63,18 @@ func runPersistentExecutorHelper() { } else { encode(bridgeEvent{Type: "result", TurnID: active, SessionID: "native-persistent", Text: active}) } - yes := true - encode(bridgeEvent{Type: "turn_settled", TurnID: active, Reusable: &yes}) + confirmed, reusable, reason := true, true, "" + switch os.Getenv("SDK_EXECUTOR_MODE") { + case "unknown_cancel", "queued_cancel": + confirmed, reusable, reason = false, false, "cancellation_unconfirmed" + case "confirmed_closed": + reusable, reason = false, "native_closed" + } + event := bridgeEvent{Type: "turn_settled", TurnID: active, Confirmed: &confirmed, Reusable: &reusable, Reason: reason} + if os.Getenv("SDK_EXECUTOR_MODE") == "missing_confirmation" { + event.Confirmed = nil + } + encode(event) old, active = active, "" } for scanner.Scan() { @@ -89,10 +99,16 @@ func runPersistentExecutorHelper() { encode(bridgeEvent{Type: "turn_started", TurnID: active}) encode(bridgeEvent{Type: "input_ready", TurnID: active, SessionID: "native-persistent"}) encode(bridgeEvent{Type: "delta", TurnID: active, Delta: "partial"}) + if os.Getenv("SDK_EXECUTOR_MODE") == "pending_function" { + encode(bridgeEvent{Type: "function_call", TurnID: active, Call: &proto.FunctionCallPayload{CallID: "call", Name: "lookup", Arguments: json.RawMessage("{}")}}) + } if len(command.Input) > 0 && len(command.Input[0].Content) > 0 && command.Input[0].Content[0].Text != nil && *command.Input[0].Content[0].Text == "wait" { continue } settle(false) + case "steer": + // A full bridge write has happened, but no native input receipt exists. + encode(bridgeEvent{Type: "delta", TurnID: active, Delta: "steer-written"}) case "turn_cancel": if active == command.TurnID { settle(true) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go index 14f5067ac..285f4caf1 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go @@ -56,6 +56,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { mcp := mcpState{calls: map[string]proto.ToolObservation{}} commands := commandState{calls: map[string]proto.ToolObservation{}} settlementReceived := false + settlementConfirmed := false reusable := false reason := "bridge_interrupted" for raw := range s.frames { @@ -69,12 +70,12 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { continue } if event.Type == "turn_settled" { - if !terminal || event.Reusable == nil || (!*event.Reusable && event.Reason == "") { + if !terminal || event.Reusable == nil || event.Confirmed == nil || (*event.Reusable && !*event.Confirmed) || (!*event.Reusable && event.Reason == "") { failure = fmt.Errorf("claudesdk: invalid Turn settlement") s.invalidate() break } - settlementReceived, reusable, reason = true, *event.Reusable, event.Reason + settlementReceived, settlementConfirmed, reusable, reason = true, *event.Confirmed, *event.Reusable, event.Reason break } if terminal { @@ -149,6 +150,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { case "result": if !s.matchesInputSession(event.SessionID) || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume || usageSession != "" && event.SessionID != usageSession || !s.functionsComplete() || !s.steeringComplete() || !mcp.complete() || !commands.complete() { failure = fmt.Errorf("claudesdk: invalid native completion or unconfirmed input/result") + s.settlementErr = failure s.invalidate() } else { result = &event @@ -172,7 +174,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { failure = fmt.Errorf("claudesdk: SDK result is missing") } if !s.functionsComplete() || !s.steeringComplete() || !mcp.complete() || !commands.complete() { - reusable, reason = false, "unsettled_native_operations" + settlementConfirmed, reusable, reason = false, false, "unsettled_native_operations" } mcp.close(start, emit) commands.close(start, emit) @@ -189,14 +191,14 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { } s.outcome = proto.DonePayload{Content: content.String(), Usage: usage, Metadata: metadata} // Publish the observed cancellation outcome before terminal delivery. - if !settlementReceived || !reusable || outputLost || s.process.Context().Err() != nil { + if s.settlementErr != nil || !settlementReceived || !settlementConfirmed || !reusable || outputLost || s.process.Context().Err() != nil { s.owner.retire() reusable = false if reason == "" { reason = "bridge_interrupted" } } - if !settlementReceived { + if !settlementReceived || !settlementConfirmed { s.settlementErr = fmt.Errorf("claudesdk: native Turn settlement is unconfirmed") } close(s.settled) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go b/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go index f64954b70..09045a612 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/functions_test.go @@ -75,8 +75,8 @@ func TestFunctionFactoryNativeReceipts(t *testing.T) { } case proto.TypeDelta: if mode == "functions-cancel" { - if err := running.Cancel(ctx); err != nil { - t.Fatal(err) + if err := running.Cancel(ctx); err == nil { + t.Fatal("unconfirmed function receipts became successful cancellation") } } case proto.TypeError: diff --git a/apps/parsar-daemon/internal/agent/claudesdk/session.go b/apps/parsar-daemon/internal/agent/claudesdk/session.go index dc612355f..f84ff79c0 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/session.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/session.go @@ -55,10 +55,11 @@ func NewFactory(config Config) agent.Factory { } type bridgeEvent struct { - TurnID string `json:"turn_id"` - Reusable *bool `json:"reusable"` - Reason string `json:"reason"` - Protocol int `json:"protocol"` + TurnID string `json:"turn_id"` + Reusable *bool `json:"reusable"` + Confirmed *bool `json:"confirmed"` + Reason string `json:"reason"` + Protocol int `json:"protocol"` Fact json.RawMessage `json:"fact"` InputID string `json:"input_id"` diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go index 2cb969cf2..bc4822ce4 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_directory_test.go @@ -32,7 +32,8 @@ func runWorkspaceDirectoryHelper(scanner *bufio.Scanner, state, mode string) { if req.Type == "turn_cancel" { reusable := false _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "error", TurnID: turnID, Code: "cancelled"}) - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_settled", TurnID: turnID, Reusable: &reusable, Reason: "fixture_closed"}) + confirmed := true + _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_settled", Confirmed: &confirmed, TurnID: turnID, Reusable: &reusable, Reason: "fixture_closed"}) return } if req.Type == "turn_start" { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go index 556db3e7f..5a3c4ad98 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go @@ -30,7 +30,7 @@ case "$1" in printf '%s\n' '{"type":"executor_ready","protocol":3}' IFS= read -r request printf '%s\n' '{"type":"result","turn_id":"run","session_id":"native","text":"completed"}' - printf '%s\n' '{"type":"turn_settled","turn_id":"run","reusable":true,"reason":""}' ;; + printf '%s\n' '{"type":"turn_settled","turn_id":"run","confirmed":true,"reusable":true,"reason":""}' ;; esac ` if err := os.WriteFile(config.Node, []byte(script), 0o700); err != nil { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go index ca46342ef..d9bfa2221 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_read_test.go @@ -31,7 +31,8 @@ func runWorkspaceReadHelper(scanner *bufio.Scanner, state, mode string) { if req.Type == "turn_cancel" { reusable := false _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "error", TurnID: turnID, Code: "cancelled"}) - _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_settled", TurnID: turnID, Reusable: &reusable, Reason: "fixture_closed"}) + confirmed := true + _ = json.NewEncoder(os.Stdout).Encode(bridgeEvent{Type: "turn_settled", Confirmed: &confirmed, TurnID: turnID, Reusable: &reusable, Reason: "fixture_closed"}) return } if req.Type == "turn_start" { diff --git a/apps/parsar-daemon/internal/agent/codex/executor_test.go b/apps/parsar-daemon/internal/agent/codex/executor_test.go index c991dd626..6cd32f60a 100644 --- a/apps/parsar-daemon/internal/agent/codex/executor_test.go +++ b/apps/parsar-daemon/internal/agent/codex/executor_test.go @@ -145,8 +145,18 @@ func TestExecutorStartErrorsRetainExactOwnership(t *testing.T) { if turn == nil || err == nil { t.Fatal("uncertain submission lost owner", err) } - if awaitExecutorTurn(t, turn, out).Reusable { - t.Fatal("failed submission was reusable") + settlement, settleErr := turn.AwaitSettlement(t.Context()) + if settleErr == nil || settlement.Reusable { + t.Fatal("failed submission fabricated native settlement", settlement, settleErr) + } + terminalCount := 0 + for frame := range out { + if frame.Type == proto.TypeDone { + terminalCount++ + } + } + if terminalCount != 1 { + t.Fatalf("failed submission terminal count = %d", terminalCount) } }) } @@ -240,7 +250,10 @@ func TestExecutorCloseTerminatesAfterMissingCancellationTerminal(t *testing.T) { t.Fatal("unresponsive native process or plan still owned after Close") } select { - case <-cancelled: + case cancelErr := <-cancelled: + if cancelErr == nil { + t.Fatal("resource cleanup fabricated native cancellation confirmation") + } case <-retry.Done(): t.Fatal("original cancellation waiter was abandoned") } diff --git a/apps/parsar-daemon/internal/agent/codex/executor_turn.go b/apps/parsar-daemon/internal/agent/codex/executor_turn.go index 216d26bbd..a356f30f7 100644 --- a/apps/parsar-daemon/internal/agent/codex/executor_turn.go +++ b/apps/parsar-daemon/internal/agent/codex/executor_turn.go @@ -33,7 +33,10 @@ func (s *Session) settleExecutorTurn(startErr error) { // replies. Later frames cannot acquire this Turn or redirect to a successor. if !s.nativeSettled.Load() || startErr != nil || !s.rpc.Alive() { s.cancelFn() - s.settlementErr = s.rpc.Close() + s.settlementErr = errors.Join(s.settlementErr, s.rpc.Close()) + } + if !s.nativeSettled.Load() { + s.settlementErr = errors.Join(s.settlementErr, errors.New("codex: native Turn settlement is unconfirmed")) } s.rpc.detachHandlers() s.operationMu.Lock() @@ -44,7 +47,7 @@ func (s *Session) settleExecutorTurn(startErr error) { s.stopCodexInteractionTimers() if !s.nativeSettled.Load() || startErr != nil || !s.rpc.Alive() { s.cancelFn() - s.settlementErr = s.rpc.Close() + s.settlementErr = errors.Join(s.settlementErr, s.rpc.Close()) s.settlement = agent.TurnSettlement{Reason: "native_execution_unavailable"} } else { s.settlement = agent.TurnSettlement{Reusable: true} @@ -57,7 +60,7 @@ func (s *Session) settleExecutorTurn(startErr error) { s.subagents.mu.Unlock() if err != nil { s.settlement = agent.TurnSettlement{Reason: "child_settlement_unconfirmed"} - s.settlementErr = errors.Join(err, s.rpc.Close()) + s.settlementErr = errors.Join(s.settlementErr, err, s.rpc.Close()) } } // The terminal callback has returned before detachHandlers completes. A @@ -70,7 +73,7 @@ func (s *Session) settleExecutorTurn(startErr error) { if s.cancelErr != nil { s.settlement.Reusable = false s.settlement.Reason = "cancellation_unconfirmed" - s.settlementErr = errors.Join(s.cancelErr, s.rpc.Close()) + s.settlementErr = errors.Join(s.settlementErr, s.cancelErr, s.rpc.Close()) } } s.cancelFn() diff --git a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go index e9cb6f18c..20d73b99c 100644 --- a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go @@ -124,8 +124,8 @@ func TestEnvironmentMCPCancelSettlesPendingObservationBeforeDone(t *testing.T) { case <-ctx.Done(): t.Fatal("MCP start was not observed") } - if err := session.Cancel(ctx); err == nil { - t.Fatal("unconfirmed MCP completion authorized reusable cancellation") + if err := session.Cancel(ctx); err != nil { + t.Fatal("stopped nonreusable MCP owner reported cancellation failure", err) } settlement, err := session.(*Session).AwaitSettlement(ctx) if err != nil || settlement.Reusable { diff --git a/apps/parsar-daemon/internal/agent/mcode/executor_backpressure_test.go b/apps/parsar-daemon/internal/agent/mcode/executor_backpressure_test.go new file mode 100644 index 000000000..f2f78c6df --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/executor_backpressure_test.go @@ -0,0 +1,99 @@ +package mcode + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestExecutorBlockedPromptWriteCanBeCancelledOrClosed(t *testing.T) { + for _, operation := range []string{"cancel", "close"} { + t.Run(operation, func(t *testing.T) { + e, _ := executorFixture(t, "executor-backpressure", true) + out := make(chan proto.Envelope, 32) + turn, err := e.StartTurn(t.Context(), "blocked", proto.TextInput(strings.Repeat("x", 4*1024*1024)), out) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(3 * time.Second) + for e.connection.writeMu.TryLock() { + e.connection.writeMu.Unlock() + if time.Now().After(deadline) { + t.Fatal("prompt did not reach native pipe") + } + time.Sleep(time.Millisecond) + } + // The native fixture acknowledged readiness but does not read stdin. + // A multi-megabyte prompt therefore fills the actual child process pipe. + result := make(chan error, 1) + go func() { + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + if operation == "cancel" { + result <- turn.Cancel(ctx) + } else { + result <- e.Close(ctx) + } + }() + select { + case err := <-result: + if operation == "cancel" && err == nil { + t.Fatal("unacknowledged cancellation was reported applied") + } + case <-time.After(3 * time.Second): + // Emergency cleanup avoids leaking the intentionally blocked fixture. + e.connection.process.Cancel() + t.Fatal(operation + " could not interrupt a blocked native write") + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + if err := e.Close(ctx); err != nil { + t.Fatal("Close did not retain cleanup ownership", err) + } + settlement, err := turn.AwaitSettlement(ctx) + if err == nil || settlement.Reusable { + t.Fatal("lost native prompt produced successful settlement", settlement, err) + } + for range out { + } + }) + } +} + +func TestExecutorUnknownSteeringOutcomeCannotBecomeAppliedCancellation(t *testing.T) { + e, _ := executorFixture(t, "executor-steer-unknown", true) + out := make(chan proto.Envelope, 32) + turn, err := e.StartTurn(t.Context(), "unknown-input", proto.TextInput("wait"), out) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + select { + case <-out: + case <-ctx.Done(): + t.Fatal("native prompt did not start") + } + session := turn.(*Session) + if err := session.Steer(ctx, proto.PromptSteerPayload{InputID: "input-unknown", Input: proto.TextInput("continue")}); err == nil { + t.Fatal("unknown native receipt was accepted") + } + if err := turn.Cancel(ctx); err == nil { + t.Fatal("unknown input became applied cancellation") + } + settlement, err := turn.AwaitSettlement(ctx) + if err == nil || settlement.Reusable { + t.Fatal("unknown input lost its settlement error", settlement, err) + } + if err := turn.Cancel(ctx); err == nil { + t.Fatal("repeated cancel lost original uncertainty") + } + if err := e.Close(ctx); err != nil { + t.Fatal("settlement failure prevented resource cleanup", err) + } + for range out { + } +} diff --git a/apps/parsar-daemon/internal/agent/mcode/executor_native_test.go b/apps/parsar-daemon/internal/agent/mcode/executor_native_test.go index 4a1e23949..5340c3f9f 100644 --- a/apps/parsar-daemon/internal/agent/mcode/executor_native_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/executor_native_test.go @@ -136,10 +136,10 @@ func TestNativeMCodeExecutorReuse(t *testing.T) { cancelErr := interrupted.Cancel(stopCtx) settlement, settleErr := interrupted.AwaitSettlement(stopCtx) stop() - if settleErr != nil || !settlement.Reusable { - if cancelErr == nil { - t.Fatal("uncertain cancellation reported success") - } + if settleErr != nil || cancelErr != nil { + t.Fatal("native cancellation did not settle", cancelErr, settleErr) + } + if !settlement.Reusable { t.Log("Native cancellation cannot establish reusable settlement; successor must use recovery") successor := make(chan proto.Envelope, 1) next, nextErr := e.StartTurn(ctx, "unsafe-successor", proto.TextInput("must not execute"), successor) @@ -164,7 +164,7 @@ func TestNativeMCodeExecutorReuse(t *testing.T) { } pid = e.connection.process.Cmd.Process.Pid run("recovery", "What exact marker did I ask you to remember at the beginning? Reply with only that marker.") - t.Log("Verified exact history continuation in a replacement native owner after uncertain cancellation") + t.Log("Verified exact history continuation in a replacement native owner after nonreusable cancellation") return } if cancelErr != nil { diff --git a/apps/parsar-daemon/internal/agent/mcode/executor_test.go b/apps/parsar-daemon/internal/agent/mcode/executor_test.go index 17f532041..f0e44f72c 100644 --- a/apps/parsar-daemon/internal/agent/mcode/executor_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/executor_test.go @@ -140,8 +140,8 @@ func TestExecutorCancellationWithoutRootProofInvalidatesAndLateCancelCannotRetar } ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) defer cancel() - if err = second.Cancel(ctx); err == nil { - t.Fatal("unproven root settlement reported reusable cancellation") + if err = second.Cancel(ctx); err != nil { + t.Fatal("stopped nonreusable owner reported cancellation failure", err) } settlement, err := second.AwaitSettlement(ctx) if err != nil || settlement.Reusable { @@ -209,8 +209,8 @@ func TestExecutorStartFailureOwnership(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) defer cancel() settlement, err := turn.AwaitSettlement(ctx) - if err != nil || settlement.Reusable { - t.Fatal("exited process remained reusable", settlement, err) + if err == nil || settlement.Reusable { + t.Fatal("native exit lost its settlement error", settlement, err) } raw, _ := os.ReadFile(record) if strings.Count(string(raw), "session/prompt\n") != 1 { diff --git a/apps/parsar-daemon/internal/agent/mcode/executor_turn.go b/apps/parsar-daemon/internal/agent/mcode/executor_turn.go index 540a6202b..6a43459f5 100644 --- a/apps/parsar-daemon/internal/agent/mcode/executor_turn.go +++ b/apps/parsar-daemon/internal/agent/mcode/executor_turn.go @@ -45,6 +45,9 @@ func (s *Session) runExecutorTurn() { if (s.cancelled && s.req.DisableSubagents) || s.inputUncertain || len(s.permissions) != 0 || len(s.questions) != 0 { reusable = false } + if s.inputUncertain && err == nil { + err = fmt.Errorf("mcode: native input outcome is unknown") + } metadata := map[string]any{proto.DoneMetaAgentSessionType: "mcode", proto.DoneMetaAgentSessionID: s.sessionID} s.outcome = proto.DonePayload{Content: s.content.String(), Metadata: metadata, SourceCompletedAtMS: s.rootCompletedAtMS} outcome := s.outcome @@ -69,6 +72,7 @@ func (s *Session) runExecutorTurn() { } s.executor.active = nil s.settlement = agent.TurnSettlement{Reusable: reusable} + s.settlementErr = err if !reusable { s.settlement.Reason = "native Turn did not establish reusable settlement" } @@ -100,7 +104,7 @@ func (s *Session) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, er } select { case <-s.settled: - return s.settlement, nil + return s.settlement, s.settlementErr case <-ctx.Done(): return agent.TurnSettlement{}, ctx.Err() } @@ -124,12 +128,12 @@ func (s *Session) cancelTurn(ctx context.Context) error { // Cancellation releases event backpressure but does not cancel native owner // context. ACP prompt completion and child/tool settlement decide reuse. s.outputCancel() + e.mu.Unlock() var err error if first { raw, _ := json.Marshal(map[string]string{"sessionId": s.sessionID}) err = s.writeContext(ctx, rpcFrame{JSONRPC: "2.0", Method: "session/cancel", Params: raw}) } - e.mu.Unlock() if first { if err == nil && s.req.StrictResume && !s.req.DisableSubagents { err = s.stopSubagents(ctx) @@ -142,12 +146,6 @@ func (s *Session) cancelTurn(ctx context.Context) error { if err != nil { return err } - settlement, err := s.AwaitSettlement(ctx) - if err != nil { - return err - } - if !settlement.Reusable { - return fmt.Errorf("mcode: cancellation did not establish reusable settlement") - } - return nil + _, err = s.AwaitSettlement(ctx) + return err } diff --git a/apps/parsar-daemon/internal/agent/mcode/session.go b/apps/parsar-daemon/internal/agent/mcode/session.go index 89f51251f..e65948f08 100644 --- a/apps/parsar-daemon/internal/agent/mcode/session.go +++ b/apps/parsar-daemon/internal/agent/mcode/session.go @@ -23,6 +23,7 @@ type Session struct { *connection executor *executor settlement agent.TurnSettlement + settlementErr error settled chan struct{} inputDone chan struct{} outputCancel context.CancelFunc @@ -275,16 +276,21 @@ func (s *Session) call(method string, params any, result any, prompt bool) error return err } if prompt && s.executor != nil { + // Serialize the admission check with the wire, but release the owner + // mutex before a pipe write so cancellation and Close can stop it. + s.connection.writeMu.Lock() s.executor.mu.Lock() s.mu.Lock() cancelled := s.cancelled s.mu.Unlock() if cancelled || s.executor.closed { s.executor.mu.Unlock() + s.connection.writeMu.Unlock() return errTurnCancelled } - err = s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: method, Params: raw}) s.executor.mu.Unlock() + err = json.NewEncoder(s.process.Stdin).Encode(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: method, Params: raw}) + s.connection.writeMu.Unlock() } else { err = s.write(rpcFrame{JSONRPC: "2.0", ID: json.RawMessage(id), Method: method, Params: raw}) } diff --git a/apps/parsar-daemon/internal/agent/mcode/session_test.go b/apps/parsar-daemon/internal/agent/mcode/session_test.go index 641fe93a0..8d0fb9537 100644 --- a/apps/parsar-daemon/internal/agent/mcode/session_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/session_test.go @@ -250,6 +250,11 @@ func TestMCodeProcess(t *testing.T) { } case "session/set_config_option": configurations++ + if scenario == "executor-backpressure" && configurations > 1 { + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Result: json.RawMessage("{}")}) + time.Sleep(time.Minute) + os.Exit(0) + } if scenario == "executor-preexit" && configurations > 1 { os.Exit(0) } @@ -327,6 +332,10 @@ func TestMCodeProcess(t *testing.T) { send(rpcFrame{JSONRPC: "2.0", ID: promptID, Result: raw}) continue case "mcode/session/steer": + if scenario == "executor-steer-unknown" { + send(rpcFrame{JSONRPC: "2.0", ID: frame.ID, Error: &rpcError{Code: -32000, Message: "Unknown input outcome"}}) + continue + } if scenario == "steer-lost" { os.Exit(0) } diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index 3ab8b88d7..7f63e0d6c 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -204,7 +204,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: if(subagents) for(const event of await subagents.facts()) await emit(event); await emit(turns.cancelled ? {type:"error",code:"cancelled"} : result); functions.close(); - await turns.settled(true,""); + await turns.settled(true,true,""); result=undefined; } } @@ -237,7 +237,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: if(turns) { if(turns.id) { await emit({type:"error",code: turns.cancelled && !cancellationFactsFailed ? "cancelled" : "execution_failed"}); - await turns.settled(false, turns.cancelled ? "cancellation_unconfirmed" : "native_execution_unavailable"); + await turns.settled(false, false, turns.cancelled ? "cancellation_unconfirmed" : "native_execution_unavailable"); } else if(failed && !abort.signal.aborted) await ownerEmit({type:"error",code:"execution_failed"}); return; } diff --git a/packages/claude-sdk-adapter/src/executor_protocol.ts b/packages/claude-sdk-adapter/src/executor_protocol.ts index ba4e4c162..b8e715adf 100644 --- a/packages/claude-sdk-adapter/src/executor_protocol.ts +++ b/packages/claude-sdk-adapter/src/executor_protocol.ts @@ -5,7 +5,7 @@ import { parseMessageInput, type MessageInput } from "./message_input.js"; export type ExecutorEvent = | { type: "executor_ready"; protocol: 3 } | { type: "turn_started"; turn_id: string } - | { type: "turn_settled"; turn_id: string; reusable: boolean; reason: string }; + | { type: "turn_settled"; turn_id: string; confirmed: boolean; reusable: boolean; reason: string }; type WireEvent = { type: string; [key: string]: unknown }; type Turn = { id: string; inputs: Inputs; cancelled: boolean; interrupt?: Promise; callbacks: Set> }; @@ -111,12 +111,12 @@ export class ExecutorTurns implements AsyncIterable { const turn = this.active; while (turn?.callbacks.size) await Promise.allSettled([...turn.callbacks]); } - async settled(reusable: boolean, reason: string): Promise { + async settled(confirmed: boolean, reusable: boolean, reason: string): Promise { const turn = this.active; if (!turn) return; turn.inputs.close(); this.active = undefined; - await this.output({ type: "turn_settled", turn_id: turn.id, reusable, reason }); + await this.output({ type: "turn_settled", turn_id: turn.id, confirmed, reusable, reason }); } close(): void { this.ended = true; this.active?.inputs.close(); this.wake?.(); } async *[Symbol.asyncIterator](): AsyncIterator { diff --git a/packages/claude-sdk-adapter/tests/executor.test.mjs b/packages/claude-sdk-adapter/tests/executor.test.mjs index 79ef95c4c..39c6f1e90 100644 --- a/packages/claude-sdk-adapter/tests/executor.test.mjs +++ b/packages/claude-sdk-adapter/tests/executor.test.mjs @@ -24,7 +24,7 @@ globalThis.startupFixture=async({options})=>{ options.abortController.signal.addEventListener('abort',close); return {close,query(prompt){assert.equal(queried,false);queried=true;process.send({type:'query'});return { close,async initializationResult(){return process.argv[1]==="no-hook-report" ? {} : process.argv[1]==="false-hook-report" ? {hooks_applied:false} : {hooks_applied:true}}, - async interrupt(){process.send({type:'interrupt'});interrupt?.();return process.argv[1]==='unknown' ? undefined : {still_queued:process.argv[1]==='queued' ? ['not-consumed'] : []}}, + async interrupt(){process.send({type:'interrupt'});interrupt?.();if(process.argv[1]==='rejected')throw new Error('interrupt failed');return process.argv[1]==='unknown' ? undefined : {still_queued:process.argv[1]==='queued' ? ['not-consumed'] : []}}, async *[Symbol.asyncIterator](){ for await(const user of prompt){ number++; @@ -76,7 +76,7 @@ async function launch(t,mode="normal") { test("executor retains one native process and one Query over two settled Turns",{timeout:10000},async t=>{ const {child,events,observations,closed,wait,start}=await launch(t); - for(const id of ["first","second"]){start(id,"answer");await wait(()=>events.some(event=>event.type==="turn_settled"&&event.turn_id===id));assert.equal(events.find(event=>event.type==="turn_settled"&&event.turn_id===id).reusable,true)} + for(const id of ["first","second"]){start(id,"answer");await wait(()=>events.some(event=>event.type==="turn_settled"&&event.turn_id===id));assert.equal(events.find(event=>event.type==="turn_settled"&&event.turn_id===id).confirmed,true);assert.equal(events.find(event=>event.type==="turn_settled"&&event.turn_id===id).reusable,true)} assert.equal(observations.filter(event=>event.type==="native").length,1); assert.equal(observations.filter(event=>event.type==="query").length,1); for(const event of events.filter(event=>event.type!=="executor_ready")) assert.ok(["first","second"].includes(event.turn_id)); @@ -95,6 +95,7 @@ test("public interrupt settles cancellation and stale cancellation cannot stop t start("first","hold");await wait(()=>events.some(event=>event.type==="input_ready")); send({type:"turn_cancel",turn_id:"first"}); await wait(()=>events.some(event=>event.type==="turn_settled")); + assert.equal(events.find(event=>event.type==="turn_settled").confirmed,true); assert.equal(events.find(event=>event.type==="turn_settled").reusable,true); assert.ok(events.some(event=>event.type==="error"&&event.code==="cancelled"&&event.turn_id==="first")); start("second","hold");await wait(()=>events.some(event=>event.type==="input_ready"&&event.turn_id==="second")); @@ -106,12 +107,15 @@ test("public interrupt settles cancellation and stale cancellation cannot stop t child.stdin.end();assert.deepEqual(await closed,{code:0,signal:null}); }); -for(const mode of ["queued","unknown"])test(`interrupt ${mode} receipt retires native ownership before non-reusable settlement`,{timeout:10000},async t=>{ +for(const mode of ["queued","unknown","rejected"])test(`interrupt ${mode} receipt retires native ownership before non-reusable settlement`,{timeout:10000},async t=>{ const {events,observations,closed,wait,send,start}=await launch(t,mode); start("first","hold");await wait(()=>events.some(event=>event.type==="input_ready")); send({type:"turn_cancel",turn_id:"first"}); await wait(()=>events.some(event=>event.type==="turn_settled")); - assert.equal(events.find(event=>event.type==="turn_settled").reusable,false); + const settlement=events.find(event=>event.type==="turn_settled"); + assert.equal(settlement.confirmed,false); + assert.equal(settlement.reusable,false); + assert.equal(settlement.reason,"cancellation_unconfirmed"); assert.equal(observations.filter(event=>event.type==="native_closed").length,1); assert.deepEqual(await closed,{code:0,signal:null}); }); @@ -125,6 +129,7 @@ test("reused Turns retain functions, native receipts, steering and message bound send({type:"function_result",turn_id:id,call_id:"same-native-call",delivery_id:"same-delivery-id",success:true,content:[{type:"input_text",text:"function result"}]}); await wait(()=>events.some(event=>event.type==="turn_settled"&&event.turn_id===id)); const own=events.filter(event=>event.turn_id===id); + assert.equal(own.find(event=>event.type==="turn_settled").confirmed,true); assert.equal(own.find(event=>event.type==="turn_settled").reusable,true); assert.equal(own.filter(event=>event.type==="function_applied").length,1); assert.equal(own.find(event=>event.type==="input_applied").input_id,"same-input-id"); @@ -138,6 +143,7 @@ for(const mode of ["no-hook-report","false-hook-report"])test("none Executor wit const {child,events,closed,wait,start}=await launch(t,mode); start("first","answer"); await wait(()=>events.some(event=>event.type==="turn_settled")); + assert.equal(events.find(event=>event.type==="turn_settled").confirmed,true); assert.equal(events.find(event=>event.type==="turn_settled").reusable,true); child.stdin.end();assert.deepEqual(await closed,{code:0,signal:null}); }); diff --git a/scripts/name-allowlist.json b/scripts/name-allowlist.json index a8c53ae8f..37e679122 100644 --- a/scripts/name-allowlist.json +++ b/scripts/name-allowlist.json @@ -823,5 +823,25 @@ "path": "apps/docs/lib/legacy-redirects.ts", "regex": "\"agents-api(?:-sessions|-errors)?\"", "reason": "Retired documentation URL keys redirect to the current public API guide; these are not current commands or API namespaces." + }, + { + "path": "apps/parsar-daemon/internal/cli/connect_cleanup_test.go", + "regex": "\"agents-api-cleanup\"", + "reason": "Executor ownership validates the current Core and local-workspace AgentStateKey namespace; this change does not introduce an alternate identity or compatibility path." + }, + { + "path": "apps/parsar-daemon/internal/dispatch/executor.go", + "regex": "\"agents-api-(?:session)?\"", + "reason": "Executor ownership validates the current Core and local-workspace AgentStateKey namespace; this change does not introduce an alternate identity or compatibility path." + }, + { + "path": "apps/parsar-daemon/internal/dispatch/executor_handoff_test.go", + "regex": "\"agents-api-(?:session)?\"", + "reason": "Executor ownership validates the current Core and local-workspace AgentStateKey namespace; this change does not introduce an alternate identity or compatibility path." + }, + { + "path": "apps/parsar-daemon/internal/dispatch/executor_test.go", + "regex": "\"agents-api-(?:session)?\"", + "reason": "Executor ownership validates the current Core and local-workspace AgentStateKey namespace; this change does not introduce an alternate identity or compatibility path." } ] From 2f4bca6e024cb8887c62e756751bed4d83d52364 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 15:38:59 +0000 Subject: [PATCH 15/78] Deliver cancellation before joining pending native receipts --- CONTRIBUTING.md | 7 +- .../dispatch/executor_cancel_receipt_test.go | 249 ++++++++++++++++++ .../internal/dispatch/prepared_handoff.go | 18 +- .../prepared_handoff_mutation_test.go | 30 +-- .../agents-api/tests/official_user_runtime.py | 4 +- 5 files changed, 284 insertions(+), 24 deletions(-) create mode 100644 apps/parsar-daemon/internal/dispatch/executor_cancel_receipt_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 584f24c6f..34ef00758 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1619,7 +1619,12 @@ channel, and retains the terminal observation until Start publication, Turn settlement and admitted operation receipts finish. Natural Done never calls Cancel. Input, function and interaction admission close before settlement, and operations already admitted hold their barrier through native receipts and -outbound acknowledgement. Only then forward Done or an applied cancellation +outbound acknowledgement. Send cancellation to the fixed Turn before waiting +for that barrier: a written input may need native interruption to produce its +receipt. Join native settlement, any required confirmed Executor close, output +drain and all admitted operations before an applied acknowledgement or reuse. +A failed Close may report failure while retaining the same Run and outstanding +operations for retry. Closing a caller wait cannot manufacture an applied input receipt. Only then forward Done or an applied cancellation receipt. Commit native continuity and release the old Run admission before publishing Done, since the receiver may immediately start another Turn. A late terminal-send failure belongs to the old Run; it cannot invalidate a successor diff --git a/apps/parsar-daemon/internal/dispatch/executor_cancel_receipt_test.go b/apps/parsar-daemon/internal/dispatch/executor_cancel_receipt_test.go new file mode 100644 index 000000000..8ea3b6ee2 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/executor_cancel_receipt_test.go @@ -0,0 +1,249 @@ +package dispatch_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type receiptCancelSender struct { + *recSender + entered, release chan struct{} + once sync.Once +} + +func (s *receiptCancelSender) Send(ctx context.Context, env proto.Envelope) error { + if env.Type == proto.TypePromptSteerAck { + var ack proto.PromptSteerAckPayload + _ = env.DecodePayload(&ack) + if ack.InputID == "pending" && !ack.Written { + s.once.Do(func() { close(s.entered) }) + select { + case <-s.release: + case <-ctx.Done(): + return ctx.Err() + } + } + } + return s.recSender.Send(ctx, env) +} + +type receiptCancelExecutor struct { + turn chan *receiptCancelTurn + current *receiptCancelTurn + cancelFails, closeFailsFirst bool + closeEntered, closeRelease chan struct{} + closes atomic.Int32 + closeOnce sync.Once +} + +func (e *receiptCancelExecutor) StartTurn(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + turn := &receiptCancelTurn{id: id, out: out, owner: e, written: make(chan struct{}), cancelled: make(chan struct{}), closed: make(chan struct{}), settled: make(chan struct{}), awaiting: make(chan struct{})} + e.current = turn + e.turn <- turn + return turn, nil +} +func (e *receiptCancelExecutor) Close(context.Context) error { + attempt := e.closes.Add(1) + if e.closeFailsFirst && attempt == 1 { + return errors.New("native close unconfirmed") + } + e.closeOnce.Do(func() { close(e.closeEntered) }) + <-e.closeRelease + if e.current != nil { + e.current.closeOnce.Do(func() { close(e.current.closed) }) + e.current.finish() + } + return nil +} + +type receiptCancelTurn struct { + id string + out chan<- proto.Envelope + owner *receiptCancelExecutor + written, cancelled, closed, settled, awaiting chan struct{} + cancelOnce, closeOnce, finishOnce, terminalOnce, awaitOnce sync.Once + cancels atomic.Int32 +} + +func (t *receiptCancelTurn) terminal() { + t.terminalOnce.Do(func() { event, _ := proto.NewEnvelope(proto.TypeDone, t.id, t.CancellationOutcome()); t.out <- event }) +} +func (t *receiptCancelTurn) finish() { + t.finishOnce.Do(func() { t.terminal(); close(t.out); close(t.settled) }) +} +func (t *receiptCancelTurn) Cancel(context.Context) error { + t.cancels.Add(1) + t.cancelOnce.Do(func() { close(t.cancelled) }) + if t.owner.cancelFails { + return errors.New("native cancellation unconfirmed") + } + t.finish() + return nil +} +func (t *receiptCancelTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + t.awaitOnce.Do(func() { close(t.awaiting) }) + select { + case <-t.settled: + return agent.TurnSettlement{Reusable: !t.owner.cancelFails}, nil + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} +func (t *receiptCancelTurn) CancellationOutcome() proto.DonePayload { + return proto.DonePayload{Content: "observed", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-session"}} +} +func (t *receiptCancelTurn) Steer(ctx context.Context, input proto.PromptSteerPayload) error { + return t.SteerWithReceipt(ctx, input, nil) +} +func (t *receiptCancelTurn) SteerWithReceipt(ctx context.Context, _ proto.PromptSteerPayload, written func()) error { + written() + close(t.written) + native := t.cancelled + if t.owner.cancelFails { + native = t.closed + } + select { + case <-native: + case <-ctx.Done(): + return ctx.Err() + } + return errors.New("native input consumption is unknown") +} + +func TestExecutorCancellationReachesNativeBeforeDurableReceiptJoin(t *testing.T) { + for _, mode := range []string{"cancel", "done_then_cancel", "cancel_failure", "close_failure_retry"} { + t.Run(mode, func(t *testing.T) { + sender := &receiptCancelSender{recSender: &recSender{}, entered: make(chan struct{}), release: make(chan struct{})} + owner := &receiptCancelExecutor{turn: make(chan *receiptCancelTurn, 2), cancelFails: mode == "cancel_failure" || mode == "close_failure_retry", closeFailsFirst: mode == "close_failure_retry", closeEntered: make(chan struct{}), closeRelease: make(chan struct{})} + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: proto.AgentKindCapabilities{EnvironmentNone: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("legacy factory forbidden") + }) + reg.RegisterExecutor("reusable", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return owner, nil }) + r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, IdleTimeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + var receiptOnce, closeOnce sync.Once + releaseReceipt := func() { receiptOnce.Do(func() { close(sender.release) }) } + releaseClose := func() { closeOnce.Do(func() { close(owner.closeRelease) }) } + t.Cleanup(func() { + releaseReceipt() + releaseClose() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := r.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + wait := func(ch <-chan struct{}, reason string) { + t.Helper() + select { + case <-ch: + case <-time.After(time.Second): + t.Fatal(reason) + } + } + p := executorAdmission(t, r, sender.recSender, "first", executorRequest()) + startExecutorTurn(t, r, sender.recSender, "first", "run", p) + turn := <-owner.turn + input := proto.PromptSteerPayload{InputID: "pending", Input: proto.TextInput("second input"), DurableReceipt: true} + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptSteer, "run", input)); err != nil { + t.Fatal(err) + } + wait(turn.written, "input did not reach native write") + if mode == "done_then_cancel" { + turn.terminal() + wait(turn.awaiting, "natural Done did not begin settlement") + } + cancel := func(id string) { + t.Helper() + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: id})); err != nil { + t.Fatal(err) + } + } + cancel("cancel") + wait(turn.cancelled, "receipt barrier prevented native cancellation") + acknowledgements := 0 + if mode == "close_failure_retry" { + waitFor(t, func() bool { return len(cancellationAcks(sender.recSender)) == 1 }, "failed Close acknowledgement") + ack := cancellationAcks(sender.recSender)[0] + if ack.Applied || ack.Outcome != nil || r.ActiveRuns() != 1 { + t.Fatal("failed Close released ownership or fabricated cancellation") + } + acknowledgements = 1 + cancel("retry") + } + if owner.cancelFails { + wait(owner.closeEntered, "failed cancellation did not reach resource cleanup") + if r.ActiveRuns() != 1 || len(cancellationAcks(sender.recSender)) != acknowledgements { + t.Fatal("cleanup was acknowledged before resource close") + } + releaseClose() + } + wait(sender.entered, "native cancellation did not unblock the written input") + if r.ActiveRuns() != 1 || len(cancellationAcks(sender.recSender)) != acknowledgements { + t.Fatal("cancellation passed an uncommitted input receipt") + } + for _, env := range sender.snapshot() { + if env.Type == proto.TypeDone { + t.Fatal("Done preceded the admitted input receipt") + } + } + releaseReceipt() + waitFor(t, func() bool { + return len(cancellationAcks(sender.recSender)) == acknowledgements+1 && r.ActiveRuns() == 0 && hasFrame(sender.recSender, proto.TypeDone, "run") + }, "joined cancellation") + frames := sender.snapshot() + inputAt, doneAt, cancelAt := -1, -1, -1 + for i, env := range frames { + switch env.Type { + case proto.TypePromptSteerAck: + var ack proto.PromptSteerAckPayload + _ = env.DecodePayload(&ack) + if !ack.Written { + if ack.Accepted || ack.ErrorCode != "outcome_unknown" { + t.Fatalf("unknown input fabricated receipt: %+v", ack) + } + inputAt = i + } + case proto.TypeDone: + doneAt = i + case proto.TypeInteractionDecisionAck: + cancelAt = i + } + } + if inputAt < 0 || doneAt < inputAt || cancelAt < inputAt || !owner.cancelFails && cancelAt < doneAt { + t.Fatalf("receipt/Done/cancel order=%d/%d/%d", inputAt, doneAt, cancelAt) + } + ack := cancellationAcks(sender.recSender)[acknowledgements] + if ack.Applied == owner.cancelFails || (ack.Outcome != nil) == owner.cancelFails { + t.Fatalf("cancellation evidence changed during cleanup: %+v", ack) + } + if !owner.cancelFails { + req := executorRequest() + req.Configuration.AgentSessionID = "native-session" + req.Configuration.RequireExistingNativeSession = true + next := executorAdmission(t, r, sender.recSender, "next", req) + if next.ExecutorID != p.ExecutorID || !next.Reused { + t.Fatal("healthy cancellation replaced owner") + } + startExecutorTurn(t, r, sender.recSender, "next", "next-run", next) + successor := <-owner.turn + cancel("late-old-run") + if successor.cancels.Load() != 0 || owner.closes.Load() != 0 { + t.Fatal("old cancellation reached successor") + } + successor.finish() + } + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff.go index 1b845b300..89c3d1862 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff.go +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff.go @@ -53,7 +53,8 @@ type preparedHandoff struct { outputDone chan struct{} // Every admitted native mutation holds a read lock through its receipt. - // A release attempt takes the write lock before native cancellation. + // Release joins these receipts after native cancellation/settlement, since + // an admitted receipt may itself require cancellation to finish. operations sync.RWMutex published bool // started status committed; protected by Router.mu @@ -164,9 +165,8 @@ func (r *Router) runPreparedRelease(state *sessionState, handoff *preparedHandof } <-handoff.startDone } - // Admission is closed; all earlier mutations retain their native receipt wait. - handoff.operations.Lock() - handoff.operations.Unlock() + // Admission is closed, but native cancellation must be able to release an + // already-written input or tool operation that is waiting for its receipt. turn := handoff.turn var settlement agent.TurnSettlement var nativeErr error @@ -221,6 +221,15 @@ func (r *Router) runPreparedRelease(state *sessionState, handoff *preparedHandof // A failed Turn settlement is not repaired into a cancellation receipt by Close. nativeErr = errors.Join(nativeErr, closeErr) } + if closeErr == nil { + // Native settlement (or confirmed resource close after failure) comes + // first. Keep every admitted operation and its outbound receipt owned + // until it returns; neither cancellation acknowledgement nor reuse can + // pass this join. Failed Close retains the same owner for a later retry. + handoff.operations.Lock() + handoff.operations.Unlock() + <-handoff.outputDone + } r.mu.Lock() attempt.err = nativeErr if closeErr != nil { @@ -236,7 +245,6 @@ func (r *Router) runPreparedRelease(state *sessionState, handoff *preparedHandof release.succeeded, release.outcome = true, outcome close(attempt.done) r.mu.Unlock() - <-handoff.outputDone r.mu.Lock() outputErr, terminal, closed := handoff.outputErr, handoff.terminal, r.closed r.mu.Unlock() diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go index fa0717b4f..fce4c8640 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go @@ -15,16 +15,12 @@ type preparedMutationSession struct { *fakeSession cancelEntered chan struct{} cancelOnce sync.Once - beforeCancel func() functions atomic.Int32 steers atomic.Int32 reads atomic.Int32 } func (s *preparedMutationSession) Cancel(ctx context.Context) error { - if s.beforeCancel != nil { - s.beforeCancel() - } s.cancelOnce.Do(func() { close(s.cancelEntered) }) return s.fakeSession.Cancel(ctx) } @@ -162,8 +158,11 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { } select { case <-session.cancelEntered: - t.Fatal("native release overtook an admitted receipt") - case <-time.After(50 * time.Millisecond): + case <-time.After(2 * time.Second): + t.Fatal("native cleanup was blocked by the outbound receipt") + } + if r.ActiveRuns() != 1 || hasFrame(sender.recSender, proto.TypeDone, "run") { + t.Fatal("Run ownership or Done overtook the admitted receipt") } close(sender.release) select { @@ -179,7 +178,7 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("release did not follow the completed receipt") } - waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "mutation release cleanup") + waitFor(t, func() bool { return r.ActiveRuns() == 0 && hasFrame(sender.recSender, proto.TypeDone, "run") }, "mutation release cleanup") if session.cancels() != 1 { t.Fatalf("Session release calls = %d, want 1", session.cancels()) } @@ -239,7 +238,6 @@ func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { t.Run(operation, func(t *testing.T) { sender := &blockingPreparedReceiptSender{recSender: &recSender{}, deliveryID: operation + "-delivery", inputID: operation + "-input", entered: make(chan struct{}), release: make(chan struct{}), exited: make(chan struct{})} defer close(sender.release) - var cancelBeforeReceipt atomic.Bool session := &preparedMutationSession{fakeSession: &fakeSession{closeOutOnCancel: true}, cancelEntered: make(chan struct{})} p := &controlledPreparation{closed: make(chan struct{})} p.start = func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { @@ -250,13 +248,6 @@ func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { startCancellationPreparation(t, r, sender.recSender) waitPreparationStatus(t, sender.recSender, "request", "started", "") - session.beforeCancel = func() { - select { - case <-sender.exited: - default: - cancelBeforeReceipt.Store(true) - } - } var mutation proto.Envelope switch operation { case "function": @@ -283,8 +274,13 @@ func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { if err := r.Shutdown(ctx); err != nil { t.Fatal(err) } - if cancelBeforeReceipt.Load() || session.cancels() != 1 || r.ActiveRuns() != 0 { - t.Fatalf("shutdown crossed receipt barrier: early=%t cancels=%d active=%d", cancelBeforeReceipt.Load(), session.cancels(), r.ActiveRuns()) + select { + case <-sender.exited: + default: + t.Fatal("shutdown returned before the receipt attempt ended") + } + if session.cancels() != 1 || r.ActiveRuns() != 0 { + t.Fatalf("shutdown did not join native cleanup: cancels=%d active=%d", session.cancels(), r.ActiveRuns()) } }) } diff --git a/services/agents-api/tests/official_user_runtime.py b/services/agents-api/tests/official_user_runtime.py index 43b3b7631..9101634b0 100644 --- a/services/agents-api/tests/official_user_runtime.py +++ b/services/agents-api/tests/official_user_runtime.py @@ -250,7 +250,9 @@ def check_artifacts(): try: assert expected_environment_type in ("self_hosted", "openai_hosted") - assert session.environment.type == expected_environment_type and session.environment.workspace_directory == "/workspace" + assert session.environment.type == expected_environment_type + if expected_environment_type == "self_hosted": + assert session.environment.workspace_directory == "/workspace" assert client.beta.agents.environments.retrieve(eid).status == "connected", "Runtime is not connected" assert not list(sessions.turns.list(sid)) and not list(sessions.items.list(sid)), "Use an unused Session" assert not list(sessions.artifacts.list(sid)), "Use a Session without existing Artifacts" From 13c22c3112d92d52af678572da9e01a80f7139f9 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 15:55:32 +0000 Subject: [PATCH 16/78] Retry queued Turns after Runtime preparation capacity rejection --- CONTRIBUTING.md | 8 +- .../agents-api/internal/execution/worker.go | 8 + .../internal/store/worker_capacity_test.go | 189 ++++++++++++++++++ 3 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 services/agents-api/internal/store/worker_capacity_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34ef00758..60c96d603 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1646,8 +1646,12 @@ later Turn. Cancellation uses the exact Run identity. Preparations and Start execute outside the receive loop and Router lock. Admission expires after five minutes; retries do not extend that deadline. Bound active preparation and execution separately from idle retained resources, and count -closing or uncertain resources until cleanup succeeds. At most 64 admission -records are retained; old handles never consume replacement admissions. Idle expiry +closing or uncertain resources until cleanup succeeds. A definite +`execution_prepare` rejection with `preparation_capacity` leaves an unclaimed +queued Turn for the existing Worker scheduler to retry, including capacity held +by cleanup. Other errors and uncertain input delivery do not authorize replay. +At most 64 admission records are retained; old handles never consume replacement +admissions. Idle expiry is a Runtime resource policy, not Core active-Turn concurrency. Shutdown tracks and closes active and idle Executors, retains failed close targets, and allows a later serialized retry. Ordinary disconnection closes the failed transport and keeps diff --git a/services/agents-api/internal/execution/worker.go b/services/agents-api/internal/execution/worker.go index 41eec56b3..c8ec77167 100644 --- a/services/agents-api/internal/execution/worker.go +++ b/services/agents-api/internal/execution/worker.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -343,6 +344,8 @@ func (w *Worker) runClaim(ctx context.Context, item store.ExecutionWork) error { if err == nil || errors.Is(err, store.ErrTurnConflict) { return nil } + var rejection *preparationRejection + capacityRejected := errors.As(err, &rejection) && rejection.operation == proto.TypeExecutionPrepare && rejection.code == "preparation_capacity" outcome := json.RawMessage(`{"error_code":"execution_unavailable"}`) if errors.Is(err, store.ErrModelProviderRequired) { outcome = json.RawMessage(`{"error_code":"model_provider_required"}`) @@ -353,6 +356,11 @@ func (w *Worker) runClaim(ctx context.Context, item store.ExecutionWork) error { if err != nil { return err } + if turn.Status == store.TurnQueued && capacityRejected { + // No input was sent. Leave durable work for the existing scheduler tick; + // active and cleanup-held Runtime capacity have the same rejection. + return nil + } if turn.Status == store.TurnCompleted || turn.Status == store.TurnFailed || turn.Status == store.TurnCancelled { return nil } diff --git a/services/agents-api/internal/store/worker_capacity_test.go b/services/agents-api/internal/store/worker_capacity_test.go new file mode 100644 index 000000000..ccb884df2 --- /dev/null +++ b/services/agents-api/internal/store/worker_capacity_test.go @@ -0,0 +1,189 @@ +package store_test + +import ( + "fmt" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Consume the actual controls so capacity rejection is exercised before any +// input, without the ordinary fixture's automatic preparation acknowledgement. +func capacityWorkerFrames(t *testing.T, h *dispatchHarness) <-chan proto.Envelope { + t.Helper() + frames := make(chan proto.Envelope, 64) + go func() { + defer close(frames) + for { + var frame proto.Envelope + if h.conn.ReadJSON(&frame) != nil { + return + } + select { + case frames <- frame: + case <-t.Context().Done(): + return + } + } + }() + return frames +} + +func TestWorkerDefersPreparationCapacityUntilCleanupReleasesSlot(t *testing.T) { + h := newDispatchHarness(t) + enableWorkerEnvironment(t, h) + h.d.MaxConcurrentExecutions = 5 + sessions := make(map[string]string) + for i := range 5 { + h.session = publicSession(t, h, fmt.Sprintf("capacity-%d", i)) + receipt := h.message("work", "once") + sessions[h.session.ID] = receipt.TurnID + } + frames := capacityWorkerFrames(t, h) + _, stop := startEnvironmentExpiryWorker(t, h.d) + defer stop() + admissions := make(map[string]string) + started := make(map[string]int) + var blocked, first string + capacityReleased := false + rejections := 0 + process := func(frame proto.Envelope) { + switch frame.Type { + case proto.TypeExecutionPrepare: + var prepare proto.ExecutionPreparePayload + if frame.DecodePayload(&prepare) != nil || sessions[prepare.SessionID] == "" || len(prepare.Configuration.Input) != 0 || prepare.Configuration.RunID != "" { + t.Fatal("invalid input-free preparation", prepare) + } + if blocked == "" && len(admissions) == 4 { + blocked = prepare.SessionID + } + if prepare.SessionID == blocked && !capacityReleased { + rejections++ + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{State: "rejected", Operation: proto.TypeExecutionPrepare, ErrorCode: "preparation_capacity"}) + return + } + admissions[frame.ID] = prepare.SessionID + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: frame.ID, ExecutorID: "executor-" + prepare.SessionID, Revision: 1, State: "ready"}) + case proto.TypeExecutionStart: + var start proto.ExecutionStartPayload + session := admissions[frame.ID] + if frame.DecodePayload(&start) != nil || session == "" || start.RunID != sessions[session] || inputTextForTest(t, start.Input) != "once" { + t.Fatal("Start changed the original input or Turn", start) + } + started[session]++ + if started[session] != 1 || (session == blocked && !capacityReleased) { + t.Fatal("capacity rejection replayed or prematurely started input") + } + if first == "" { + first = session + } + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: start.Handle, ExecutorID: start.ExecutorID, Revision: 2, State: "started", RunID: start.RunID}) + case proto.TypeExecutionRelease: + default: + t.Fatal("unexpected worker frame", frame.Type) + } + } + next := func() { + t.Helper() + select { + case frame, ok := <-frames: + if !ok { + t.Fatal("Runtime connection closed") + } + process(frame) + case <-time.After(5 * time.Second): + t.Fatal("worker did not send preparation or Start") + } + } + for len(started) != 4 || rejections == 0 { + next() + } + assertQueued := func() { + t.Helper() + turn, err := h.s.GetTurn(t.Context(), h.tenant, blocked, sessions[blocked]) + if err != nil || turn.Status != store.TurnQueued { + t.Fatal("capacity rejection failed queued work", turn, err) + } + } + // Observe multiple scheduler ticks with four active native owners. The + // bounded attempt count detects a completion-triggered busy retry loop. + observeCapacity := func() { + t.Helper() + before := rejections + timer := time.NewTimer(800 * time.Millisecond) + defer timer.Stop() + for { + select { + case frame := <-frames: + process(frame) + if rejections-before > 6 { + t.Fatal("capacity rejection caused a busy retry loop") + } + case <-timer.C: + if rejections == before { + t.Fatal("queued work was not retried by scheduler") + } + assertQueued() + return + } + } + } + observeCapacity() + // After completion, Runtime can retire the idle Executor. Its cleanup + // still owns a slot and reports the same capacity rejection. + h.write(sessions[first], proto.TypeDone, proto.DonePayload{Content: "done"}) + awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "first completed Turn", func() bool { + turn, err := h.s.GetTurn(t.Context(), h.tenant, first, sessions[first]) + return err == nil && turn.Status == store.TurnCompleted + }) + observeCapacity() + capacityReleased = true + for started[blocked] == 0 { + next() + } + for session, turn := range sessions { + if session != first { + h.write(turn, proto.TypeDone, proto.DonePayload{Content: "done"}) + } + } + awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "all five Turns completed", func() bool { + for session, id := range sessions { + turn, err := h.s.GetTurn(t.Context(), h.tenant, session, id) + if err != nil || turn.Status != store.TurnCompleted { + return false + } + } + return true + }) + if len(started) != 5 || started[blocked] != 1 { + t.Fatal("queued Turn did not start exactly once", started) + } +} + +func TestWorkerDoesNotDeferOtherPreparationOrStartRejections(t *testing.T) { + for _, test := range []struct{ name, operation, code string }{ + {"invalid_configuration", proto.TypeExecutionPrepare, "invalid_configuration"}, + {"resource_unavailable", proto.TypeExecutionPrepare, "resource_unavailable"}, + {"unknown_operation", "", "preparation_capacity"}, + {"start_capacity", proto.TypeExecutionStart, "preparation_capacity"}, + } { + t.Run(test.name, func(t *testing.T) { + h := newDispatchHarness(t) + enableWorkerEnvironment(t, h) + h.session = publicSession(t, h, test.name) + receipt := h.message("work", "once") + frames := capacityWorkerFrames(t, h) + _, stop := startEnvironmentExpiryWorker(t, h.d) + defer stop() + prepare := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) + if test.operation == proto.TypeExecutionStart { + h.write(prepare.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: prepare.ID, Revision: 1, State: "ready"}) + nextWorkerFrame(t, frames, proto.TypeExecutionStart) + } + h.write(prepare.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{State: "rejected", Operation: test.operation, ErrorCode: test.code}) + waitTurn(t, h, receipt.TurnID, store.TurnFailed) + }) + } +} From a5ff4796c44b5c1134fd3fd87727e12023c07c23 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 16:04:39 +0000 Subject: [PATCH 17/78] Settle unanswered Claude functions after confirmed cancellation --- CONTRIBUTING.md | 3 ++ .../claudesdk/executor_confirmation_test.go | 11 +++---- .../internal/agent/claudesdk/executor_test.go | 4 +-- .../internal/agent/claudesdk/executor_turn.go | 9 ++++-- .../internal/agent/claudesdk/functions.go | 4 +-- packages/claude-sdk-adapter/src/adapter.ts | 3 +- .../claude-sdk-adapter/src/function_bridge.ts | 26 +++++++++++++--- .../tests/executor.test.mjs | 30 +++++++++++++++++-- .../tests/function_bridge.test.mjs | 30 ++++++++++++++++++- .../tests/official_hosted_functions_native.py | 2 +- 10 files changed, 101 insertions(+), 21 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60c96d603..e2491b2e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3233,6 +3233,9 @@ remain unconfirmed even after successful teardown. Go rejects cancellation and AwaitSettlement when native confirmation is missing or false, or its own receipt ledger remains unsettled. A confirmed Turn may be non-reusable after cleanup; that state alone does not turn a verified cancellation into an error. +Confirmed native cancellation may settle unanswered function calls after result +admission closes and callbacks drain. A submitted function result still requires +its native application receipt, including when the MCP request aborts. `claudesdk.Config.Workspace` is a private, trusted operator binding for one qualified placement. It enables native Bash/Read/Edit and declared host functions diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_confirmation_test.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_confirmation_test.go index bf74b120b..631f52793 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor_confirmation_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_confirmation_test.go @@ -11,10 +11,10 @@ import ( ) func TestExecutorNativeConfirmationSurvivesCleanup(t *testing.T) { - for _, mode := range []string{"unknown_cancel", "queued_cancel", "missing_confirmation", "pending_function", "pending_input", "confirmed_closed"} { + for _, mode := range []string{"unknown_cancel", "queued_cancel", "missing_confirmation", "pending_function", "pending_function_unconfirmed", "pending_input", "confirmed_closed"} { t.Run(mode, func(t *testing.T) { config, req := persistentConfig(t, mode) - if mode == "pending_function" { + if mode == "pending_function" || mode == "pending_function_unconfirmed" { req.FunctionTools = []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`)}} } owner, err := NewExecutorFactory(config)(t.Context(), req) @@ -26,7 +26,7 @@ func TestExecutorNativeConfirmationSurvivesCleanup(t *testing.T) { if event := <-out; event.Type != proto.TypeDelta { t.Fatal("initial output missing") } - if mode == "pending_function" { + if mode == "pending_function" || mode == "pending_function_unconfirmed" { if event := <-out; event.Type != proto.TypeFunctionCall { t.Fatal("function obligation missing") } @@ -45,8 +45,9 @@ func TestExecutorNativeConfirmationSurvivesCleanup(t *testing.T) { for range out { } settlement, settlementErr := turn.AwaitSettlement(t.Context()) - confirmed := mode == "confirmed_closed" - if (cancelErr == nil) != confirmed || (settlementErr == nil) != confirmed || settlement.Reusable { + confirmed := mode == "confirmed_closed" || mode == "pending_function" + reusable := mode == "pending_function" + if (cancelErr == nil) != confirmed || (settlementErr == nil) != confirmed || settlement.Reusable != reusable { t.Fatalf("cancel=%v settlement=%+v err=%v", cancelErr, settlement, settlementErr) } if inputReceipt != nil { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go index c699b63ae..39d18f03a 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_test.go @@ -65,7 +65,7 @@ func runPersistentExecutorHelper() { } confirmed, reusable, reason := true, true, "" switch os.Getenv("SDK_EXECUTOR_MODE") { - case "unknown_cancel", "queued_cancel": + case "unknown_cancel", "queued_cancel", "pending_function_unconfirmed": confirmed, reusable, reason = false, false, "cancellation_unconfirmed" case "confirmed_closed": reusable, reason = false, "native_closed" @@ -99,7 +99,7 @@ func runPersistentExecutorHelper() { encode(bridgeEvent{Type: "turn_started", TurnID: active}) encode(bridgeEvent{Type: "input_ready", TurnID: active, SessionID: "native-persistent"}) encode(bridgeEvent{Type: "delta", TurnID: active, Delta: "partial"}) - if os.Getenv("SDK_EXECUTOR_MODE") == "pending_function" { + if strings.HasPrefix(os.Getenv("SDK_EXECUTOR_MODE"), "pending_function") { encode(bridgeEvent{Type: "function_call", TurnID: active, Call: &proto.FunctionCallPayload{CallID: "call", Name: "lookup", Arguments: json.RawMessage("{}")}}) } if len(command.Input) > 0 && len(command.Input[0].Content) > 0 && command.Input[0].Content[0].Text != nil && *command.Input[0].Content[0].Text == "wait" { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go b/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go index 285f4caf1..47a587a41 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor_turn.go @@ -57,6 +57,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { commands := commandState{calls: map[string]proto.ToolObservation{}} settlementReceived := false settlementConfirmed := false + cancelled := false reusable := false reason := "bridge_interrupted" for raw := range s.frames { @@ -148,7 +149,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { usageSession = event.SessionID emit(proto.TypeUsage, proto.UsagePayload{Usage: usage}) case "result": - if !s.matchesInputSession(event.SessionID) || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume || usageSession != "" && event.SessionID != usageSession || !s.functionsComplete() || !s.steeringComplete() || !mcp.complete() || !commands.complete() { + if !s.matchesInputSession(event.SessionID) || event.SessionID == "" || start.Resume != "" && event.SessionID != start.Resume || usageSession != "" && event.SessionID != usageSession || !s.functionsComplete(false) || !s.steeringComplete() || !mcp.complete() || !commands.complete() { failure = fmt.Errorf("claudesdk: invalid native completion or unconfirmed input/result") s.settlementErr = failure s.invalidate() @@ -157,6 +158,7 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { } terminal = true case "error": + cancelled = event.Code == "cancelled" failure = bridgeFailure(event.Code) terminal = true default: @@ -173,12 +175,13 @@ func (s *session) runTurn(start startRequest, out chan<- proto.Envelope) { if result == nil && failure == nil { failure = fmt.Errorf("claudesdk: SDK result is missing") } - if !s.functionsComplete() || !s.steeringComplete() || !mcp.complete() || !commands.complete() { + // Close result admission before deciding which unanswered calls cancellation settled. + s.stopFunctions() + if !s.functionsComplete(cancelled && settlementConfirmed) || !s.steeringComplete() || !mcp.complete() || !commands.complete() { settlementConfirmed, reusable, reason = false, false, "unsettled_native_operations" } mcp.close(start, emit) commands.close(start, emit) - s.stopFunctions() s.stopSteering() metadata := map[string]any{proto.DoneMetaAgentSessionType: "claude_session"} if id := s.inputSessionID(); id != "" { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/functions.go b/apps/parsar-daemon/internal/agent/claudesdk/functions.go index 74df35648..aaa56e61c 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/functions.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/functions.go @@ -159,11 +159,11 @@ func (s *session) SubmitFunctionResult(ctx context.Context, result proto.Functio } } -func (s *session) functionsComplete() bool { +func (s *session) functionsComplete(cancelled bool) bool { s.functions.mu.Lock() defer s.functions.mu.Unlock() for _, pending := range s.functions.calls { - if !pending.applied { + if !pending.applied && !(cancelled && pending.result == nil) { return false } } diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index 7f63e0d6c..e6520541b 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -168,7 +168,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: if(turns && !turns.id && !(message.type==="system" && message.subtype==="init")) throw new Error("unbound native event"); subagents?.consume(message); structured?.consume(message, nativeID); - await functions.consume(message, nativeID); + await functions.consume(message, nativeID, turns?.cancelled); if (mcp) for (const event of mcp.consume(message, nativeID)) await emit(event); if (commands) for (const event of commands.consume(message, nativeID, inputs.hasInput)) await emit(event); if (messages) for (const event of messages.consume(message)) await emit(event); @@ -199,6 +199,7 @@ export async function execute(request: Start | Prepare | ExecutorPrepare, emit: } if (message.type !== "result" && (!turns || turns.id)) for (const event of inputs.consume(message)) await emit(event); if(turns && result && inputs.complete && (!subagents || subagents.complete)) { + if (turns.cancelled) functions.cancelUnanswered(); if(!await turns.quiescent() || abort.signal.aborted) throw new Error("unconfirmed interrupt"); functions.assertComplete(); mcp?.assertComplete(); commands?.assertComplete(); if(subagents) for(const event of await subagents.facts()) await emit(event); diff --git a/packages/claude-sdk-adapter/src/function_bridge.ts b/packages/claude-sdk-adapter/src/function_bridge.ts index d849288c6..777d9ea15 100644 --- a/packages/claude-sdk-adapter/src/function_bridge.ts +++ b/packages/claude-sdk-adapter/src/function_bridge.ts @@ -19,6 +19,7 @@ type Pending = { reject: (error: Error) => void; cleanup: () => void; result?: FunctionResult; + aborted?: boolean; }; export class FunctionBridge { @@ -32,7 +33,9 @@ export class FunctionBridge { this.seen.add(call.id); const waiting = new Promise((resolve, reject) => { const stop = () => { - this.pending.delete(call.id); + const pending = this.pending.get(call.id); + if (pending?.result) pending.aborted = true; + else this.pending.delete(call.id); reject(new Error("Native function call aborted.")); }; signal.addEventListener("abort", stop, { once: true }); @@ -69,15 +72,19 @@ export class FunctionBridge { }), isError: !result.success }); } - async consume(message: SDKMessage, sessionID: string): Promise { + async consume(message: SDKMessage, sessionID: string, cancelled = false): Promise { if (message.type !== "user" || message.parent_tool_use_id !== null || message.session_id !== sessionID || message.isSynthetic || ("isReplay" in message && message.isReplay) || !Array.isArray(message.message.content)) return; for (const block of message.message.content) { if (block.type !== "tool_result") continue; const pending = this.pending.get(block.tool_use_id); - if (!pending) continue; + if (!pending || pending.aborted) continue; const result = pending.result; - if (!result) throw new Error("Native response preceded host result."); + if (!result) { + if (!cancelled || !block.is_error) throw new Error("Native response preceded host result."); + this.cancelUnanswered(block.tool_use_id); + continue; + } // Native error results join MCP text blocks; retain the original parts in the host. const matches = result.success ? matchesNativeContent(result.content, block.content) : block.content === result.content.map(part => part.type === "input_text" ? part.text : "").join("\n"); @@ -90,6 +97,17 @@ export class FunctionBridge { } } + // Only a native cancellation result authorizes abandoning unanswered calls. + // Submitted results retain their receipt obligation, including after abort. + cancelUnanswered(callID?: string): void { + for (const [id, pending] of this.pending) { + if (pending.result || (callID !== undefined && id !== callID)) continue; + pending.cleanup(); + this.pending.delete(id); + pending.reject(new Error("Native function call cancelled.")); + } + } + assertComplete(): void { if (this.pending.size) throw new Error("Native function results are unconfirmed."); } diff --git a/packages/claude-sdk-adapter/tests/executor.test.mjs b/packages/claude-sdk-adapter/tests/executor.test.mjs index 39c6f1e90..894221ed4 100644 --- a/packages/claude-sdk-adapter/tests/executor.test.mjs +++ b/packages/claude-sdk-adapter/tests/executor.test.mjs @@ -24,7 +24,7 @@ globalThis.startupFixture=async({options})=>{ options.abortController.signal.addEventListener('abort',close); return {close,query(prompt){assert.equal(queried,false);queried=true;process.send({type:'query'});return { close,async initializationResult(){return process.argv[1]==="no-hook-report" ? {} : process.argv[1]==="false-hook-report" ? {hooks_applied:false} : {hooks_applied:true}}, - async interrupt(){process.send({type:'interrupt'});interrupt?.();if(process.argv[1]==='rejected')throw new Error('interrupt failed');return process.argv[1]==='unknown' ? undefined : {still_queued:process.argv[1]==='queued' ? ['not-consumed'] : []}}, + async interrupt(){process.send({type:'interrupt'});interrupt?.();if(process.argv[1]==='rejected')throw new Error('interrupt failed');return ['unknown','pending-function-unknown'].includes(process.argv[1]) ? undefined : {still_queued:process.argv[1]==='queued' ? ['not-consumed'] : []}}, async *[Symbol.asyncIterator](){ for await(const user of prompt){ number++; @@ -32,6 +32,11 @@ globalThis.startupFixture=async({options})=>{ process.send({type:'input',text}); yield {type:'system',subtype:'init',session_id:'native',tools:client ? ['mcp__functions__lookup'] : [],mcp_servers:client ? [{name:'functions',status:'connected'}] : []}; if(client){ + if(text==='pending-function'){ + void client.callTool({name:'lookup',arguments:{text},_meta:{'claudecode/toolUseId':'pending-call'}}).catch(()=>{}); + await Promise.race([new Promise(resolve=>{interrupt=resolve}),exited]); + if(process.argv[1]==='pending-function-result') yield {type:'user',session_id:'native',parent_tool_use_id:null,message:{content:[{type:'tool_result',tool_use_id:'pending-call',content:'Interrupted',is_error:true}]}}; + } if(text==='features'){ const called=await client.callTool({name:'lookup',arguments:{text},_meta:{'claudecode/toolUseId':'same-native-call'}}); yield {type:'user',session_id:'native',parent_tool_use_id:null,message:{content:[{type:'tool_result',tool_use_id:'same-native-call',content:called.content,is_error:called.isError}]}}; @@ -68,7 +73,7 @@ async function launch(t,mode="normal") { const send=value=>child.stdin.write(JSON.stringify(value)+"\n"); const start=(id,text)=>send({type:"turn_start",turn_id:id,input:[{content:[{type:"input_text",text}]}]}); send({type:"executor_prepare",cwd:"/tmp",model:"fixture",system_prompt:"", - ...(mode==="features" ? {observe_messages:true,functions:[{name:"lookup",description:"lookup",parameters:{type:"object",properties:{text:{type:"string"}}}}]} : {})}); + ...(mode==="features" || mode.startsWith("pending-function") ? {observe_messages:true,functions:[{name:"lookup",description:"lookup",parameters:{type:"object",properties:{text:{type:"string"}}}}]} : {})}); await wait(()=>events.some(event=>event.type==="executor_ready")); assert.equal(observations.filter(event=>event.type==="input").length,0); return {child,events,observations,closed,wait,send,start}; @@ -147,3 +152,24 @@ for(const mode of ["no-hook-report","false-hook-report"])test("none Executor wit assert.equal(events.find(event=>event.type==="turn_settled").reusable,true); child.stdin.end();assert.deepEqual(await closed,{code:0,signal:null}); }); + +for (const mode of ["pending-function-result", "pending-function-terminal", "pending-function-unknown"]) test(`unanswered function cancellation ${mode} retains native confirmation requirements`, {timeout:10000}, async t => { + const {child,events,closed,wait,send,start}=await launch(t,mode); + start("first","pending-function"); + await wait(()=>events.some(event=>event.type==="function_call")); + send({type:"turn_cancel",turn_id:"first"}); + await wait(()=>events.some(event=>event.type==="turn_settled")); + const settled=events.find(event=>event.type==="turn_settled"); + const confirmed=mode!=="pending-function-unknown"; + assert.equal(settled.confirmed,confirmed); + assert.equal(settled.reusable,confirmed); + assert.ok(events.some(event=>event.type==="error"&&event.code==="cancelled")); + assert.equal(events.filter(event=>event.type==="function_applied").length,0); + if(confirmed){ + start("second","answer"); + await wait(()=>events.some(event=>event.type==="turn_settled"&&event.turn_id==="second")); + assert.equal(events.find(event=>event.type==="turn_settled"&&event.turn_id==="second").confirmed,true); + child.stdin.end(); + } + assert.deepEqual(await closed,{code:0,signal:null}); +}); diff --git a/packages/claude-sdk-adapter/tests/function_bridge.test.mjs b/packages/claude-sdk-adapter/tests/function_bridge.test.mjs index fac8458d9..bd78b0e27 100644 --- a/packages/claude-sdk-adapter/tests/function_bridge.test.mjs +++ b/packages/claude-sdk-adapter/tests/function_bridge.test.mjs @@ -62,7 +62,10 @@ test("an aborted submitted result never becomes a native application receipt", { bridge.submit(JSON.stringify(result("a", false))); await waiting; abort.abort(); - await bridge.consume(native(result("a", false)), "native"); + bridge.cancelUnanswered(); + assert.throws(() => bridge.assertComplete(), /unconfirmed/); + await bridge.consume(native(result("a", false)), "native", true); + assert.throws(() => bridge.assertComplete(), /unconfirmed/); assert.deepEqual(events.map(event => event.type), ["function_call"]); }); @@ -112,3 +115,28 @@ test("native error-image rejection leaves the pending call available for a suppo await bridge.consume(native(result("error", false)), "native"); bridge.assertComplete(); }); + +for (const terminal of ["tool_result", "result"]) test(`native cancellation ${terminal} settles unanswered callbacks without application`, async () => { + const events = []; + const bridge = new FunctionBridge(async event => { events.push(event); }); + const waiting = bridge.invoke(call("a"), new AbortController().signal); + const rejected = assert.rejects(waiting, /cancelled/); + if (terminal === "tool_result") { + const cancellation = native(result("a", false), "Interrupted"); + await assert.rejects(bridge.consume(cancellation, "native"), /preceded/); + await bridge.consume(cancellation, "native", true); + } else bridge.cancelUnanswered(); + await rejected; + bridge.assertComplete(); + assert.deepEqual(events.map(event => event.type), ["function_call"]); +}); + +test("native cancellation preserves a submitted result's missing receipt", async () => { + const bridge = new FunctionBridge(async () => {}); + const waiting = bridge.invoke(call("a"), new AbortController().signal); + bridge.submit(JSON.stringify(result("a"))); + await waiting; + bridge.cancelUnanswered(); + assert.throws(() => bridge.assertComplete(), /unconfirmed/); + bridge.close(); +}); diff --git a/services/agents-api/tests/official_hosted_functions_native.py b/services/agents-api/tests/official_hosted_functions_native.py index 9f57c183f..a573d89ee 100644 --- a/services/agents-api/tests/official_hosted_functions_native.py +++ b/services/agents-api/tests/official_hosted_functions_native.py @@ -83,7 +83,7 @@ def failure(arguments): raise RuntimeError(private_error) _, result = invoke("Call lookup exactly once with key failure. If it fails, do not retry. Then reply with the exact string returned by lookup in our first turn, using conversation history and no file tools.", "function-failure", failure) - assert result["error"] == "Tool handler failed." and "output" not in result + assert result["error"] == "Tool handler failed." and "output" in result and result["output"] is None messages = [i for i in items() if i["type"] == "message" and i["role"] == "assistant"] assert marker in "\n".join(p.get("text", "") for p in messages[-1]["content"]) assert len(calls) == 2 From 541499bf63a6e55d98035b62e84692ee4544e278 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 16:15:20 +0000 Subject: [PATCH 18/78] Confirm native Codex terminal cleanup before cancellation settles --- CONTRIBUTING.md | 5 + .../internal/agent/codex/executor.go | 12 ++ .../internal/agent/codex/executor_test.go | 4 +- .../internal/agent/codex/executor_turn.go | 9 ++ .../agent/codex/preparation_helpers_test.go | 20 ++++ .../internal/agent/codex/session.go | 1 + .../internal/agent/codex/terminal_cleanup.go | 58 +++++++++ .../agent/codex/terminal_cleanup_test.go | 113 ++++++++++++++++++ 8 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 apps/parsar-daemon/internal/agent/codex/terminal_cleanup.go create mode 100644 apps/parsar-daemon/internal/agent/codex/terminal_cleanup_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2491b2e5..70cd94af7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1632,6 +1632,11 @@ that already owns the Executor. Connection shutdown owns transport-loss cleanup. Preserve the ten-second settlement wait and separate five-second receipt send budget; timeout is not proof of quiescence. The observed cancellation outcome retains native identity, Usage and output without fabricating missing evidence. +Codex native Turn interruption can leave background terminals alive. Its adapter +uses the exact thread-owned terminal list and confirmed per-terminal termination +before settling cancellation, and repeats this cleanup during Executor close. +The bulk clean acknowledgement does not prove termination. Failed cleanup keeps +the native owner available for a later close attempt. Private preparation controls reserve a per-Turn admission, not a new Executor. They carry an explicit Session identity and immutable configuration without model diff --git a/apps/parsar-daemon/internal/agent/codex/executor.go b/apps/parsar-daemon/internal/agent/codex/executor.go index 8692b3ce2..2e437c20e 100644 --- a/apps/parsar-daemon/internal/agent/codex/executor.go +++ b/apps/parsar-daemon/internal/agent/codex/executor.go @@ -129,6 +129,18 @@ func (e *Executor) Close(ctx context.Context) error { stop() } } + if running != nil && base.rpc.Alive() { + // A caller deadline only stops waiting. Cleanup retains a bounded + // opportunity to release native terminals before closing their owner. + cleanupCtx, stop := context.WithTimeout(context.Background(), rpcKillTimeout) + err := running.cleanupNativeTerminals(cleanupCtx) + stop() + if err != nil { + // Preserve the native owner and exact handles for a later Close. + done <- err + return + } + } base.cancelFn() err := base.rpc.Close() e.mu.Lock() diff --git a/apps/parsar-daemon/internal/agent/codex/executor_test.go b/apps/parsar-daemon/internal/agent/codex/executor_test.go index 6cd32f60a..f6c9d56fa 100644 --- a/apps/parsar-daemon/internal/agent/codex/executor_test.go +++ b/apps/parsar-daemon/internal/agent/codex/executor_test.go @@ -18,7 +18,9 @@ func executorFixture(t *testing.T, mode string) (*Executor, string) { t.Helper() req, cfg, root := preparationFixture(t) t.Setenv("OAC_TEST_EXECUTOR_MODE", mode) - e, err := newExecutor(t.Context(), req, cfg) + ownerCtx, cancelOwner := context.WithCancel(context.Background()) + t.Cleanup(cancelOwner) + e, err := newExecutor(ownerCtx, req, cfg) if err != nil { t.Fatal(err) } diff --git a/apps/parsar-daemon/internal/agent/codex/executor_turn.go b/apps/parsar-daemon/internal/agent/codex/executor_turn.go index a356f30f7..0579e81fa 100644 --- a/apps/parsar-daemon/internal/agent/codex/executor_turn.go +++ b/apps/parsar-daemon/internal/agent/codex/executor_turn.go @@ -76,6 +76,15 @@ func (s *Session) settleExecutorTurn(startErr error) { s.settlementErr = errors.Join(s.settlementErr, s.cancelErr, s.rpc.Close()) } } + if s.cancelled.Load() && s.nativeSettled.Load() && s.cancelErr == nil && s.rpc.Alive() { + cleanupCtx, stop := context.WithTimeout(context.Background(), 10*time.Second) + err := s.cleanupNativeTerminals(cleanupCtx) + stop() + if err != nil { + s.settlement = agent.TurnSettlement{Reason: "native_terminal_cleanup_unconfirmed"} + s.settlementErr = errors.Join(s.settlementErr, err) + } + } s.cancelFn() } diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go b/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go index 857e0082d..2957a4718 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation_helpers_test.go @@ -185,6 +185,26 @@ func TestPreparationFakeCodexProcess(t *testing.T) { } } result = map[string]any{"thread": map[string]string{"id": "fixture-native-thread"}, "model": "fixture-model"} + case "thread/backgroundTerminals/list": + result = map[string]any{"data": []any{}} + if executorMode == "terminal-cleanup" { + if _, err := os.Stat(os.Getenv("OAC_TEST_PREPARATION_FRAMES") + ".terminated"); os.IsNotExist(err) { + result = map[string]any{"data": []any{map[string]string{"processId": "owned-terminal"}}} + } + } + case "thread/backgroundTerminals/terminate": + var target struct { + ThreadID string `json:"threadId"` + ProcessID string `json:"processId"` + } + if json.Unmarshal(frame.Params, &target) != nil || target.ThreadID != "fixture-native-thread" || target.ProcessID != "owned-terminal" { + os.Exit(8) + } + _, allowed := os.Stat(os.Getenv("OAC_TEST_PREPARATION_FRAMES") + ".allow-cleanup") + result = map[string]any{"terminated": allowed == nil} + if allowed == nil { + _ = os.WriteFile(os.Getenv("OAC_TEST_PREPARATION_FRAMES")+".terminated", nil, 0600) + } case "turn/interrupt": if executorMode == "interrupt-error" { _ = output.Encode(map[string]any{"id": frame.ID, "error": map[string]any{"code": -32603, "message": "interrupt rejected"}}) diff --git a/apps/parsar-daemon/internal/agent/codex/session.go b/apps/parsar-daemon/internal/agent/codex/session.go index bbd808693..bed20d0d6 100644 --- a/apps/parsar-daemon/internal/agent/codex/session.go +++ b/apps/parsar-daemon/internal/agent/codex/session.go @@ -69,6 +69,7 @@ type Session struct { nativeSettled atomic.Bool settlement agent.TurnSettlement settlementErr error + terminalCleanupMu sync.Mutex operationMu sync.Mutex operations sync.WaitGroup operationsClosed bool diff --git a/apps/parsar-daemon/internal/agent/codex/terminal_cleanup.go b/apps/parsar-daemon/internal/agent/codex/terminal_cleanup.go new file mode 100644 index 000000000..a6f11c050 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/terminal_cleanup.go @@ -0,0 +1,58 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" +) + +// A native interrupted Turn can leave unified-exec terminals running. Retire +// their exact thread-owned handles; the bulk clean RPC only queues cleanup. +func (s *Session) cleanupNativeTerminals(ctx context.Context) error { + s.terminalCleanupMu.Lock() + defer s.terminalCleanupMu.Unlock() + thread := s.currentThreadID() + if thread == "" { + return nil + } + seen := map[string]bool{} + for { + raw, err := s.rpc.Request(ctx, "thread/backgroundTerminals/list", map[string]any{"threadId": thread, "limit": 100}) + if err != nil { + return err + } + var page struct { + Data *[]struct { + ProcessID string `json:"processId"` + } `json:"data"` + NextCursor *string `json:"nextCursor"` + } + if json.Unmarshal(raw, &page) != nil || page.Data == nil { + return errors.New("codex: invalid native terminal list") + } + if len(*page.Data) == 0 { + if page.NextCursor != nil { + return errors.New("codex: incomplete native terminal list") + } + return nil + } + for _, terminal := range *page.Data { + if terminal.ProcessID == "" || seen[terminal.ProcessID] { + return errors.New("codex: invalid or retained native terminal identity") + } + seen[terminal.ProcessID] = true + raw, err = s.rpc.Request(ctx, "thread/backgroundTerminals/terminate", map[string]string{"threadId": thread, "processId": terminal.ProcessID}) + if err != nil { + return err + } + var receipt struct { + Terminated bool `json:"terminated"` + } + if json.Unmarshal(raw, &receipt) != nil || !receipt.Terminated { + return errors.New("codex: native terminal termination unconfirmed") + } + } + // Terminating the first page removes its handles. Read the first page + // again to confirm absence and cover any remaining native terminals. + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/terminal_cleanup_test.go b/apps/parsar-daemon/internal/agent/codex/terminal_cleanup_test.go new file mode 100644 index 000000000..ace5c1a17 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/terminal_cleanup_test.go @@ -0,0 +1,113 @@ +package codex + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestExecutorCancellationRequiresNativeTerminalCleanup(t *testing.T) { + for _, confirmed := range []bool{false, true} { + t.Run(map[bool]string{false: "retry", true: "confirmed"}[confirmed], func(t *testing.T) { + e, root := executorFixture(t, "terminal-cleanup") + allow := func() { + t.Helper() + if err := os.WriteFile(filepath.Join(root, "frames.jsonl.allow-cleanup"), nil, 0600); err != nil { + t.Fatal(err) + } + } + if confirmed { + allow() + } + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), "cancel", proto.TextInput("hold"), out) + if err != nil { + t.Fatal(err) + } + ctx, stop := context.WithTimeout(t.Context(), 5*time.Second) + defer stop() + if err := turn.Cancel(ctx); (err == nil) != confirmed { + t.Fatalf("cancellation confirmed=%t: %v", confirmed, err) + } + settlement, err := turn.AwaitSettlement(ctx) + if (err == nil) != confirmed || settlement.Reusable != confirmed { + t.Fatal("cleanup changed cancellation confirmation", settlement, err) + } + for range out { + } + if !e.prepared.session.rpc.Alive() { + t.Fatal("native owner lost before cleanup/reuse") + } + frames := preparationFrames(t, root) + interrupted, terminated := false, false + for _, frame := range frames { + if frame.Method == "turn/interrupt" { + interrupted = true + } + if frame.Method == "thread/backgroundTerminals/terminate" { + if !interrupted { + t.Fatal("cleanup preceded native interrupt") + } + terminated = true + } + } + if !terminated { + t.Fatal("native interrupted receipt skipped terminal cleanup") + } + if confirmed { + nextOut := make(chan proto.Envelope, 20) + next, err := e.StartTurn(ctx, "next", proto.TextInput("answer"), nextOut) + if err != nil || !awaitExecutorTurn(t, next, nextOut).Reusable { + t.Fatal("confirmed cleanup prevented reuse", err) + } + } else { + if err := e.Close(ctx); err == nil || !e.prepared.session.rpc.Alive() { + t.Fatal("failed cleanup released the owned native process", err) + } + allow() + if err := e.Close(ctx); err != nil { + t.Fatal("cleanup retry failed", err) + } + if err := turn.Cancel(ctx); err == nil { + t.Fatal("resource cleanup fabricated native cancellation confirmation") + } + } + }) + } +} + +func TestExecutorCloseCleansNativeTerminalsAfterNormalCompletion(t *testing.T) { + e, root := executorFixture(t, "terminal-cleanup") + out := make(chan proto.Envelope, 20) + turn, err := e.StartTurn(t.Context(), "normal", proto.TextInput("answer"), out) + if err != nil { + t.Fatal(err) + } + if !awaitExecutorTurn(t, turn, out).Reusable { + t.Fatal("normal Turn did not retain its Executor") + } + for _, frame := range preparationFrames(t, root) { + if frame.Method == "thread/backgroundTerminals/terminate" { + t.Fatal("normal Turn prematurely killed its background terminal") + } + } + if err := os.WriteFile(filepath.Join(root, "frames.jsonl.allow-cleanup"), nil, 0600); err != nil { + t.Fatal(err) + } + if err := e.Close(t.Context()); err != nil { + t.Fatal(err) + } + terminated := false + for _, frame := range preparationFrames(t, root) { + if frame.Method == "thread/backgroundTerminals/terminate" { + terminated = true + } + } + if !terminated || e.prepared.session.rpc.Alive() { + t.Fatal("Executor Close left native terminals or owner alive") + } +} From fecf58da94074ab77b2db6cc14684ae6eb0a1c72 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 16:16:37 +0000 Subject: [PATCH 19/78] Bound Codex terminal cleanup control writes --- .../parsar-daemon/internal/agent/codex/terminal_cleanup.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/parsar-daemon/internal/agent/codex/terminal_cleanup.go b/apps/parsar-daemon/internal/agent/codex/terminal_cleanup.go index a6f11c050..49d409b2a 100644 --- a/apps/parsar-daemon/internal/agent/codex/terminal_cleanup.go +++ b/apps/parsar-daemon/internal/agent/codex/terminal_cleanup.go @@ -15,9 +15,12 @@ func (s *Session) cleanupNativeTerminals(ctx context.Context) error { if thread == "" { return nil } + request := func(method string, params any) (json.RawMessage, error) { + return s.rpc.request(ctx, method, params, func(frame any) error { return s.rpc.writeFrameContext(ctx, frame) }) + } seen := map[string]bool{} for { - raw, err := s.rpc.Request(ctx, "thread/backgroundTerminals/list", map[string]any{"threadId": thread, "limit": 100}) + raw, err := request("thread/backgroundTerminals/list", map[string]any{"threadId": thread, "limit": 100}) if err != nil { return err } @@ -41,7 +44,7 @@ func (s *Session) cleanupNativeTerminals(ctx context.Context) error { return errors.New("codex: invalid or retained native terminal identity") } seen[terminal.ProcessID] = true - raw, err = s.rpc.Request(ctx, "thread/backgroundTerminals/terminate", map[string]string{"threadId": thread, "processId": terminal.ProcessID}) + raw, err = request("thread/backgroundTerminals/terminate", map[string]string{"threadId": thread, "processId": terminal.ProcessID}) if err != nil { return err } From b1fdd0dd5c9907a55f1bd035f983f3da52e71713 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 16:18:40 +0000 Subject: [PATCH 20/78] Unify Runtime capability preparation across environment origins --- CONTRIBUTING.md | 78 +++- .../agent/claudesdk/mcp_environment_test.go | 12 +- .../internal/agent/claudesdk/workspace.go | 2 + .../agent/codex/capability_root_test.go | 13 + .../agent/codex/mcp_environment_test.go | 6 +- .../internal/agent/codex/session_plan.go | 9 + .../agent/mcode/environment_mcp_test.go | 4 +- .../internal/agent/mcode/workspace.go | 2 +- .../internal/cli/connect_environment.go | 3 +- .../cli/connect_environment_binding.go | 53 ++- .../cli/connect_environment_binding_test.go | 136 +++++++ .../internal/cli/connect_environment_test.go | 19 +- .../cli/retired_configuration_test.go | 2 +- apps/parsar-daemon/internal/cli/root.go | 1 - apps/parsar-daemon/internal/cli/root_test.go | 30 +- .../internal/cli/runtime_capabilities.go | 56 --- .../parsar-daemon/internal/cli/runtime_mcp.go | 15 +- .../internal/cli/runtime_mcp_test.go | 20 +- .../dispatch/capabilities_execution_test.go | 56 +++ .../internal/dispatch/capabilities_prepare.go | 186 ++++++++++ .../dispatch/capabilities_prepare_test.go | 336 +++++++++++++++++ .../internal/dispatch/executor.go | 7 +- .../dispatch/functions_native_test.go | 3 + .../internal/dispatch/preparation.go | 2 +- .../internal/dispatch/preparation_start.go | 2 +- .../internal/dispatch/preparation_test.go | 27 +- .../parsar-daemon/internal/dispatch/prompt.go | 9 +- .../parsar-daemon/internal/dispatch/router.go | 3 + .../internal/dispatch/shutdown.go | 9 + .../internal/dispatch/suspend.go | 2 +- .../internal/dispatch/workspace_write.go | 2 +- .../internal/localworkspace/binding.go | 99 +++-- .../internal/localworkspace/binding_test.go | 30 +- .../internal/localworkspace/capabilities.go | 210 +++++++++++ .../localworkspace/capabilities_test.go | 5 +- .../capability_preparation_test.go | 147 ++++++++ .../internal/localworkspace/initialization.go | 9 + .../internal/localworkspace/mcp.go | 2 +- .../internal/localworkspace/mcp_test.go | 4 +- .../internal/localworkspace/skills.go | 17 +- .../internal/localworkspace/skills_test.go | 15 + .../localworkspace/snapshot_marker.go | 177 +++++++++ .../localworkspace/snapshot_marker_test.go | 250 +++++++++++++ contracts/agents-api/environment-files.md | 10 +- contracts/agents-api/environment-templates.md | 59 +-- contracts/agents-api/environments.md | 60 +++- contracts/agents-api/harness-onboarding.md | 7 +- contracts/agents-api/message-input.md | 2 +- contracts/agents-api/openapi.yaml | 156 ++++---- .../agents-api/user-managed-runtime-v1.md | 13 +- docs/getting-started/self-hosted.md | 39 ++ internal/agentcapabilities/install.go | 95 ++++- internal/agentcapabilities/install_test.go | 10 +- internal/agentcapabilities/manifest.go | 115 +++++- internal/agentcapabilities/mcp_test.go | 4 +- internal/agentcapabilities/operation.go | 13 - internal/agentcapabilities/selection_test.go | 338 ++++++++++++++++++ internal/agentcapabilities/tree.go | 24 +- .../agentdaemon/gateway/cancellation_test.go | 2 +- internal/agentdaemon/gateway/capabilities.go | 111 ++++++ .../agentdaemon/gateway/capabilities_test.go | 234 ++++++++++++ .../agentdaemon/gateway/chunk_exchange.go | 26 ++ internal/agentdaemon/gateway/session.go | 6 + .../agentdaemon/gateway/workspace_write.go | 21 +- internal/agentdaemon/proto/capabilities.go | 132 +++++++ .../agentdaemon/proto/capabilities_test.go | 173 +++++++++ internal/agentdaemon/proto/envelope_test.go | 4 +- internal/agentdaemon/proto/environment.go | 19 +- internal/agentdaemon/proto/version.go | 2 +- .../claude-sdk-adapter/src/mcp_environment.ts | 13 +- packages/claude-sdk-adapter/src/workspace.ts | 13 +- .../src/workspace_skills.ts | 3 +- .../tests/mcp_workspace.test.mjs | 16 +- .../tests/workspace_skills.test.mjs | 19 + packages/mcode-harness/launch.mjs | 6 +- .../agents-api/deploy/runtime/initialize.py | 112 ++---- .../deploy/runtime/initialize_receipt_test.py | 72 ++++ .../deploy/runtime/initialize_test.py | 17 +- .../agents-api/deploy/runtime/tool-root.py | 6 +- .../internal/api/environment_request.go | 5 +- .../internal/api/environment_request_test.go | 29 ++ .../agents-api/internal/api/environments.go | 2 +- .../internal/api/environments_test.go | 20 +- services/agents-api/internal/api/handler.go | 2 +- .../internal/api/session_request.go | 12 +- .../environment_capabilities_test.go | 34 ++ .../execution/environment_placement.go | 26 +- .../execution/environment_placement_test.go | 4 +- .../execution/runtime_capabilities_test.go | 98 +++++ .../execution/runtime_initialization.go | 37 +- .../internal/execution/runtime_setup.go | 67 ++-- .../internal/execution/runtime_setup_test.go | 4 +- .../agents-api/internal/execution/support.go | 2 +- .../runtime_capabilities_pending_test.go | 82 +++++ 94 files changed, 3901 insertions(+), 545 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/codex/capability_root_test.go create mode 100644 apps/parsar-daemon/internal/cli/connect_environment_binding_test.go delete mode 100644 apps/parsar-daemon/internal/cli/runtime_capabilities.go create mode 100644 apps/parsar-daemon/internal/dispatch/capabilities_execution_test.go create mode 100644 apps/parsar-daemon/internal/dispatch/capabilities_prepare.go create mode 100644 apps/parsar-daemon/internal/dispatch/capabilities_prepare_test.go create mode 100644 apps/parsar-daemon/internal/localworkspace/capabilities.go create mode 100644 apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go create mode 100644 apps/parsar-daemon/internal/localworkspace/skills_test.go create mode 100644 apps/parsar-daemon/internal/localworkspace/snapshot_marker.go create mode 100644 apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go delete mode 100644 internal/agentcapabilities/operation.go create mode 100644 internal/agentcapabilities/selection_test.go create mode 100644 internal/agentdaemon/gateway/capabilities.go create mode 100644 internal/agentdaemon/gateway/capabilities_test.go create mode 100644 internal/agentdaemon/gateway/chunk_exchange.go create mode 100644 internal/agentdaemon/proto/capabilities.go create mode 100644 internal/agentdaemon/proto/capabilities_test.go create mode 100644 services/agents-api/internal/api/environment_request_test.go create mode 100644 services/agents-api/internal/execution/environment_capabilities_test.go create mode 100644 services/agents-api/internal/execution/runtime_capabilities_test.go create mode 100644 services/agents-api/internal/store/runtime_capabilities_pending_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0599a7464..cb9655194 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,14 +28,14 @@ separate: closing a Session executor does not release its allocation, destroy it Environment or delete its workspace. Reclamation is an explicit resource-manager operation coordinated with active work. -The capability-unification target is one Runtime preparation path for managed -bundles and self-hosted local directories. Core resolves configuration, versions +Capability preparation uses one Runtime path for managed bundles and self-hosted +local directories. Core resolves configuration, versions and resources; Runtime uses one parser and `installed.json` snapshot to supply Skill paths and MCP declarations to adapters. Preparation must complete before execution; reconnect reuses installed contents, and new Sessions freeze a new -configuration snapshot. The protocol is platform-neutral, while the first -implementation is Linux-only. This is pending work, not a statement that current -managed and self-hosted preparation or Windows/macOS isolation is already unified. +configuration snapshot. The protocol is platform-neutral; the implementation uses the existing packaged +Linux Runtime layout for both environment origins. Windows/macOS installation and +isolation remain separate work. - Keep component boundaries explicit through shared interfaces and versioned protocols. Register implementations behind those interfaces. Adding an @@ -495,7 +495,7 @@ forwarding. The explicit daemon-executor decision supersedes the previous native executor interoperability requirement. The superseded execution route is removed; retain reusable filesystem helpers, necessary regressions and historical evidence without a compatibility layer. -The private daemon wire protocol is 0.6.0. Initial, prepared and active input use +The private daemon wire protocol is 0.7.0. Initial, prepared and active input use the same ordered MessageInput contract, replacing scalar prompts and attachments. User-message boundaries and text/image order remain intact through Core and the Runtime wire; adapters own native conversion and receipt aggregation. Text-only @@ -1169,7 +1169,7 @@ a chain of old writable disks across suspension cycles. No Kubernetes, distribut scheduler or snapshot replication belongs in this V1 profile. Queued work and live Environment file access request wake. History and published -artifact reads do not. Planned suspension uses private daemon wire 0.6.0 with an +artifact reads do not. Planned suspension uses private daemon wire 0.7.0 with an Environment and suspension token; a PID/start-time fenced local control signal wakes the parked daemon, which reauthenticates before admitting new work. A transient disconnect before confirmation retries the same armed suspension with @@ -1482,8 +1482,9 @@ the Session row/cursor in the same transaction, without borrowing subsequent activity or Turn state. Initial state is `pending`; authenticated connection observations follow the lifecycle rules below. Public creation supports `self_hosted` on the three enabled native profiles when -the daemon gateway is configured. V1 requires `/workspace` and empty/default -capability directories. Supported non-deferred functions keep their engine-specific +the daemon gateway is configured. The workspace is a clean absolute Linux path matching the bound Runtime root; +`/workspace` denotes that root through the existing logical mapping. Optional +capability directories are clean absolute host-local selections. Supported non-deferred functions keep their engine-specific validation and native callback bridge. Enrollment binds the dedicated Runtime; Session output uses the owned Environment association. Public Files reuse the exact local workspace; populated self-hosted installation metadata remains unsupported. @@ -1622,8 +1623,9 @@ rotation/revocation and digest-only storage. They grant connection authority, ne Session API access. No raw-token import or secret read-back is added. Enrollment atomically creates or recovers one dedicated device and immutable Session -binding under the Session lock. The V1 workspace is `/workspace`; nonempty -self-hosted capability directories remain unsupported. No `runtime_allocation` is +binding under the Session lock. The frozen workspace and capability directory selection come from the Session +configuration and must match the local binding. Runtime snapshots declared local +Skill or Plugin directories before creating the native executor. No `runtime_allocation` is created for user-owned compute. A retry cannot replace a device, change its bound key or adopt another native history. Gateway authentication and dispatch recheck current key authority; rotation/revocation and deletion deny further use. @@ -3894,6 +3896,60 @@ request on first execution; later messages and observer retries reuse it. Separa conversations get independent Sessions and environments. Edits affect future Sessions only. Keep the existing product navigation and direct empty-chat composer. +### Shared Runtime capability preparation + +Resource management retains provider placement, capacity, allocation and Environment +create/renew/reclaim operations. The authenticated Runtime connection carries +capability preparation and executor operations; it does not implicitly allocate or +destroy compute. Connection loss, executor idle close and Turn cancellation preserve +the workspace and installed snapshot. Resource reclamation coordinates with active +work through its existing owner. + +Core freezes resource versions, metadata and local source selections. Managed +initialization keeps its existing order: initial files, provider tool configuration, +Runtime bundle import, provider packages and setup commands, then Runtime directory +snapshot finalization. Provider commands no longer parse or install Skills/Plugins. +A missing authenticated Runtime connection waits before capability initialization +is claimed; an operation whose effect is unknown is not replayed. Self-hosted +capability directories remain in immutable Environment configuration and never +create a managed initialization row or allocation. + +The private `capabilities_prepare`/`capabilities_result` exchange transfers inert +Skill/Plugin archives or a finalization selection, with canonical Session and +Environment identities. It accepts no command or destination path. Ordered 64 KiB +chunks and SHA-256 receipts bound each archive to 50 MiB, each control frame to +1 MiB and each connection to one transfer. Begin, chunk and commit are never retried; +rejected, failed and unknown effects remain distinct. Runtime owns transfer and +filesystem work through settlement, including disconnect or cancellation. + +The common Linux binding authorizes local directories, excluding aliases, Runtime +credentials, native history and initialization roots or their ancestors. One shared +parser writes `installed.json` below the operator-owned capability directory +(default `/environment/initialization/capabilities`, selected at startup through +`OAC_RUNTIME_CAPABILITY_DIRECTORY`). The root cannot overlap the workspace or +private Runtime state and is never a public input. All adapters consume the same +resolved installation root. The manifest +binds Session/Environment identities and the ordered source-selection digest. +Filesystem locking prevents overlapping installation. A partial, invalid or foreign +snapshot fails closed without deleting files or silently reinstalling. Directory +sources are copied only during first preparation; reconnect loads protected installed +contents and validates the selection without rereading source directories. A new +Session uses its own Environment and fresh snapshot. + +Synchronous executor admission validates the frozen descriptor only. The admitted +asynchronous preparation owner ensures capabilities are ready before invoking the +native factory, including an empty inventory. Resolved Skill paths and MCP declarations +remain Runtime-only fields; engine adapters consume this common result. Reused +Session executors retain their original configuration. Files reads retain their +separate authorization and do not require capability or native execution readiness. + +Self-hosted public input remains `workspace_directory` and optional +`capability_directories`; it does not accept managed archive fields. Its public +installation arrays describe API-managed uploads and stay empty for local directories. +MCP environment variables must come from explicit immutable Runtime tool configuration; +missing variables never fall back to daemon credentials or ambient process variables. +No project-version upgrade or historical manifest compatibility is introduced. + ### API-key write provenance Public resource writes carry authenticated key provenance separately from the diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go index 5f48890fc..e3310414b 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go @@ -15,19 +15,19 @@ func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) req := workspaceRequest() token := "selected-user-token" t.Setenv("MCP_TOKEN", "unselected-native-token") - req.LocalEnvironment = &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{ - {PackageRoot: "plugins/local", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "untrusted-package-command", Args: []string{"package-argument"}, EnvVars: []string{"MCP_TOKEN"}}}, - {PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp", BearerTokenEnvVar: "MCP_TOKEN"}, BearerToken: &token}, + req.LocalEnvironment = &proto.LocalEnvironment{CapabilityRoot: "/private/runtime/capabilities", NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{ + {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/local", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "untrusted-package-command", Args: []string{"package-argument"}, EnvVars: []string{"MCP_TOKEN"}}}, + {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp", BearerTokenEnvVar: "MCP_TOKEN"}, BearerToken: &token}, }} start, env, err := prepare(config, req) if err != nil { t.Fatal(err) } - if start.MCPHTTPServers != nil || len(start.Workspace.MCP) != 2 { + if start.MCPHTTPServers != nil || len(start.Workspace.MCP) != 2 || start.Workspace.CapabilityRoot != req.LocalEnvironment.CapabilityRoot { t.Fatal("environment declarations changed authority") } stdio := start.Workspace.MCP[0] - if stdio.Command != "/usr/bin/python3" || len(stdio.Args) != 6 || stdio.Args[4] != "plugins/local" || stdio.Args[5] != "local" { + if stdio.Command != "/usr/bin/python3" || len(stdio.Args) != 8 || stdio.Args[4] != "/private/runtime/capabilities" || stdio.Args[5] != "/private/runtime/workspace" || stdio.Args[6] != "plugins/local" || stdio.Args[7] != "local" { t.Fatal("stdio bypassed the shared installed entry") } raw, _ := json.Marshal(start) @@ -65,7 +65,7 @@ func TestEnvironmentMCPRejectsUnqualifiedCombinations(t *testing.T) { e.MCP[0].Server.URL = "http://example.invalid/mcp" }, } { - environment := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"}}}} + environment := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/remote", Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.invalid/mcp"}}}} mutate(environment) if _, _, err := prepareEnvironmentMCP(environment); err == nil { t.Fatal("unsupported declaration accepted") diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index 3a58c38b2..42356a754 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -30,6 +30,7 @@ type WorkspaceConfig struct { } type workspaceProfile struct { + CapabilityRoot string `json:"capability_root,omitempty"` MCP []environmentMCPServer `json:"mcp,omitempty"` Skills []agentcapabilities.InstalledSkill `json:"skills,omitempty"` ToolEnvironment bool `json:"tool_environment,omitempty"` @@ -67,6 +68,7 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace } if req.LocalEnvironment != nil { profile.Skills = req.LocalEnvironment.Skills + profile.CapabilityRoot = req.LocalEnvironment.CapabilityRoot } servers, credentials, err := prepareEnvironmentMCP(req.LocalEnvironment) if err != nil { diff --git a/apps/parsar-daemon/internal/agent/codex/capability_root_test.go b/apps/parsar-daemon/internal/agent/codex/capability_root_test.go new file mode 100644 index 000000000..36201a246 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/capability_root_test.go @@ -0,0 +1,13 @@ +package codex + +import ( + "testing" +) + +func TestCapabilityRootIsOneQuotedReadOnlyNativeKey(t *testing.T) { + var plan SessionPlan + configureCapabilityRoot(&plan, "managed-workspace-enabled", "/tmp/root.with.dot/quote\"inside") + if len(plan.ExtraConfig) != 1 || plan.ExtraConfig[0][0] != "permissions.\"managed-workspace-enabled\".filesystem.\"/tmp/root.with.dot/quote\\\"inside\"" || plan.ExtraConfig[0][1] != "\"read\"" { + t.Fatal("root escaped its read-only key", plan.ExtraConfig) + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go index 6ec33f262..37f368b81 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go @@ -13,15 +13,15 @@ import ( func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing.T) { token := "user-token" local := &proto.LocalEnvironment{NetworkAccess: "enabled", MCP: []proto.EnvironmentMCP{ - {PackageRoot: "plugins/0", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "must-not-be-native-command", Args: []string{"private-argument"}}}, - {PackageRoot: "plugins/1", BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.com/mcp", HTTPHeaders: map[string]string{"X-Key": "literal-${DO_NOT_EXPAND}"}}}, + {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/0", Server: agentplugin.MCPServer{Name: "local", Type: "stdio", Command: "must-not-be-native-command", Args: []string{"private-argument"}}}, + {InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/1", BearerToken: &token, Server: agentplugin.MCPServer{Name: "remote", Type: "http", URL: "https://example.com/mcp", HTTPHeaders: map[string]string{"X-Key": "literal-${DO_NOT_EXPAND}"}}}, }} servers, env, err := mergeEnvironmentMCP(nil, local) if err != nil || len(servers) != 2 || len(env) != 2 { t.Fatal("environment declarations were not projected", err) } if servers["local"].Command != "/usr/bin/python3" || !servers["local"].ApproveTools || - !slices.Equal(servers["local"].Args, []string{"-I", "-S", "/usr/local/bin/oac-runtime-initialize", "stdio", "plugins/0", "local"}) { + !slices.Equal(servers["local"].Args, []string{"-I", "-S", "/usr/local/bin/oac-runtime-initialize", "stdio", "/private/runtime/capabilities", "/private/runtime/workspace", "plugins/0", "local"}) { t.Fatal("native stdio bypasses the packaged launcher") } remote := servers["remote"] diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go index 35313f715..b0550f894 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -34,6 +34,9 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg plan.Permissions = profile plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"default_permissions", tomlQuoteString(profile)}) configureRestrictedShellEnvironment(&plan) + if req.LocalEnvironment != nil && req.LocalEnvironment.CapabilityRoot != "" { + configureCapabilityRoot(&plan, profile, req.LocalEnvironment.CapabilityRoot) + } // Native login-shell snapshots live outside the managed tool filesystem. plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.shell_snapshot", "false"}) } @@ -110,3 +113,9 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg } return plan, skillRoots, nil } + +// The Runtime supplies this resolved root after validating its binding. Quote the +// path as one TOML key; neither public options nor source directories select it. +func configureCapabilityRoot(plan *SessionPlan, profile, root string) { + plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"permissions." + tomlQuoteString(profile) + ".filesystem." + tomlQuoteString(root), tomlQuoteString("read")}) +} diff --git a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go index 20d73b99c..3b78994a6 100644 --- a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go @@ -15,7 +15,7 @@ import ( ) func environmentMCPFixture() proto.EnvironmentMCP { - return proto.EnvironmentMCP{PackageRoot: "plugins/fixture", Server: agentplugin.MCPServer{ + return proto.EnvironmentMCP{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/fixture", Server: agentplugin.MCPServer{ Name: "proof.server", Type: "stdio", Command: "never-exec-before-sandbox", Args: []string{"private-argument"}, EnvVars: []string{"USER_SELECTED"}, CWD: "resources", }} @@ -54,7 +54,7 @@ func TestEnvironmentMCPUsesFixedLauncherForNewAndLoadedSessions(t *testing.T) { } server := params.MCP[1] if server.Name != "proof.server" || server.Command != "/usr/bin/python3" || server.Env == nil || len(server.Env) != 0 || - !reflect.DeepEqual(server.Args, []string{"-I", "-S", "/usr/local/bin/oac-runtime-initialize", "stdio", "plugins/fixture", "proof.server"}) { + !reflect.DeepEqual(server.Args, []string{"-I", "-S", "/usr/local/bin/oac-runtime-initialize", "stdio", "/private/runtime/capabilities", "/private/runtime/workspace", "plugins/fixture", "proof.server"}) { t.Fatal("ACP declaration bypassed the fixed isolated launcher") } if (params.SessionID != "") != resume || strings.Contains(string(raw), "must-not") || strings.Contains(string(raw), "private-argument") { diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index 88864e976..79ed46902 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -103,7 +103,7 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P if err = os.WriteFile(filepath.Join(opts.DataDir, "config.yaml"), raw, 0600); err != nil { return opts, err } - profile := map[string]any{"workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "protectedDirs": slices.Clone(c.ProtectedDirs), "skills": len(req.LocalEnvironment.Skills) > 0} + profile := map[string]any{"capabilityRoot": req.LocalEnvironment.CapabilityRoot, "workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "protectedDirs": slices.Clone(c.ProtectedDirs), "skills": len(req.LocalEnvironment.Skills) > 0} if req.LocalEnvironment.ToolEnvironment { if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { return opts, err diff --git a/apps/parsar-daemon/internal/cli/connect_environment.go b/apps/parsar-daemon/internal/cli/connect_environment.go index e02c1c551..bfdb5a263 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment.go +++ b/apps/parsar-daemon/internal/cli/connect_environment.go @@ -18,6 +18,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/google/uuid" ) @@ -113,7 +114,7 @@ func enrollEnvironment(ctx context.Context, client *http.Client, base, environme return out, fmt.Errorf("connect: Environment enrollment rejected (HTTP %d)", resp.StatusCode) } raw, err := io.ReadAll(io.LimitReader(resp.Body, 16*1024+1)) - if err != nil || len(raw) > 16*1024 || decodeEnvironmentJSON(raw, &out) != nil || !environmentUUID(out.DeviceID) || !environmentUUID(out.SessionID) || out.EnvironmentID != environment || out.WorkspaceDirectory != "/workspace" { + if err != nil || len(raw) > 16*1024 || decodeEnvironmentJSON(raw, &out) != nil || !environmentUUID(out.DeviceID) || !environmentUUID(out.SessionID) || out.EnvironmentID != environment || out.WorkspaceDirectory == "/" || agentcapabilities.ValidateLocalDirectories([]string{out.WorkspaceDirectory}) != nil { return environmentEnrollment{}, errors.New("connect: invalid Environment enrollment response") } return out, nil diff --git a/apps/parsar-daemon/internal/cli/connect_environment_binding.go b/apps/parsar-daemon/internal/cli/connect_environment_binding.go index df3d31833..3eb887058 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_binding.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_binding.go @@ -11,13 +11,15 @@ import ( "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" ) // This receipt contains identity only; executor credentials remain in their file. type environmentBinding struct { - RemoteURL string `json:"remote_url"` - Enrollment environmentEnrollment `json:"enrollment"` - LocalWorkspace string `json:"local_workspace"` + RemoteURL string `json:"remote_url"` + Enrollment environmentEnrollment `json:"enrollment"` + LocalWorkspace string `json:"local_workspace"` + CapabilityDirectory string `json:"capability_directory"` } // Check persisted ownership before transmitting the executor credential. @@ -70,8 +72,25 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti return errors.New("connect: Runtime state directory unavailable") } workspace := os.Getenv("OAC_RUNTIME_WORKSPACE") - if workspace != "/environment/workspace" { - return errors.New("connect: packaged /workspace Runtime required") + if workspace == "/" || agentcapabilities.ValidateLocalDirectories([]string{workspace}) != nil { + return errors.New("connect: clean absolute Runtime workspace required") + } + canonical, err := filepath.EvalSymlinks(workspace) + info, statErr := os.Stat(workspace) + if err != nil || canonical != workspace || statErr != nil || !info.IsDir() { + return errors.New("connect: existing canonical Runtime workspace directory required") + } + if bound.WorkspaceDirectory != "/workspace" && bound.WorkspaceDirectory != workspace { + return errors.New("connect: enrollment does not match the Runtime workspace") + } + initialization := localworkspace.InitializationDirectory + if resolved, resolveErr := filepath.EvalSymlinks(initialization); resolveErr == nil { + initialization = resolved + } else if !errors.Is(resolveErr, os.ErrNotExist) { + return errors.New("connect: protected initialization directory unavailable") + } + if environmentPathsOverlap(workspace, initialization) { + return errors.New("connect: Runtime workspace overlaps protected initialization") } for key, value := range map[string]string{ "OAC_RUNTIME_ENVIRONMENT_ID": bound.EnvironmentID, @@ -95,8 +114,8 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti return errors.New("connect: private Runtime path unavailable") } for _, public := range []string{"/workspace", workspace} { - if relative, e := filepath.Rel(public, resolved); e == nil && (relative == "." || filepath.IsLocal(relative)) { - return errors.New("connect: private Runtime state cannot be inside the workspace") + if environmentPathsOverlap(public, resolved) { + return errors.New("connect: private Runtime state cannot overlap the workspace") } } } @@ -109,7 +128,14 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti if err != nil || !filepath.IsLocal(relative) || relative == "." { return errors.New("connect: executor credential must be inside the protected daemon directory") } - want := environmentBinding{RemoteURL: remote, Enrollment: bound, LocalWorkspace: workspace} + capabilityDirectory := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") + if capabilityDirectory == "" { + capabilityDirectory = localworkspace.CapabilityDirectory + } + if _, err := localworkspace.NewWithCapabilityDirectory(bound.EnvironmentID, bound.SessionID, workspace, os.Getenv("OAC_RUNTIME_DIRECTORY_HELPER"), capabilityDirectory); err != nil { + return errors.New("connect: local Runtime layout unavailable") + } + want := environmentBinding{RemoteURL: remote, Enrollment: bound, LocalWorkspace: workspace, CapabilityDirectory: capabilityDirectory} if err = saveEnvironmentBinding(root, want); err != nil { return err } @@ -125,6 +151,17 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti return nil } +// Neither selecting private state nor selecting its parent grants workspace access. +func environmentPathsOverlap(first, second string) bool { + for _, pair := range [][2]string{{first, second}, {second, first}} { + relative, err := filepath.Rel(pair[0], pair[1]) + if err == nil && (relative == "." || filepath.IsLocal(relative)) { + return true + } + } + return false +} + func saveEnvironmentBinding(root string, want environmentBinding) error { // All three native sandboxes protect this existing daemon state directory. dir := filepath.Join(root, "daemon") diff --git a/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go b/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go new file mode 100644 index 000000000..b9223181e --- /dev/null +++ b/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go @@ -0,0 +1,136 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" +) + +func environmentRuntimeFixture(t *testing.T) (root, workspace, credential string, bound environmentEnrollment) { + t.Helper() + base := t.TempDir() + root, workspace = filepath.Join(base, "private"), filepath.Join(base, "workspace") + for _, dir := range []string{filepath.Join(root, "daemon"), workspace} { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + } + credential = filepath.Join(root, "daemon", "executor.json") + if err := os.WriteFile(credential, []byte("private-test-credential"), 0600); err != nil { + t.Fatal(err) + } + helper := filepath.Join(base, "directory-helper") + if err := os.WriteFile(helper, []byte("#!/bin/sh\nexit 0\n"), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_HOME", root) + t.Setenv("OAC_RUNTIME_WORKSPACE", workspace) + t.Setenv("OAC_RUNTIME_DIRECTORY_HELPER", helper) + for _, name := range []string{"OAC_RUNTIME_ENVIRONMENT_ID", "OAC_RUNTIME_SESSION_ID", "OAC_RUNTIME_NETWORK_ACCESS", "OAC_RUNTIME_ALLOWED_DOMAINS", "OAC_RUNTIME_WRITE_HELPER", "OAC_RUNTIME_EXPORT_HELPER", "OAC_RUNTIME_STAGING"} { + t.Setenv(name, "") + } + return root, workspace, credential, environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"} +} + +func TestBindEnvironmentRuntimeUsesCanonicalOperatorWorkspace(t *testing.T) { + for _, physical := range []bool{false, true} { + t.Run(map[bool]string{false: "logical", true: "physical"}[physical], func(t *testing.T) { + root, workspace, credential, bound := environmentRuntimeFixture(t) + if physical { + bound.WorkspaceDirectory = workspace + } + if err := bindEnvironmentRuntime("wss://core/api/v1/agent-daemon/ws", bound, credential); err != nil { + t.Fatal(err) + } + raw, err := readEnvironmentPrivateFile(filepath.Join(root, "daemon", "environment.json")) + if err != nil { + t.Fatal(err) + } + var receipt environmentBinding + if decodeEnvironmentJSON(raw, &receipt) != nil || receipt.LocalWorkspace != workspace || receipt.Enrollment != bound { + t.Fatal("workspace identity changed", receipt) + } + if err := bindEnvironmentRuntime(receipt.RemoteURL, bound, credential); err != nil { + t.Fatal("same binding refused", err) + } + replacement := filepath.Join(filepath.Dir(workspace), "replacement") + if err := os.Mkdir(replacement, 0700); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_WORKSPACE", replacement) + if err := bindEnvironmentRuntime(receipt.RemoteURL, bound, credential); err == nil { + t.Fatal("existing workspace identity replaced") + } + after, _ := os.ReadFile(filepath.Join(root, "daemon", "environment.json")) + if string(after) != string(raw) { + t.Fatal("failed rebind changed identity") + } + contents, _ := os.ReadFile(credential) + if string(contents) != "private-test-credential" { + t.Fatal("credential changed") + } + }) + } +} + +func TestBindEnvironmentRuntimeRejectsUnsafeOrMismatchedWorkspace(t *testing.T) { + for _, kind := range []string{"relative", "unclean", "missing", "file", "symlink", "parent symlink", "workspace contains private", "workspace inside private", "foreign enrollment", "credential outside private", "identity conflict"} { + t.Run(kind, func(t *testing.T) { + root, workspace, credential, bound := environmentRuntimeFixture(t) + selected := workspace + switch kind { + case "relative": + selected = "workspace" + case "unclean": + selected = workspace + "/../workspace" + case "missing": + selected = filepath.Join(filepath.Dir(workspace), "missing") + case "file": + selected = filepath.Join(filepath.Dir(workspace), "regular") + if err := os.WriteFile(selected, nil, 0600); err != nil { + t.Fatal(err) + } + case "symlink": + selected = filepath.Join(filepath.Dir(workspace), "linked") + if err := os.Symlink(workspace, selected); err != nil { + t.Fatal(err) + } + case "parent symlink": + child := filepath.Join(workspace, "child") + if err := os.Mkdir(child, 0700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(filepath.Dir(workspace), "linked-parent") + if err := os.Symlink(workspace, alias); err != nil { + t.Fatal(err) + } + selected = filepath.Join(alias, "child") + case "workspace contains private": + selected = filepath.Dir(root) + case "workspace inside private": + selected = filepath.Join(root, "nested-workspace") + if err := os.Mkdir(selected, 0700); err != nil { + t.Fatal(err) + } + case "foreign enrollment": + bound.WorkspaceDirectory = filepath.Join(filepath.Dir(workspace), "different") + case "credential outside private": + credential = filepath.Join(filepath.Dir(root), "credential") + if err := os.WriteFile(credential, []byte("private-test-credential"), 0600); err != nil { + t.Fatal(err) + } + case "identity conflict": + t.Setenv("OAC_RUNTIME_ENVIRONMENT_ID", uuid.NewString()) + } + t.Setenv("OAC_RUNTIME_WORKSPACE", selected) + if err := bindEnvironmentRuntime("wss://core/api/v1/agent-daemon/ws", bound, credential); err == nil { + t.Fatal("unsafe binding accepted") + } + if _, err := os.Lstat(filepath.Join(root, "daemon", "environment.json")); !os.IsNotExist(err) { + t.Fatal("invalid binding published identity", err) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/cli/connect_environment_test.go b/apps/parsar-daemon/internal/cli/connect_environment_test.go index dd03b66dd..7cefe7261 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_test.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_test.go @@ -154,7 +154,7 @@ func TestEnvironmentBindingPreservesIdentityAndHistory(t *testing.T) { if err := os.Chmod(root, 0700); err != nil { t.Fatal(err) } - want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace"} + want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} if err := saveEnvironmentBinding(root, want); err != nil { t.Fatal(err) } @@ -191,7 +191,7 @@ func TestEnvironmentBindingAllowsPackagedFilesAndRejectsUnsafeReceipt(t *testing if err := os.Chmod(root, 0700); err != nil { t.Fatal(err) } - want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace"} + want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} if err := os.WriteFile(filepath.Join(root, "installed-bundle"), []byte("bundle"), 0600); err != nil { t.Fatal(err) } @@ -213,7 +213,7 @@ func TestEnvironmentTargetCheckedBeforeCredentialTransmission(t *testing.T) { t.Fatal(err) } t.Setenv("OAC_RUNTIME_HOME", root) - want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace"} + want := environmentBinding{"wss://core/api/v1/agent-daemon/ws", environmentEnrollment{uuid.NewString(), uuid.NewString(), uuid.NewString(), "/workspace"}, "/environment/workspace", "/environment/initialization/capabilities"} if err := saveEnvironmentBinding(root, want); err != nil { t.Fatal(err) } @@ -231,7 +231,7 @@ func TestEnvironmentTargetCheckedBeforeCredentialTransmission(t *testing.T) { func TestEnvironmentEnrollmentRejectsWrongIdentityAndWorkspace(t *testing.T) { environment := uuid.NewString() good := environmentEnrollment{uuid.NewString(), uuid.NewString(), environment, "/workspace"} - for _, mutate := range []func(*environmentEnrollment){func(b *environmentEnrollment) { b.EnvironmentID = uuid.NewString() }, func(b *environmentEnrollment) { b.DeviceID = "" }, func(b *environmentEnrollment) { b.SessionID = "invalid" }, func(b *environmentEnrollment) { b.WorkspaceDirectory = "/different" }} { + for _, mutate := range []func(*environmentEnrollment){func(b *environmentEnrollment) { b.EnvironmentID = uuid.NewString() }, func(b *environmentEnrollment) { b.DeviceID = "" }, func(b *environmentEnrollment) { b.SessionID = "invalid" }, func(b *environmentEnrollment) { b.WorkspaceDirectory = "relative" }} { bad := good mutate(&bad) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(bad) })) @@ -253,3 +253,14 @@ func TestEnvironmentConnectRejectsPairing(t *testing.T) { t.Fatal("pairing accepted or leaked") } } + +func TestEnvironmentEnrollmentAcceptsPhysicalWorkspaceSelection(t *testing.T) { + environment := uuid.NewString() + want := environmentEnrollment{uuid.NewString(), uuid.NewString(), environment, "/srv/runtime/workspace"} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(want) })) + defer server.Close() + actual, err := enrollEnvironment(t.Context(), environmentClient(), server.URL, environment, "secret") + if err != nil || actual != want { + t.Fatal("canonical workspace selection refused", actual, err) + } +} diff --git a/apps/parsar-daemon/internal/cli/retired_configuration_test.go b/apps/parsar-daemon/internal/cli/retired_configuration_test.go index 740c8640a..8ba18fdc7 100644 --- a/apps/parsar-daemon/internal/cli/retired_configuration_test.go +++ b/apps/parsar-daemon/internal/cli/retired_configuration_test.go @@ -11,7 +11,7 @@ func TestRetiredRuntimeSettingsRejectBeforeEverySubcommand(t *testing.T) { t.Setenv("PARSAR_UNKNOWN_RUNTIME_SETTING", "private-canary") t.Setenv("PARSAR_CODEX_BIN", "") t.Setenv("PARSAR_MCP_BEARER_EXAMPLE", "private-bearer") - for _, command := range []string{"version", "--help", "connect", "runtime-capabilities"} { + for _, command := range []string{"version", "--help", "connect", "runtime-mcp-exec"} { stdout, stderr, err := runArgv(t, command) if err == nil || stdout != "" || stderr != "" { t.Fatalf("%s ran before retirement validation: %q %q %v", command, stdout, stderr, err) diff --git a/apps/parsar-daemon/internal/cli/root.go b/apps/parsar-daemon/internal/cli/root.go index 85d0ac474..46f72f553 100644 --- a/apps/parsar-daemon/internal/cli/root.go +++ b/apps/parsar-daemon/internal/cli/root.go @@ -34,7 +34,6 @@ func defaultRunContext() *runContext { // likely flow connect → status → stop / logs → logout. var commands = []command{ {name: "resume", summary: "Wake one planned hosted suspension", run: runResume}, - {name: "runtime-capabilities", summary: "Install frozen capabilities in the packaged Runtime", run: runRuntimeCapabilities}, {name: "runtime-mcp-exec", summary: "Execute installed MCP inside the packaged Runtime sandbox", run: runRuntimeMCP}, {name: "placement", summary: "Enroll or retire an explicitly managed local execution placement", run: runPlacement}, {name: "connect", summary: "Pair, open the reverse WebSocket, and start serving prompts", run: runConnect}, diff --git a/apps/parsar-daemon/internal/cli/root_test.go b/apps/parsar-daemon/internal/cli/root_test.go index 0bb8d576b..77f1be614 100644 --- a/apps/parsar-daemon/internal/cli/root_test.go +++ b/apps/parsar-daemon/internal/cli/root_test.go @@ -60,16 +60,15 @@ func TestSubcommandsAreRegistered(t *testing.T) { // Guards against dropping a subcommand off the commands slice — // the public CLI surface is the shipped contract. want := map[string]bool{ - "resume": false, - "runtime-capabilities": false, - "runtime-mcp-exec": false, - "placement": false, - "connect": false, - "status": false, - "stop": false, - "logs": false, - "logout": false, - "version": false, + "resume": false, + "runtime-mcp-exec": false, + "placement": false, + "connect": false, + "status": false, + "stop": false, + "logs": false, + "logout": false, + "version": false, } for _, c := range commands { if _, ok := want[c.name]; !ok { @@ -84,3 +83,14 @@ func TestSubcommandsAreRegistered(t *testing.T) { } } } + +func TestRemovedCapabilityHelperIsNotACommand(t *testing.T) { + help, _, err := runArgv(t, "--help") + if err != nil || strings.Contains(help, "runtime-capabilities") { + t.Fatal("removed helper remains advertised", err) + } + _, _, err = runArgv(t, "runtime-capabilities") + if err == nil || !strings.Contains(err.Error(), "unknown subcommand") { + t.Fatal("removed helper still dispatches", err) + } +} diff --git a/apps/parsar-daemon/internal/cli/runtime_capabilities.go b/apps/parsar-daemon/internal/cli/runtime_capabilities.go deleted file mode 100644 index ac17058ba..000000000 --- a/apps/parsar-daemon/internal/cli/runtime_capabilities.go +++ /dev/null @@ -1,56 +0,0 @@ -package cli - -import ( - "encoding/json" - "fmt" - "io" - "os" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" -) - -// This packaged helper is invoked by the existing Core initializer. It has no -// daemon connection, credentials, scheduling or retry behavior. -func runRuntimeCapabilities(ctx *runContext, args []string) error { - if len(args) != 0 { - return agentcapabilities.ErrInvalid - } - var request agentcapabilities.Operation - decoder := json.NewDecoder(io.LimitReader(os.Stdin, 32<<20)) - decoder.DisallowUnknownFields() - if decoder.Decode(&request) != nil || request.Version != 1 { - return agentcapabilities.ErrInvalid - } - var extra any - if decoder.Decode(&extra) != io.EOF { - return agentcapabilities.ErrInvalid - } - root, err := os.OpenRoot("/environment") - if err != nil { - return agentcapabilities.ErrInvalid - } - defer root.Close() - installed, err := root.OpenRoot("initialization/capabilities") - if err != nil { - return agentcapabilities.ErrInvalid - } - defer installed.Close() - switch request.Action { - case "plugin": - err = agentcapabilities.InstallPlugin(installed, request.Slot, request.Archive, request.Plugin) - case "finalize": - workspace, openErr := root.OpenRoot("workspace") - if openErr != nil { - return agentcapabilities.ErrInvalid - } - defer workspace.Close() - err = agentcapabilities.Finalize(workspace, installed, request.Sources) - default: - err = agentcapabilities.ErrInvalid - } - if err != nil { - return agentcapabilities.ErrInvalid - } - _, err = fmt.Fprintln(ctx.stdout, `{"version":1,"outcome":"completed"}`) - return err -} diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp.go b/apps/parsar-daemon/internal/cli/runtime_mcp.go index b8f6e7483..fcd030536 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp.go @@ -23,10 +23,10 @@ type mcpInvocation struct { // runRuntimeMCP executes only inside the packaged initializer's sandbox. It // never consumes MCP stdin, opens a daemon connection or owns a child process. func runRuntimeMCP(_ *runContext, args []string) error { - if len(args) != 2 { + if len(args) != 3 || args[0] == "/" || agentcapabilities.ValidateLocalDirectories([]string{args[0]}) != nil { return errRuntimeMCP } - root, err := os.OpenRoot(agentcapabilities.Directory) + root, err := os.OpenRoot(args[0]) if err != nil { return errRuntimeMCP } @@ -35,18 +35,21 @@ func runRuntimeMCP(_ *runContext, args []string) error { if err != nil { return errRuntimeMCP } - values, err := localworkspace.ReadToolEnvironment() + values, err := localworkspace.ReadOptionalToolEnvironment() if err != nil { return errRuntimeMCP } - invocation, err := resolveMCPInvocation(manifest, args[0], args[1], values) + invocation, err := resolveMCPInvocation(manifest, args[0], args[1], args[2], values) if err != nil { return errRuntimeMCP } return execRuntimeMCP(invocation) } -func resolveMCPInvocation(manifest agentcapabilities.Manifest, pkg, name string, values map[string]string) (mcpInvocation, error) { +func resolveMCPInvocation(manifest agentcapabilities.Manifest, installationRoot, pkg, name string, values map[string]string) (mcpInvocation, error) { + if installationRoot == "/" || agentcapabilities.ValidateLocalDirectories([]string{installationRoot}) != nil { + return mcpInvocation{}, errRuntimeMCP + } for _, installed := range manifest.MCP { server := installed.Server if installed.PackageRoot != pkg || server.Name != name { @@ -72,7 +75,7 @@ func resolveMCPInvocation(manifest agentcapabilities.Manifest, pkg, name string, } cwd := server.CWD if !filepath.IsAbs(cwd) { - cwd = filepath.Join(agentcapabilities.Directory, installed.PackageRoot, cwd) + cwd = filepath.Join(installationRoot, installed.PackageRoot, cwd) } result := mcpInvocation{command: server.Command, args: append([]string{server.Command}, server.Args...), cwd: cwd} for key, value := range env { diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_test.go b/apps/parsar-daemon/internal/cli/runtime_mcp_test.go index 39b43ec54..960b517a5 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_test.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_test.go @@ -14,16 +14,16 @@ func TestRuntimeMCPSelectsOnlyInstalledUserEnvironment(t *testing.T) { }}}} t.Setenv("SELECTED", "private-native-value") t.Setenv("NATIVE_ONLY", "private-native-value") - got, err := resolveMCPInvocation(manifest, "plugins/0", "local", map[string]string{"SELECTED": "user-value", "UNDECLARED": "not-injected"}) - if err != nil || got.command != "python3" || got.cwd != agentcapabilities.Directory+"/plugins/0/server" || + got, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", map[string]string{"SELECTED": "user-value", "UNDECLARED": "not-injected"}) + if err != nil || got.command != "python3" || got.cwd != "/private/runtime/capabilities/plugins/0/server" || !slices.Equal(got.args, []string{"python3", "proof.py", "${LITERAL}"}) || !slices.Contains(got.env, "SELECTED=user-value") || slices.Contains(got.env, "UNDECLARED=not-injected") || slices.Contains(got.env, "NATIVE_ONLY=private-native-value") { t.Fatalf("incorrect isolated invocation: %+v %v", got, err) } - if _, err := resolveMCPInvocation(manifest, "plugins/0", "local", map[string]string{}); err == nil { + if _, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", map[string]string{}); err == nil { t.Fatal("missing user value fell back to native environment") } - if _, err := resolveMCPInvocation(manifest, "plugins/1", "local", map[string]string{"SELECTED": "x"}); err == nil { + if _, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/1", "local", map[string]string{"SELECTED": "x"}); err == nil { t.Fatal("undeclared package selected") } } @@ -32,12 +32,20 @@ func TestRuntimeMCPKeepsAbsoluteCWDAndRejectsHTTP(t *testing.T) { manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ Name: "local", Type: "stdio", Command: "python3", CWD: "/workspace", }}}} - got, err := resolveMCPInvocation(manifest, "plugins/0", "local", nil) + got, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", nil) if err != nil || got.cwd != "/workspace" { t.Fatalf("absolute cwd changed: %+v %v", got, err) } manifest.MCP[0].Server.Type = "http" - if _, err := resolveMCPInvocation(manifest, "plugins/0", "local", nil); err == nil { + if _, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", nil); err == nil { t.Fatal("HTTP configuration treated as a process") } } + +func TestRuntimeMCPRejectsUncleanInstallationRoot(t *testing.T) { + for _, root := range []string{"", "/", "relative", "/tmp/../private", "/tmp/root/", "/tmp/line\n"} { + if _, err := resolveMCPInvocation(agentcapabilities.Manifest{}, root, "plugins/0", "local", nil); err == nil { + t.Fatal("invalid root accepted", root) + } + } +} diff --git a/apps/parsar-daemon/internal/dispatch/capabilities_execution_test.go b/apps/parsar-daemon/internal/dispatch/capabilities_execution_test.go new file mode 100644 index 000000000..4e376303f --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/capabilities_execution_test.go @@ -0,0 +1,56 @@ +package dispatch_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Directory selection is syntactically valid and passes frozen configuration +// admission. Runtime preparation must fail before creating a native Executor. +func TestCapabilitiesUnavailablePreventsNativeExecutor(t *testing.T) { + for _, mode := range []string{"missing-directory", "invalid-skill"} { + t.Run(mode, func(t *testing.T) { + source := filepath.Join(t.TempDir(), "capability") + if mode == "invalid-skill" { + if err := os.Mkdir(source, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("not a portable Skill"), 0600); err != nil { + t.Fatal(err) + } + } + sender := &recSender{} + var calls atomic.Int32 + r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { + calls.Add(1) + return nil, errors.New("native factory must not be reached") + }) + installation := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") + if info, err := os.Stat(installation); err != nil || !info.IsDir() { + t.Fatalf("capability fixture is unavailable: %v", err) + } + request := preparationRequest() + request.Configuration.LocalEnvironment.Capabilities = true + request.Configuration.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source}} + if err := r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "missing-capability", request)); err != nil { + t.Fatalf("valid frozen selection rejected before preparation: %v", err) + } + status := waitPreparationStatus(t, sender, "missing-capability", "failed", "") + if _, err := os.Stat(filepath.Join(installation, agentcapabilities.ManifestName)); !os.IsNotExist(err) { + t.Fatal("invalid source published an installed snapshot") + } + if status.ErrorCode != "preparation_failed" || calls.Load() != 0 || r.ActiveRuns() != 0 { + t.Fatalf("failed capability preparation reached native execution: status=%+v calls=%d", status, calls.Load()) + } + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/capabilities_prepare.go b/apps/parsar-daemon/internal/dispatch/capabilities_prepare.go new file mode 100644 index 000000000..118bdb736 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/capabilities_prepare.go @@ -0,0 +1,186 @@ +package dispatch + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +const capabilitiesPrepareTimeout = 120 * time.Second + +// Router.mu protects one connection-local transfer. Partial installation data +// belongs to the bound Environment and is never removed by transfer cleanup. +type capabilitiesUpload struct { + envelope proto.Envelope + request proto.CapabilitiesPreparePayload + data []byte + ready chan struct{} + cancel context.CancelFunc + finished bool + apply bool + uncertain bool +} + +func (r *Router) handleCapabilitiesPrepare(ctx context.Context, env proto.Envelope) error { + id, err := uuid.Parse(env.ID) + if err != nil || id == uuid.Nil || id.String() != env.ID { + return errors.New("dispatch: invalid capability preparation identity") + } + var request proto.CapabilitiesPreparePayload + if len(env.Payload) > proto.CapabilitiesMaxFrameBytes || env.DecodePayload(&request) != nil || !proto.ValidCapabilitiesPrepareRequest(request) { + r.mu.Lock() + pending := r.capabilitiesPrepare != nil && r.capabilitiesPrepare.envelope.ID == env.ID + if pending && !r.capabilitiesPrepare.finished { + r.finishCapabilitiesUploadLocked(r.capabilitiesPrepare, false) + } + r.mu.Unlock() + if pending { + // A late malformed frame cannot report rejection of an earlier commit. + return errors.New("dispatch: malformed pending capability frame") + } + return r.sendCapabilitiesResult(ctx, env.ID, rejectedCapabilities("invalid_request")) + } + r.mu.Lock() + if r.closed || r.suspension != nil { + r.mu.Unlock() + return ErrRouterClosed + } + if request.Step == "begin" { + if r.capabilitiesPrepare != nil { + duplicate := r.capabilitiesPrepare.envelope.ID == env.ID + r.mu.Unlock() + if duplicate { + return errors.New("dispatch: capability preparation already admitted") + } + return r.sendCapabilitiesResult(ctx, env.ID, rejectedCapabilities("capabilities_capacity")) + } + if r.localWorkspace == nil || !r.localWorkspace.Matches(request.EnvironmentID, request.SessionID) || r.capabilityResourcesBusyLocked() { + r.mu.Unlock() + return r.sendCapabilitiesResult(ctx, env.ID, rejectedCapabilities("resource_unavailable")) + } + owner, cancel := context.WithTimeout(context.WithoutCancel(ctx), capabilitiesPrepareTimeout) + u := &capabilitiesUpload{ + envelope: env, request: request, data: make([]byte, 0, request.SizeBytes), + ready: make(chan struct{}), cancel: cancel, + } + r.capabilitiesPrepare = u + r.shutdownWG.Add(1) + r.mu.Unlock() + go r.runCapabilitiesUpload(owner, u, r.localWorkspace.ApplyCapabilities) + if err := r.sendCapabilitiesResult(ctx, env.ID, proto.CapabilitiesResultPayload{Outcome: "ready"}); err != nil { + cancel() + return err + } + return nil + } + u := r.capabilitiesPrepare + if u == nil || u.envelope.ID != env.ID { + r.mu.Unlock() + return r.sendCapabilitiesResult(ctx, env.ID, rejectedCapabilities("resource_unavailable")) + } + if u.finished { + r.mu.Unlock() + return errors.New("dispatch: capability preparation body already closed") + } + if request.Step == "chunk" && request.Offset == len(u.data) && len(request.Data) <= u.request.SizeBytes-len(u.data) { + u.data = append(u.data, request.Data...) + offset := len(u.data) + r.mu.Unlock() + if err := r.sendCapabilitiesResult(ctx, env.ID, proto.CapabilitiesResultPayload{Outcome: "received", Offset: offset}); err != nil { + u.cancel() + return err + } + return nil + } + apply := false + if request.Step == "commit" && len(u.data) == u.request.SizeBytes { + if u.request.Action == "finalize" { + apply = true + } else { + digest := sha256.Sum256(u.data) + apply = hex.EncodeToString(digest[:]) == u.request.SHA256 + } + } + r.finishCapabilitiesUploadLocked(u, apply) + r.mu.Unlock() + return nil +} + +func (r *Router) capabilityResourcesBusyLocked() bool { + if r.workspaceWrite != nil || r.workspaceExport != nil || len(r.workspaceReads) != 0 || len(r.sessions) != 0 || len(r.idle) != 0 || len(r.executors) != 0 { + return true + } + for _, p := range r.preparations { + if p.owns || p.busy { + return true + } + } + return false +} + +func (r *Router) finishCapabilitiesUploadLocked(u *capabilitiesUpload, apply bool) { + u.apply, u.finished = apply, true + close(u.ready) +} + +// apply must return only after its local mutations stop. Cancellation requests +// shutdown, but cannot release ownership while that call is still running. +func (r *Router) runCapabilitiesUpload(ctx context.Context, u *capabilitiesUpload, apply func(context.Context, proto.CapabilitiesPreparePayload, []byte) error) { + defer r.shutdownWG.Done() + defer u.cancel() + select { + case <-u.ready: + case <-r.shutdownCh: + case <-ctx.Done(): + } + r.mu.Lock() + admitted := u.apply && !r.closed && ctx.Err() == nil + u.finished = true + data := u.data + u.data = nil + r.mu.Unlock() + result := rejectedCapabilities("invalid_request") + if admitted { + result = capabilitiesApplyResult(apply(ctx, u.request, data), u.request.SizeBytes) + } + // Release the potentially large body before waiting on transport delivery. + data = nil + r.mu.Lock() + u.uncertain = result.Outcome == "unknown" + if !u.uncertain && r.capabilitiesPrepare == u { + r.capabilitiesPrepare = nil + } + r.mu.Unlock() + // The result has a separate send budget, independent of an installation timeout. + _ = r.sendCapabilitiesResult(context.WithoutCancel(ctx), u.envelope.ID, result) +} + +func rejectedCapabilities(code string) proto.CapabilitiesResultPayload { + return proto.CapabilitiesResultPayload{Outcome: "rejected", ErrorCode: code} +} + +func capabilitiesApplyResult(err error, size int) proto.CapabilitiesResultPayload { + if err == nil { + return proto.CapabilitiesResultPayload{Outcome: "completed", SizeBytes: size} + } + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) && errors.Is(err, agentcapabilities.ErrInvalid) { + return proto.CapabilitiesResultPayload{Outcome: "failed", ErrorCode: "capabilities_failed"} + } + return proto.CapabilitiesResultPayload{Outcome: "unknown", ErrorCode: "capabilities_unconfirmed"} +} + +func (r *Router) sendCapabilitiesResult(ctx context.Context, id string, result proto.CapabilitiesResultPayload) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + env, err := proto.NewEnvelope(proto.TypeCapabilitiesResult, id, result) + if err != nil { + return err + } + return r.sender.Send(ctx, env) +} diff --git a/apps/parsar-daemon/internal/dispatch/capabilities_prepare_test.go b/apps/parsar-daemon/internal/dispatch/capabilities_prepare_test.go new file mode 100644 index 000000000..ea5293575 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/capabilities_prepare_test.go @@ -0,0 +1,336 @@ +package dispatch + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/google/uuid" +) + +type capabilitiesTestSender struct{ frames chan proto.Envelope } + +func (s *capabilitiesTestSender) Send(ctx context.Context, env proto.Envelope) error { + select { + case s.frames <- env: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func capabilitiesTestRouter(t *testing.T) (*Router, *capabilitiesTestSender, string, string) { + t.Helper() + environment, session := uuid.NewString(), uuid.NewString() + helper := filepath.Join(t.TempDir(), "helper") + if err := os.WriteFile(helper, []byte("#!/bin/sh\nexit 0\n"), 0700); err != nil { + t.Fatal(err) + } + binding, err := localworkspace.New(environment, session, t.TempDir(), helper) + if err != nil { + t.Fatal(err) + } + sender := &capabilitiesTestSender{frames: make(chan proto.Envelope, 64)} + router, err := New(Config{Registry: agent.NewRegistry(), Sender: sender, LocalWorkspace: binding}) + if err != nil { + t.Fatal(err) + } + return router, sender, environment, session +} + +func capabilityEnvelope(t *testing.T, id string, request proto.CapabilitiesPreparePayload) proto.Envelope { + t.Helper() + env, err := proto.NewEnvelope(proto.TypeCapabilitiesPrepare, id, request) + if err != nil { + t.Fatal(err) + } + return env +} + +func capabilityBegin(environment, session string, body []byte) proto.CapabilitiesPreparePayload { + digest := sha256.Sum256(body) + return proto.CapabilitiesPreparePayload{ + Step: "begin", Action: "skill", EnvironmentID: environment, SessionID: session, + Skill: &agentskill.Metadata{Type: "inline", Name: "proof", Description: "A proof."}, + SizeBytes: len(body), SHA256: hex.EncodeToString(digest[:]), + } +} + +func capabilitiesReceipt(t *testing.T, sender *capabilitiesTestSender, id, outcome string) proto.CapabilitiesResultPayload { + t.Helper() + select { + case env := <-sender.frames: + var result proto.CapabilitiesResultPayload + if env.Type != proto.TypeCapabilitiesResult || env.ID != id || env.DecodePayload(&result) != nil || result.Outcome != outcome { + t.Fatalf("unexpected capability receipt: id=%s type=%s result=%+v", env.ID, env.Type, result) + } + return result + case <-time.After(3 * time.Second): + t.Fatal("missing capability receipt") + } + return proto.CapabilitiesResultPayload{} +} + +func shutdownCapabilitiesRouter(t *testing.T, router *Router) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := router.Shutdown(ctx); err != nil { + t.Fatal(err) + } +} + +func TestCapabilitiesTransferValidatesCompleteBodyBeforeMutation(t *testing.T) { + for _, mode := range []string{"offset", "digest", "short", "oversized-chunk"} { + t.Run(mode, func(t *testing.T) { + r, sender, environment, session := capabilitiesTestRouter(t) + defer shutdownCapabilitiesRouter(t, r) + body := bytes.Repeat([]byte("a"), proto.CapabilitiesChunkBytes+1) + request := capabilityBegin(environment, session, body) + if mode == "digest" { + request.SHA256 = stringsOfZeroDigest() + } + id := uuid.NewString() + if err := r.Handle(t.Context(), capabilityEnvelope(t, id, request)); err != nil { + t.Fatal(err) + } + capabilitiesReceipt(t, sender, id, "ready") + r.mu.Lock() + owner := r.capabilitiesPrepare + r.mu.Unlock() + duplicate := uuid.NewString() + if err := r.Handle(t.Context(), capabilityEnvelope(t, duplicate, request)); err != nil { + t.Fatal(err) + } + if got := capabilitiesReceipt(t, sender, duplicate, "rejected"); got.ErrorCode != "capabilities_capacity" { + t.Fatal(got) + } + switch mode { + case "offset": + _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.CapabilitiesPreparePayload{Step: "chunk", Offset: 1, Data: []byte("a")})) + case "oversized-chunk": + _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.CapabilitiesPreparePayload{Step: "chunk", Data: body})) + default: + if err := r.Handle(t.Context(), capabilityEnvelope(t, id, proto.CapabilitiesPreparePayload{Step: "chunk", Data: body[:proto.CapabilitiesChunkBytes]})); err != nil { + t.Fatal(err) + } + if got := capabilitiesReceipt(t, sender, id, "received"); got.Offset != proto.CapabilitiesChunkBytes { + t.Fatal(got) + } + if mode == "digest" { + _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.CapabilitiesPreparePayload{Step: "chunk", Offset: proto.CapabilitiesChunkBytes, Data: body[proto.CapabilitiesChunkBytes:]})) + capabilitiesReceipt(t, sender, id, "received") + } + _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.CapabilitiesPreparePayload{Step: "commit"})) + } + capabilitiesReceipt(t, sender, id, "rejected") + r.mu.Lock() + defer r.mu.Unlock() + if owner.apply || owner.data != nil || r.capabilitiesPrepare != nil { + t.Fatal("invalid body retained or admitted a mutation") + } + }) + } +} + +func stringsOfZeroDigest() string { return hex.EncodeToString(make([]byte, sha256.Size)) } + +func TestCapabilitiesBeginRequiresExactBindingAndBounds(t *testing.T) { + r, sender, environment, session := capabilitiesTestRouter(t) + defer shutdownCapabilitiesRouter(t, r) + for _, mode := range []string{"environment", "session", "size", "nil-binding"} { + request := capabilityBegin(environment, session, []byte("abc")) + switch mode { + case "environment": + request.EnvironmentID = uuid.NewString() + case "session": + request.SessionID = uuid.NewString() + case "size": + request.SizeBytes = proto.CapabilitiesMaxBytes + 1 + case "nil-binding": + r.localWorkspace = nil + } + id := uuid.NewString() + if err := r.Handle(t.Context(), capabilityEnvelope(t, id, request)); err != nil { + t.Fatal(err) + } + capabilitiesReceipt(t, sender, id, "rejected") + if r.capabilitiesPrepare != nil { + t.Fatal("invalid scope allocated a transfer") + } + } +} + +func TestCapabilitiesPreparationExcludesOwnedResources(t *testing.T) { + for _, mode := range []string{"write", "export", "read", "run", "idle", "executor", "preparation"} { + t.Run(mode, func(t *testing.T) { + r, sender, environment, session := capabilitiesTestRouter(t) + switch mode { + case "write": + r.workspaceWrite = &workspaceUpload{} + case "export": + r.workspaceExport = &workspaceExport{} + case "read": + r.workspaceReads = map[string]struct{}{"read": {}} + case "run": + r.sessions["run"] = &sessionState{} + case "idle": + r.idle["state"] = map[*sessionState]struct{}{} + case "executor": + r.executors[session] = &executorState{} + case "preparation": + r.preparations["p"] = &preparationState{owns: true} + } + id := uuid.NewString() + if err := r.Handle(t.Context(), capabilityEnvelope(t, id, capabilityBegin(environment, session, []byte("abc")))); err != nil { + t.Fatal(err) + } + if got := capabilitiesReceipt(t, sender, id, "rejected"); got.ErrorCode != "resource_unavailable" { + t.Fatal(got) + } + if r.capabilitiesPrepare != nil { + t.Fatal("busy Runtime admitted capability preparation") + } + r.workspaceWrite = nil + r.workspaceExport = nil + r.workspaceReads = nil + clear(r.sessions) + clear(r.idle) + clear(r.executors) + clear(r.preparations) + shutdownCapabilitiesRouter(t, r) + }) + } +} + +func TestCapabilitiesUploadBlocksWorkspaceWriteAndSuspension(t *testing.T) { + r, sender, environment, session := capabilitiesTestRouter(t) + id := uuid.NewString() + if err := r.Handle(t.Context(), capabilityEnvelope(t, id, capabilityBegin(environment, session, []byte("abc")))); err != nil { + t.Fatal(err) + } + capabilitiesReceipt(t, sender, id, "ready") + if err := r.Quiesce(t.Context(), proto.EnvironmentSuspendPayload{EnvironmentID: environment, SuspendID: uuid.NewString()}); !errors.Is(err, ErrRouterBusy) { + t.Fatal(err) + } + digest := sha256.Sum256([]byte("abc")) + write, err := proto.NewEnvelope(proto.TypeWorkspaceWrite, uuid.NewString(), proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: environment, SessionID: session, Path: "proof", SizeBytes: 3, SHA256: hex.EncodeToString(digest[:])}) + if err != nil { + t.Fatal(err) + } + if err := r.Handle(t.Context(), write); err != nil { + t.Fatal(err) + } + select { + case env := <-sender.frames: + var result proto.WorkspaceWriteResultPayload + if env.Type != proto.TypeWorkspaceWriteResult || env.DecodePayload(&result) != nil || result.Outcome != "rejected" || result.ErrorCode != "resource_unavailable" { + t.Fatal("workspace mutation bypassed capability transfer") + } + case <-time.After(time.Second): + t.Fatal("missing write rejection") + } + r.mu.Lock() + owner := r.capabilitiesPrepare + r.mu.Unlock() + shutdownCapabilitiesRouter(t, r) + if owner.data != nil || r.capabilitiesPrepare != nil { + t.Fatal("disconnect retained uncommitted body") + } +} + +func TestCapabilitiesCancellationKeepsOwnershipUntilApplyStops(t *testing.T) { + r, sender, environment, session := capabilitiesTestRouter(t) + ctx, cancel := context.WithCancel(context.Background()) + id := uuid.NewString() + request := proto.CapabilitiesPreparePayload{Step: "begin", Action: "finalize", EnvironmentID: environment, SessionID: session, Sources: &agentcapabilities.Input{}} + owner := &capabilitiesUpload{envelope: capabilityEnvelope(t, id, request), request: request, ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} + close(owner.ready) + r.capabilitiesPrepare = owner + r.shutdownWG.Add(1) + started, interrupted, release := make(chan struct{}), make(chan struct{}), make(chan struct{}) + retained := filepath.Join(t.TempDir(), "installed.json") + go r.runCapabilitiesUpload(ctx, owner, func(ctx context.Context, got proto.CapabilitiesPreparePayload, data []byte) error { + if got.Action != "finalize" || len(data) != 0 { + return agentcapabilities.ErrInvalid + } + close(started) + <-ctx.Done() + close(interrupted) + <-release + return os.WriteFile(retained, []byte("retained"), 0400) + }) + <-started + wait, stop := context.WithTimeout(context.Background(), 20*time.Millisecond) + err := r.Shutdown(wait) + stop() + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("shutdown discarded running mutation: %v", err) + } + <-interrupted + r.mu.Lock() + owned := r.capabilitiesPrepare == owner + r.mu.Unlock() + if !owned { + t.Fatal("cancel released unsettled capability ownership") + } + close(release) + shutdownCapabilitiesRouter(t, r) + capabilitiesReceipt(t, sender, id, "completed") + if _, err := os.Stat(retained); err != nil { + t.Fatal("shutdown deleted installation result") + } + if r.capabilitiesPrepare != nil { + t.Fatal("confirmed completion retained capacity") + } +} + +func TestCapabilitiesResultCategoriesAndUnknownOwnership(t *testing.T) { + for _, tc := range []struct { + err error + outcome, code string + }{ + {nil, "completed", ""}, + {agentcapabilities.ErrInvalid, "failed", "capabilities_failed"}, + {context.DeadlineExceeded, "unknown", "capabilities_unconfirmed"}, + {errors.Join(agentcapabilities.ErrInvalid, context.Canceled), "unknown", "capabilities_unconfirmed"}, + {errors.New("private native diagnostic"), "unknown", "capabilities_unconfirmed"}, + } { + got := capabilitiesApplyResult(tc.err, 3) + if got.Outcome != tc.outcome || got.ErrorCode != tc.code || !proto.ValidCapabilitiesResult(got, "completed", 0, 3) { + t.Fatalf("unsafe result: %+v", got) + } + } + r, sender, environment, session := capabilitiesTestRouter(t) + ctx, cancel := context.WithCancel(context.Background()) + id := uuid.NewString() + request := capabilityBegin(environment, session, []byte("abc")) + owner := &capabilitiesUpload{envelope: capabilityEnvelope(t, id, request), request: request, data: []byte("abc"), ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} + close(owner.ready) + r.capabilitiesPrepare = owner + r.shutdownWG.Add(1) + go r.runCapabilitiesUpload(ctx, owner, func(context.Context, proto.CapabilitiesPreparePayload, []byte) error { return context.DeadlineExceeded }) + capabilitiesReceipt(t, sender, id, "unknown") + r.mu.Lock() + owned := r.capabilitiesPrepare == owner && owner.uncertain && owner.data == nil + r.mu.Unlock() + if !owned { + t.Fatal("unknown mutation released its ownership") + } + wait, stop := context.WithTimeout(context.Background(), time.Second) + defer stop() + if err := r.Shutdown(wait); err == nil { + t.Fatal("shutdown claimed uncertain mutation settled") + } +} diff --git a/apps/parsar-daemon/internal/dispatch/executor.go b/apps/parsar-daemon/internal/dispatch/executor.go index 22fa3d708..7eec038e8 100644 --- a/apps/parsar-daemon/internal/dispatch/executor.go +++ b/apps/parsar-daemon/internal/dispatch/executor.go @@ -73,7 +73,7 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, r.mu.Unlock() return ErrRouterClosed } - if r.workspaceWrite != nil || r.workspaceExport != nil { + if r.workspaceWrite != nil || r.workspaceExport != nil || r.capabilitiesPrepare != nil { r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") } @@ -177,7 +177,10 @@ func (r *Router) prepareExecutor(p *preparationState, req proto.PromptRequestPay var native agent.Executor var err error if owner.ctx.Err() == nil { - native, err = factory(owner.ctx, req) + req, err = r.localWorkspace.Prepare(owner.ctx, req) + if err == nil && owner.ctx.Err() == nil { + native, err = factory(owner.ctx, req) + } } else { err = owner.ctx.Err() } diff --git a/apps/parsar-daemon/internal/dispatch/functions_native_test.go b/apps/parsar-daemon/internal/dispatch/functions_native_test.go index b47d0aec9..9502539ac 100644 --- a/apps/parsar-daemon/internal/dispatch/functions_native_test.go +++ b/apps/parsar-daemon/internal/dispatch/functions_native_test.go @@ -40,6 +40,9 @@ func TestNativeFunctionBridge(t *testing.T) { if err != nil { t.Fatal(err) } + if err := os.Chmod(home, 0o700); err != nil { + t.Fatal(err) + } t.Setenv("OAC_RUNTIME_HOME", home) var count atomic.Int32 model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/apps/parsar-daemon/internal/dispatch/preparation.go b/apps/parsar-daemon/internal/dispatch/preparation.go index 26e230cab..0e0ae9e67 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation.go +++ b/apps/parsar-daemon/internal/dispatch/preparation.go @@ -78,7 +78,7 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) r.mu.Unlock() return ErrRouterClosed } - if (r.workspaceWrite != nil || r.workspaceExport != nil) && !req.WorkspaceReadOnly { + if r.capabilitiesPrepare != nil || ((r.workspaceWrite != nil || r.workspaceExport != nil) && !req.WorkspaceReadOnly) { r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") } diff --git a/apps/parsar-daemon/internal/dispatch/preparation_start.go b/apps/parsar-daemon/internal/dispatch/preparation_start.go index bd1e64894..24e5afbbd 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_start.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_start.go @@ -24,7 +24,7 @@ func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) err r.mu.Unlock() return ErrRouterClosed } - if r.workspaceWrite != nil || r.workspaceExport != nil { + if r.workspaceWrite != nil || r.workspaceExport != nil || r.capabilitiesPrepare != nil { r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") } diff --git a/apps/parsar-daemon/internal/dispatch/preparation_test.go b/apps/parsar-daemon/internal/dispatch/preparation_test.go index 16134d129..2304ca1a2 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_test.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_test.go @@ -13,6 +13,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) @@ -77,16 +78,22 @@ func preparationWorkspace(t *testing.T) *localworkspace.Binding { if err := os.WriteFile(helper, []byte("#!/bin/sh\nprintf '%s' '{\"version\":1,\"directory\":{\"entries\":[{\"name\":\"file\",\"kind\":\"file\",\"size_bytes\":3}],\"truncated\":true}}'\n"), 0o700); err != nil { t.Fatal(err) } + runtimeHome := t.TempDir() + if err := os.Chmod(runtimeHome, 0o700); err != nil { + t.Fatal(err) + } for name, value := range map[string]string{ - "OAC_RUNTIME_ENVIRONMENT_ID": preparationEnvironmentID, - "OAC_RUNTIME_SESSION_ID": preparationSessionID, - "OAC_RUNTIME_WORKSPACE": t.TempDir(), - "OAC_RUNTIME_DIRECTORY_HELPER": helper, - "OAC_RUNTIME_NETWORK_ACCESS": "enabled", - "OAC_RUNTIME_ALLOWED_DOMAINS": "", - "OAC_RUNTIME_WRITE_HELPER": "", - "OAC_RUNTIME_EXPORT_HELPER": "", - "OAC_RUNTIME_STAGING": "", + "OAC_RUNTIME_ENVIRONMENT_ID": preparationEnvironmentID, + "OAC_RUNTIME_SESSION_ID": preparationSessionID, + "OAC_RUNTIME_WORKSPACE": t.TempDir(), + "OAC_RUNTIME_CAPABILITY_DIRECTORY": t.TempDir(), + "OAC_RUNTIME_HOME": runtimeHome, + "OAC_RUNTIME_DIRECTORY_HELPER": helper, + "OAC_RUNTIME_NETWORK_ACCESS": "enabled", + "OAC_RUNTIME_ALLOWED_DOMAINS": "", + "OAC_RUNTIME_WRITE_HELPER": "", + "OAC_RUNTIME_EXPORT_HELPER": "", + "OAC_RUNTIME_STAGING": "", } { t.Setenv(name, value) } @@ -112,7 +119,7 @@ func localPreparationHarness(t *testing.T) *harness { } func preparationRequest() proto.ExecutionPreparePayload { - return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "agents-api-" + preparationSessionID, StrictResume: true, ReleaseOnCompletion: true, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled"}}} + return proto.ExecutionPreparePayload{SessionID: preparationSessionID, Configuration: proto.PromptRequestPayload{AgentKind: "prepared", AgentStateKey: "agents-api-" + preparationSessionID, StrictResume: true, ReleaseOnCompletion: true, LocalEnvironment: &proto.LocalEnvironment{ID: preparationEnvironmentID, NetworkAccess: "enabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}}} } func preparationRouter(t *testing.T, sender dispatch.Sender, timeout time.Duration, factory agent.PreparationFactory) *dispatch.Router { diff --git a/apps/parsar-daemon/internal/dispatch/prompt.go b/apps/parsar-daemon/internal/dispatch/prompt.go index f92d972ca..76dd98bbb 100644 --- a/apps/parsar-daemon/internal/dispatch/prompt.go +++ b/apps/parsar-daemon/internal/dispatch/prompt.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" ) @@ -74,7 +75,7 @@ func (r *Router) handlePromptRequest(callerCtx context.Context, env proto.Envelo r.log.ErrorContext(callerCtx, "handlePromptRequest: router closed", "run_id", runID) return ErrRouterClosed } - if r.workspaceWrite != nil || r.workspaceExport != nil { + if r.capabilitiesPrepare != nil || r.workspaceWrite != nil || r.workspaceExport != nil { r.mu.Unlock() err := errors.New("local workspace has an unsettled write") r.emitTerminalError(callerCtx, runID, err.Error()) @@ -111,7 +112,11 @@ func (r *Router) handlePromptRequest(callerCtx context.Context, env proto.Envelo r.mu.Unlock() r.log.InfoContext(callerCtx, "handlePromptRequest: calling factory", "run_id", runID) - sess, err := factory(sessionCtx, req, out) + req, err = r.localWorkspace.Prepare(sessionCtx, req) + var sess agent.Session + if err == nil { + sess, err = factory(sessionCtx, req, out) + } if err != nil { r.log.ErrorContext(callerCtx, "handlePromptRequest: factory call failed", "run_id", runID, "agent_kind", req.AgentKind, "err", err) // Roll back the registration, cancel ctx, surface error+done diff --git a/apps/parsar-daemon/internal/dispatch/router.go b/apps/parsar-daemon/internal/dispatch/router.go index 8bcf14c12..7c6744d1c 100644 --- a/apps/parsar-daemon/internal/dispatch/router.go +++ b/apps/parsar-daemon/internal/dispatch/router.go @@ -51,6 +51,7 @@ type Router struct { preparations map[string]*preparationState preparationRequests map[string]*preparationState preparationTimeout time.Duration + capabilitiesPrepare *capabilitiesUpload workspaceWrite *workspaceUpload workspaceExport *workspaceExport workspaceReads map[string]struct{} @@ -167,6 +168,8 @@ func (r *Router) Handle(ctx context.Context, env proto.Envelope) error { ctx = adoptEnvelopeTrace(ctx, env) switch env.Type { + case proto.TypeCapabilitiesPrepare: + return r.handleCapabilitiesPrepare(ctx, env) case proto.TypeWorkspaceExport: return r.handleWorkspaceExport(ctx, env) case proto.TypeWorkspaceWrite: diff --git a/apps/parsar-daemon/internal/dispatch/shutdown.go b/apps/parsar-daemon/internal/dispatch/shutdown.go index f5ea7693c..43aa0a684 100644 --- a/apps/parsar-daemon/internal/dispatch/shutdown.go +++ b/apps/parsar-daemon/internal/dispatch/shutdown.go @@ -43,6 +43,9 @@ func (r *Router) Shutdown(ctx context.Context) error { } if first { r.closed = true + if r.capabilitiesPrepare != nil { + r.capabilitiesPrepare.cancel() + } for _, states := range r.idle { for state := range states { state.retain = false @@ -100,6 +103,9 @@ func (r *Router) runShutdownAttempt(attempt *shutdownAttempt, victims []sessionC r.shutdownWG.Wait() r.mu.Lock() + if r.capabilitiesPrepare != nil && r.capabilitiesPrepare.uncertain { + attempt.err = errors.Join(attempt.err, errors.New("dispatch: capability preparation remains uncertain")) + } if r.workspaceWrite != nil && r.workspaceWrite.uncertain { attempt.err = errors.Join(attempt.err, errors.New("dispatch: local workspace write remains uncertain")) } @@ -144,6 +150,9 @@ func (r *Router) handleDeviceShutdown(ctx context.Context, env proto.Envelope) e r.mu.Unlock() return ErrRouterClosed } + if r.capabilitiesPrepare != nil { + r.capabilitiesPrepare.cancel() + } victims := make([]sessionCancellation, 0, len(r.sessions)) for _, state := range r.sessions { state.retain = false diff --git a/apps/parsar-daemon/internal/dispatch/suspend.go b/apps/parsar-daemon/internal/dispatch/suspend.go index 6058530f0..a39db52f0 100644 --- a/apps/parsar-daemon/internal/dispatch/suspend.go +++ b/apps/parsar-daemon/internal/dispatch/suspend.go @@ -31,7 +31,7 @@ func (r *Router) Quiesce(ctx context.Context, request proto.EnvironmentSuspendPa r.mu.Unlock() return ErrRouterQuiesced } - if len(r.sessions) != 0 || len(r.workspaceReads) != 0 || r.workspaceWrite != nil || r.workspaceExport != nil || len(r.permIndex) != 0 || len(r.askIndex) != 0 { + if r.capabilitiesPrepare != nil || len(r.sessions) != 0 || len(r.workspaceReads) != 0 || r.workspaceWrite != nil || r.workspaceExport != nil || len(r.permIndex) != 0 || len(r.askIndex) != 0 { r.mu.Unlock() return ErrRouterBusy } diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write.go b/apps/parsar-daemon/internal/dispatch/workspace_write.go index adac13c47..7f916d8cf 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_write.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_write.go @@ -46,7 +46,7 @@ func (r *Router) handleWorkspaceWrite(ctx context.Context, env proto.Envelope) e return ErrRouterClosed } if request.Step == "begin" { - if r.workspaceExport != nil { + if r.workspaceExport != nil || r.capabilitiesPrepare != nil { r.mu.Unlock() return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("resource_unavailable")) } diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/parsar-daemon/internal/localworkspace/binding.go index 825661c78..c533216d4 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding.go +++ b/apps/parsar-daemon/internal/localworkspace/binding.go @@ -5,6 +5,10 @@ import ( "os" "path/filepath" "strings" + "sync" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" @@ -21,6 +25,8 @@ type Binding struct { helper string exportHelper string writer *fileWriter + capabilityMu sync.Mutex + capabilityRoot string } func New(environment, session, workspace, helper string) (*Binding, error) { @@ -43,7 +49,30 @@ func New(environment, session, workspace, helper string) (*Binding, error) { if err != nil || !program.Mode().IsRegular() || program.Mode().Perm()&0111 == 0 || strings.HasPrefix(helper, workspace+string(filepath.Separator)) { return nil, errors.New("local workspace helper must be executable outside the workspace") } - return &Binding{environment: environment, stateKey: "agents-api-" + session, workspace: workspace, helper: helper}, nil + return &Binding{environment: environment, stateKey: "agents-api-" + session, workspace: workspace, helper: helper, capabilityRoot: CapabilityDirectory}, nil +} + +// NewWithCapabilityDirectory freezes an operator-owned installation layout. +// Core and capability transfer messages cannot choose this destination. +func NewWithCapabilityDirectory(environment, session, workspace, helper, directory string) (*Binding, error) { + b, err := New(environment, session, workspace, helper) + if err != nil { + return nil, err + } + if agentcapabilities.ValidateLocalDirectories([]string{directory}) != nil || directory == "/" || containsPath(workspace, directory) || containsPath(directory, workspace) || containsPath(PackageDirectory, directory) || containsPath(directory, PackageDirectory) { + return nil, agentcapabilities.ErrInvalid + } + private, err := paths.Root() + if err != nil || !filepath.IsAbs(private) || containsPath(private, directory) || containsPath(directory, private) { + return nil, agentcapabilities.ErrInvalid + } + for _, name := range []string{workspace, private, directory} { + if !canonicalExistingParent(name) { + return nil, agentcapabilities.ErrInvalid + } + } + b.capabilityRoot = directory + return b, nil } func Load() (*Binding, error) { @@ -55,10 +84,14 @@ func Load() (*Binding, error) { network := policy.Access writeHelper, staging := os.Getenv("OAC_RUNTIME_WRITE_HELPER"), os.Getenv("OAC_RUNTIME_STAGING") exportHelper := os.Getenv("OAC_RUNTIME_EXPORT_HELPER") - if strings.Join(values, "") == "" && writeHelper == "" && staging == "" && network == "" && exportHelper == "" { + capabilityDirectory := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") + if strings.Join(values, "") == "" && writeHelper == "" && staging == "" && network == "" && exportHelper == "" && capabilityDirectory == "" { return nil, nil } - b, err := New(values[0], values[1], values[2], values[3]) + if capabilityDirectory == "" { + capabilityDirectory = CapabilityDirectory + } + b, err := NewWithCapabilityDirectory(values[0], values[1], values[2], values[3], capabilityDirectory) if err != nil { return nil, err } @@ -100,39 +133,43 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa } } if !r.WorkspaceReadOnly { - if r.LocalEnvironment.SystemPackages && !r.LocalEnvironment.ToolEnvironment { - return r, errors.New("system packages require initialized tool configuration") + local := *r.LocalEnvironment + if local.WorkspaceDirectory != "/workspace" && local.WorkspaceDirectory != b.workspace { + return r, errors.New("request does not match the local workspace selection") } - if r.LocalEnvironment.ToolEnvironment { - if err := VerifyToolEnvironment(r.LocalEnvironment.SystemPackages); err != nil { - return r, err - } + if local.CapabilitySources == nil || agentcapabilities.ValidateInput(*local.CapabilitySources) != nil { + return r, agentcapabilities.ErrInvalid } - local := *r.LocalEnvironment - local.Skills = nil - local.MCP = nil - if local.Capabilities { - manifest, err := LoadCapabilities() - if err != nil { - return r, err - } - local.Skills = manifest.Skills - if len(manifest.MCP) != 0 { - if b.NetworkPolicy().Access != "enabled" { - return r, errors.New("environment MCP requires qualified enabled-network execution") - } - values, err := ReadToolEnvironment() - if err != nil { - return r, err - } - local.MCP, err = resolveEnvironmentMCP(manifest.MCP, values) - if err != nil { - return r, err - } - } + sources := *local.CapabilitySources + present := len(sources.Skills)+len(sources.Plugins)+len(sources.Directories) > 0 + if present != local.Capabilities || local.SystemPackages && !local.ToolEnvironment { + return r, agentcapabilities.ErrInvalid } + local.Skills, local.MCP, local.CapabilityRoot = nil, nil, "" r.LocalEnvironment = &local r.WorkDir = b.workspace } return r, nil } + +func (b *Binding) Matches(environment, session string) bool { + return b != nil && b.environment == environment && b.stateKey == "agents-api-"+session +} + +// Inspect existing ancestors before creating an operator-owned path. This prevents +// MkdirAll from creating directories through an alias before its final validation. +func canonicalExistingParent(name string) bool { + for { + if _, err := os.Lstat(name); err == nil { + actual, err := filepath.EvalSymlinks(name) + return err == nil && actual == name + } else if !errors.Is(err, os.ErrNotExist) { + return false + } + parent := filepath.Dir(name) + if parent == name { + return false + } + name = parent + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/binding_test.go b/apps/parsar-daemon/internal/localworkspace/binding_test.go index 8a68555f9..4e0da0b2d 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding_test.go +++ b/apps/parsar-daemon/internal/localworkspace/binding_test.go @@ -8,12 +8,18 @@ import ( "testing" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/google/uuid" ) func testBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { t.Helper() + private := t.TempDir() + if err := os.Chmod(private, 0700); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_HOME", private) root := t.TempDir() helper := filepath.Join(t.TempDir(), "helper") if err := os.WriteFile(helper, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { @@ -25,7 +31,8 @@ func testBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { t.Fatal(err) } b.networkAccess = "disabled" - return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, NetworkAccess: "disabled"}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true} + b.capabilityRoot = t.TempDir() + return b, proto.PromptRequestPayload{LocalEnvironment: &proto.LocalEnvironment{ID: environment, NetworkAccess: "disabled", WorkspaceDirectory: "/workspace", CapabilitySources: &agentcapabilities.Input{}}, AgentStateKey: "agents-api-" + session, StrictResume: true, ReleaseOnCompletion: true} } func TestBindingRejectsScopeAndPathOverrides(t *testing.T) { @@ -115,3 +122,24 @@ func TestBindingAllowsRetainedExecutor(t *testing.T) { t.Fatal(err) } } + +func TestCapabilityLayoutExcludesWritableAndPrivateRoots(t *testing.T) { + b, _ := testBinding(t) + private := os.Getenv("OAC_RUNTIME_HOME") + for _, directory := range []string{b.workspace, filepath.Join(b.workspace, "caps"), private, filepath.Join(private, "caps"), PackageDirectory, filepath.Join(PackageDirectory, "caps"), "/"} { + if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.helper, directory); err == nil { + t.Fatal("unsafe installation layout accepted") + } + } + root := t.TempDir() + alias := filepath.Join(root, "alias") + if err := os.Symlink(t.TempDir(), alias); err != nil { + t.Fatal(err) + } + if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.helper, filepath.Join(alias, "new")); err == nil { + t.Fatal("installation parent alias accepted") + } + if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.helper, t.TempDir()); err != nil { + t.Fatal(err) + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/capabilities.go b/apps/parsar-daemon/internal/localworkspace/capabilities.go new file mode 100644 index 000000000..07ec639b3 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/capabilities.go @@ -0,0 +1,210 @@ +package localworkspace + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Prepare runs under the admitted executor's lifetime, before native startup. +// Reconnection validates installed contents without reopening mutable sources. +func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (proto.PromptRequestPayload, error) { + if b == nil && r.LocalEnvironment == nil || r.WorkspaceReadOnly { + return r, nil + } + if b == nil || r.LocalEnvironment == nil || r.LocalEnvironment.CapabilitySources == nil || + r.LocalEnvironment.ID != b.environment || r.AgentStateKey != b.stateKey || r.WorkDir != b.workspace { + return r, agentcapabilities.ErrInvalid + } + b.capabilityMu.Lock() + defer b.capabilityMu.Unlock() + marker, err := b.openSnapshotMarker() + if err != nil { + return r, err + } + defer marker.close() + root, unlock, err := b.openCapabilities(ctx, marker.completed) + if err != nil { + return r, err + } + defer unlock() + input := *r.LocalEnvironment.CapabilitySources + identity := b.capabilityIdentity() + manifest, err := b.loadCapabilitySnapshot(root, input, identity, marker.completed) + if err != nil || marker.complete() != nil { + return r, agentcapabilities.ErrInvalid + } + if err = ctx.Err(); err != nil { + return r, err + } + local := *r.LocalEnvironment + local.Skills, local.MCP, local.CapabilityRoot = manifest.Skills, nil, b.capabilityRoot + for i := range local.Skills { + local.Skills[i].InstallationRoot = b.capabilityRoot + } + if local.ToolEnvironment { + if err = VerifyToolEnvironment(local.SystemPackages); err != nil { + return r, err + } + } + if len(manifest.MCP) != 0 { + if b.NetworkPolicy().Access != "enabled" { + return r, agentcapabilities.ErrInvalid + } + values, readErr := ReadOptionalToolEnvironment() + if readErr != nil { + return r, readErr + } + local.MCP, err = resolveEnvironmentMCP(manifest.MCP, values) + for i := range local.MCP { + local.MCP[i].InstallationRoot = b.capabilityRoot + local.MCP[i].WorkspaceRoot = b.workspace + } + if err != nil { + return r, err + } + } + r.LocalEnvironment = &local + return r, nil +} + +// ApplyCapabilities settles every filesystem operation before returning. A +// cancelled caller never leaves an unowned installation goroutine behind. +func (b *Binding) ApplyCapabilities(ctx context.Context, input proto.CapabilitiesPreparePayload, data []byte) error { + if b == nil || !b.Matches(input.EnvironmentID, input.SessionID) || !proto.ValidCapabilitiesPrepareRequest(input) || input.Step != "begin" { + return agentcapabilities.ErrInvalid + } + b.capabilityMu.Lock() + defer b.capabilityMu.Unlock() + marker, err := b.openSnapshotMarker() + if err != nil { + return err + } + defer marker.close() + if marker.completed { + return agentcapabilities.ErrInvalid + } + root, unlock, err := b.openCapabilities(ctx, false) + if err != nil { + return err + } + defer unlock() + switch input.Action { + case "skill": + err = agentcapabilities.InstallSkill(root, data, *input.Skill) + case "plugin": + err = agentcapabilities.InstallPlugin(root, input.Slot, data, *input.Plugin) + case "finalize": + _, err = b.loadCapabilitySnapshot(root, *input.Sources, b.capabilityIdentity(), false) + if err == nil { + err = marker.complete() + } + default: + err = agentcapabilities.ErrInvalid + } + if err != nil { + return agentcapabilities.ErrInvalid + } + return ctx.Err() +} + +func (b *Binding) loadCapabilitySnapshot(root *os.Root, input agentcapabilities.Input, identity agentcapabilities.Identity, completed bool) (agentcapabilities.Manifest, error) { + if _, err := root.Lstat(agentcapabilities.ManifestName); errors.Is(err, os.ErrNotExist) { + if completed || agentcapabilities.Finalize(root, input, identity, b.resolveCapabilityDirectory) != nil { + return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid + } + } else if err != nil { + return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid + } + manifest, err := agentcapabilities.Load(root) + if err != nil || agentcapabilities.ValidateSelection(manifest, input, identity) != nil { + return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid + } + return manifest, nil +} + +func (b *Binding) capabilityIdentity() agentcapabilities.Identity { + return agentcapabilities.Identity{EnvironmentID: b.environment, SessionID: strings.TrimPrefix(b.stateKey, "agents-api-")} +} + +// The current Linux Runtime layout is shared by user-owned and managed hosts. +// Deployment paths never come from a capability transport request. +func (b *Binding) openCapabilities(ctx context.Context, completed bool) (*os.Root, func(), error) { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + if b.capabilityRoot == "" || !filepath.IsAbs(b.capabilityRoot) || !canonicalExistingParent(b.capabilityRoot) { + return nil, nil, agentcapabilities.ErrInvalid + } + if !completed { + if err := os.MkdirAll(b.capabilityRoot, 0700); err != nil { + return nil, nil, agentcapabilities.ErrInvalid + } + } + actual, err := filepath.EvalSymlinks(b.capabilityRoot) + if err != nil || actual != b.capabilityRoot { + return nil, nil, agentcapabilities.ErrInvalid + } + root, err := os.OpenRoot(b.capabilityRoot) + if err != nil { + return nil, nil, agentcapabilities.ErrInvalid + } + dir, err := root.Open(".") + if err != nil { + root.Close() + return nil, nil, agentcapabilities.ErrInvalid + } + if err = syscall.Flock(int(dir.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + dir.Close() + root.Close() + return nil, nil, agentcapabilities.ErrInvalid + } + return root, func() { _ = syscall.Flock(int(dir.Fd()), syscall.LOCK_UN); dir.Close(); root.Close() }, nil +} + +func containsPath(parent, child string) bool { + relative, err := filepath.Rel(parent, child) + return err == nil && (relative == "." || filepath.IsLocal(relative)) +} + +func (b *Binding) resolveCapabilityDirectory(source string) (*os.Root, error) { + if agentcapabilities.ValidateLocalDirectories([]string{source}) != nil { + return nil, agentcapabilities.ErrInvalid + } + if source == "/workspace" || strings.HasPrefix(source, "/workspace/") { + source = filepath.Join(b.workspace, strings.TrimPrefix(source, "/workspace")) + } + actual, err := filepath.EvalSymlinks(source) + if err != nil || actual != source { + return nil, agentcapabilities.ErrInvalid + } + private, err := paths.Root() + if err != nil || !filepath.IsAbs(private) { + return nil, agentcapabilities.ErrInvalid + } + // Protect both descendants and ancestors: selecting a parent must not copy + // credentials, native history, installed capabilities or initialization env. + for _, protected := range []string{private, b.capabilityRoot, InitializationDirectory} { + canonical, e := filepath.EvalSymlinks(protected) + if e == nil { + protected = canonical + } else if !errors.Is(e, os.ErrNotExist) { + return nil, agentcapabilities.ErrInvalid + } + if containsPath(source, protected) || containsPath(protected, source) { + return nil, agentcapabilities.ErrInvalid + } + } + root, err := os.OpenRoot(source) + if err != nil { + return nil, agentcapabilities.ErrInvalid + } + return root, nil +} diff --git a/apps/parsar-daemon/internal/localworkspace/capabilities_test.go b/apps/parsar-daemon/internal/localworkspace/capabilities_test.go index be69fcb28..2713ae93e 100644 --- a/apps/parsar-daemon/internal/localworkspace/capabilities_test.go +++ b/apps/parsar-daemon/internal/localworkspace/capabilities_test.go @@ -11,7 +11,8 @@ import ( func TestCapabilityPathsStayRuntimeOwnedAndDoNotGateReads(t *testing.T) { binding, request := testBinding(t) - request.LocalEnvironment.Skills = []agentcapabilities.InstalledSkill{{RelativeRoot: "caller/private", PackageRoot: "caller"}} + request.LocalEnvironment.CapabilityRoot = "/caller/installation" + request.LocalEnvironment.Skills = []agentcapabilities.InstalledSkill{{RelativeRoot: "caller/private", PackageRoot: "caller", InstallationRoot: "/caller/installation"}} secret := "private-mcp-marker" request.LocalEnvironment.MCP = []proto.EnvironmentMCP{{PackageRoot: "caller/private", BearerToken: &secret}} raw, err := json.Marshal(request.LocalEnvironment) @@ -19,7 +20,7 @@ func TestCapabilityPathsStayRuntimeOwnedAndDoNotGateReads(t *testing.T) { t.Fatal("Runtime paths crossed the public daemon descriptor", err) } configured, err := binding.Configure(request) - if err != nil || len(configured.LocalEnvironment.Skills) != 0 || len(configured.LocalEnvironment.MCP) != 0 { + if err != nil || len(configured.LocalEnvironment.Skills) != 0 || len(configured.LocalEnvironment.MCP) != 0 || configured.LocalEnvironment.CapabilityRoot != "" { t.Fatal("execution accepted caller-supplied Skill paths", err) } request.LocalEnvironment.Capabilities = true diff --git a/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go b/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go new file mode 100644 index 000000000..656d5ddd5 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go @@ -0,0 +1,147 @@ +package localworkspace + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +func writeSourceSkill(t *testing.T, directory, text string) { + t.Helper() + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "SKILL.md"), []byte("---\nname: local-proof\ndescription: Local preparation proof\n---\n"+text), 0600); err != nil { + t.Fatal(err) + } +} + +func TestPreparationFreezesLocalContentsAcrossReconnect(t *testing.T) { + b, req := testBinding(t) + source := t.TempDir() + writeSourceSkill(t, source, "first") + req.LocalEnvironment.Capabilities = true + req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source}} + configured, err := b.Configure(req) + if err != nil { + t.Fatal(err) + } + if _, err = os.Stat(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); !os.IsNotExist(err) { + t.Fatal("binding installed before asynchronous admission") + } + first, err := b.Prepare(t.Context(), configured) + if err != nil || len(first.LocalEnvironment.Skills) != 1 { + t.Fatalf("first preparation: %v", err) + } + writeSourceSkill(t, source, "second") + reconnect, err := New(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.helper) + if err != nil { + t.Fatal(err) + } + reconnect.networkAccess = b.networkAccess + reconnect.capabilityRoot = b.capabilityRoot + again, err := reconnect.Prepare(t.Context(), configured) + if err != nil || len(again.LocalEnvironment.Skills) != 1 { + t.Fatalf("reconnection: %v", err) + } + frozen, err := os.ReadFile(filepath.Join(b.capabilityRoot, again.LocalEnvironment.Skills[0].RelativeRoot, "SKILL.md")) + if err != nil || string(frozen[len(frozen)-5:]) != "first" { + t.Fatal("reconnection recaptured source", err) + } + next, nextReq := testBinding(t) + nextReq.LocalEnvironment.Capabilities = true + nextReq.LocalEnvironment.CapabilitySources = req.LocalEnvironment.CapabilitySources + nextConfigured, err := next.Configure(nextReq) + if err != nil { + t.Fatal(err) + } + if _, err = next.Prepare(t.Context(), nextConfigured); err != nil { + t.Fatal(err) + } + fresh, err := os.ReadFile(filepath.Join(next.capabilityRoot, "directories/0/SKILL.md")) + if err != nil || string(fresh[len(fresh)-6:]) != "second" { + t.Fatal("new Session did not capture new source", err) + } + // Reusing a snapshot under another identity or selection cannot start native work. + reconnect.stateKey = "agents-api-" + uuid.NewString() + changed := configured + changed.AgentStateKey = reconnect.stateKey + if _, err = reconnect.Prepare(t.Context(), changed); err == nil { + t.Fatal("foreign snapshot accepted") + } + changed = configured + local := *configured.LocalEnvironment + local.CapabilitySources = &agentcapabilities.Input{} + changed.LocalEnvironment = &local + if _, err = b.Prepare(t.Context(), changed); err == nil { + t.Fatal("changed selection accepted") + } +} + +func TestPreparationRejectsPrivateSourcesAndLeavesFailuresInert(t *testing.T) { + b, req := testBinding(t) + private := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", private) + writeSourceSkill(t, private, "private") + for _, source := range []string{private, filepath.Dir(private), b.capabilityRoot, InitializationDirectory} { + if root, err := b.resolveCapabilityDirectory(source); err == nil { + root.Close() + t.Fatal("protected source accepted") + } + } + source := filepath.Join(b.workspace, "selected") + writeSourceSkill(t, source, "valid") + link := filepath.Join(b.workspace, "link") + if err := os.Symlink(source, link); err != nil { + t.Fatal(err) + } + if root, err := b.resolveCapabilityDirectory(link); err == nil { + root.Close() + t.Fatal("source alias accepted") + } + root, err := b.resolveCapabilityDirectory("/workspace/selected") + if err != nil { + t.Fatal("logical workspace source rejected", err) + } + root.Close() + req.LocalEnvironment.Capabilities = true + req.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source, t.TempDir()}} + configured, err := b.Configure(req) + if err != nil { + t.Fatal(err) + } + if _, err = b.Prepare(t.Context(), configured); err == nil { + t.Fatal("invalid second source accepted") + } + if _, err = os.Stat(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); !os.IsNotExist(err) { + t.Fatal("partial snapshot ready") + } + if _, err = b.Prepare(t.Context(), configured); err == nil { + t.Fatal("partial snapshot silently replayed") + } +} + +func TestPreparationEmptySelectionAndCancellation(t *testing.T) { + b, req := testBinding(t) + configured, err := b.Configure(req) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err = b.Prepare(ctx, configured); err == nil { + t.Fatal("cancelled preparation started") + } + if _, err = b.Prepare(t.Context(), configured); err != nil { + t.Fatal(err) + } + read := proto.PromptRequestPayload{WorkspaceReadOnly: true} + if _, err = b.Prepare(t.Context(), read); err != nil { + t.Fatal("Files required capability installation", err) + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/initialization.go b/apps/parsar-daemon/internal/localworkspace/initialization.go index 11c8a69c2..b1f87f0de 100644 --- a/apps/parsar-daemon/internal/localworkspace/initialization.go +++ b/apps/parsar-daemon/internal/localworkspace/initialization.go @@ -65,3 +65,12 @@ func VerifyToolEnvironment(systemPackages bool) error { } return nil } + +// ReadOptionalToolEnvironment permits a directory-only Runtime without user +// variables. Declared MCP variables still require the immutable explicit file. +func ReadOptionalToolEnvironment() (map[string]string, error) { + if _, err := os.Lstat(ToolEnvironmentJSON); errors.Is(err, os.ErrNotExist) { + return map[string]string{}, nil + } + return ReadToolEnvironment() +} diff --git a/apps/parsar-daemon/internal/localworkspace/mcp.go b/apps/parsar-daemon/internal/localworkspace/mcp.go index 312ee5e4d..b196fd857 100644 --- a/apps/parsar-daemon/internal/localworkspace/mcp.go +++ b/apps/parsar-daemon/internal/localworkspace/mcp.go @@ -13,7 +13,7 @@ const MCPInitializer = "/usr/local/bin/oac-runtime-initialize" // MCPStdioCommand contains only installed identities. The server's executable, // arguments and selected user variables are resolved after entering isolation. func MCPStdioCommand(server proto.EnvironmentMCP) (string, []string) { - return "/usr/bin/python3", []string{"-I", "-S", MCPInitializer, "stdio", server.PackageRoot, server.Server.Name} + return "/usr/bin/python3", []string{"-I", "-S", MCPInitializer, "stdio", server.InstallationRoot, server.WorkspaceRoot, server.PackageRoot, server.Server.Name} } func resolveEnvironmentMCP(installed []agentcapabilities.InstalledMCP, values map[string]string) ([]proto.EnvironmentMCP, error) { diff --git a/apps/parsar-daemon/internal/localworkspace/mcp_test.go b/apps/parsar-daemon/internal/localworkspace/mcp_test.go index a7d85cef4..36834c4de 100644 --- a/apps/parsar-daemon/internal/localworkspace/mcp_test.go +++ b/apps/parsar-daemon/internal/localworkspace/mcp_test.go @@ -28,11 +28,11 @@ func TestEnvironmentMCPCredentialsNeverFallBackToNativeEnv(t *testing.T) { } func TestMCPStdioLauncherContainsOnlyInstalledIdentity(t *testing.T) { - server := proto.EnvironmentMCP{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ + server := proto.EnvironmentMCP{InstallationRoot: "/private/runtime/capabilities", WorkspaceRoot: "/private/runtime/workspace", PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ Name: "package_tool", Type: "stdio", Command: "untrusted-command", Args: []string{"private-argument"}, }} command, args := MCPStdioCommand(server) - if command != "/usr/bin/python3" || !slices.Equal(args, []string{"-I", "-S", MCPInitializer, "stdio", "plugins/0", "package_tool"}) { + if command != "/usr/bin/python3" || !slices.Equal(args, []string{"-I", "-S", MCPInitializer, "stdio", "/private/runtime/capabilities", "/private/runtime/workspace", "plugins/0", "package_tool"}) { t.Fatal("native configuration included untrusted process configuration") } } diff --git a/apps/parsar-daemon/internal/localworkspace/skills.go b/apps/parsar-daemon/internal/localworkspace/skills.go index cefa50acd..5a9a90ca5 100644 --- a/apps/parsar-daemon/internal/localworkspace/skills.go +++ b/apps/parsar-daemon/internal/localworkspace/skills.go @@ -1,7 +1,6 @@ package localworkspace import ( - "os" "path/filepath" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" @@ -9,20 +8,6 @@ import ( const CapabilityDirectory = agentcapabilities.Directory -// LoadCapabilities consumes the packaged installation after binding authorization. -func LoadCapabilities() (agentcapabilities.Manifest, error) { - actual, err := filepath.EvalSymlinks(CapabilityDirectory) - if err != nil || actual != CapabilityDirectory { - return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid - } - root, err := os.OpenRoot(CapabilityDirectory) - if err != nil { - return agentcapabilities.Manifest{}, agentcapabilities.ErrInvalid - } - defer root.Close() - return agentcapabilities.Load(root) -} - func SkillPath(skill agentcapabilities.InstalledSkill) string { - return filepath.Join(CapabilityDirectory, skill.RelativeRoot) + return filepath.Join(skill.InstallationRoot, skill.RelativeRoot) } diff --git a/apps/parsar-daemon/internal/localworkspace/skills_test.go b/apps/parsar-daemon/internal/localworkspace/skills_test.go new file mode 100644 index 000000000..a4038ea1c --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/skills_test.go @@ -0,0 +1,15 @@ +package localworkspace + +import ( + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "path/filepath" + "testing" +) + +func TestSkillPathUsesRuntimeInstallationRoot(t *testing.T) { + root := t.TempDir() + skill := agentcapabilities.InstalledSkill{InstallationRoot: root, RelativeRoot: "plugins/0/skills/proof"} + if got := SkillPath(skill); got != filepath.Join(root, "plugins/0/skills/proof") { + t.Fatal("Runtime root lost", got) + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/snapshot_marker.go b/apps/parsar-daemon/internal/localworkspace/snapshot_marker.go new file mode 100644 index 000000000..2aaab5e8f --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/snapshot_marker.go @@ -0,0 +1,177 @@ +package localworkspace + +import ( + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "syscall" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/google/uuid" +) + +// snapshotMarker remembers completion outside the installed tree. It contains +// only the operator root, never capability configuration or a second inventory. +type snapshotMarker struct { + directory *os.Root + lock *os.File + name string + root string + completed bool +} + +func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { + identity := b.capabilityIdentity() + for _, id := range []string{identity.EnvironmentID, identity.SessionID} { + parsed, err := uuid.Parse(id) + if err != nil || parsed == uuid.Nil || parsed.String() != id { + return nil, agentcapabilities.ErrInvalid + } + } + private, err := paths.Root() + if err != nil || agentcapabilities.ValidateLocalDirectories([]string{private}) != nil || private == "/" || + !canonicalExistingParent(private) { + return nil, agentcapabilities.ErrInvalid + } + canonical, err := filepath.EvalSymlinks(private) + if err != nil || canonical != private { + return nil, agentcapabilities.ErrInvalid + } + current, err := os.OpenRoot(private) + if err != nil { + return nil, agentcapabilities.ErrInvalid + } + for _, child := range []string{"daemon", "capability-installations"} { + if !privateSnapshotDirectory(current) { + current.Close() + return nil, agentcapabilities.ErrInvalid + } + err = current.Mkdir(child, 0700) + if err != nil && !errors.Is(err, os.ErrExist) { + current.Close() + return nil, agentcapabilities.ErrInvalid + } + created := err == nil + info, err := current.Lstat(child) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + current.Close() + return nil, agentcapabilities.ErrInvalid + } + if created { + parent, openErr := current.Open(".") + if openErr != nil { + current.Close() + return nil, agentcapabilities.ErrInvalid + } + syncErr := parent.Sync() + parent.Close() + if syncErr != nil { + current.Close() + return nil, agentcapabilities.ErrInvalid + } + } + next, err := current.OpenRoot(child) + current.Close() + if err != nil { + return nil, agentcapabilities.ErrInvalid + } + current = next + } + if !privateSnapshotDirectory(current) { + current.Close() + return nil, agentcapabilities.ErrInvalid + } + lock, err := current.Open(".") + if err != nil { + current.Close() + return nil, agentcapabilities.ErrInvalid + } + if syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil { + lock.Close() + current.Close() + return nil, agentcapabilities.ErrInvalid + } + marker := &snapshotMarker{directory: current, lock: lock, name: identity.EnvironmentID + "-" + identity.SessionID + ".json", root: b.capabilityRoot} + completed, err := marker.read() + if err != nil { + marker.close() + return nil, agentcapabilities.ErrInvalid + } + marker.completed = completed + return marker, nil +} + +func privateSnapshotDirectory(root *os.Root) bool { + file, err := root.Open(".") + if err != nil { + return false + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.IsDir() || info.Mode().Perm()&0077 != 0 { + return false + } + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && stat.Uid == uint32(os.Getuid()) +} + +func (m *snapshotMarker) read() (bool, error) { + file, err := m.directory.OpenFile(m.name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return false, err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || stat.Uid != uint32(os.Getuid()) || stat.Nlink != 1 || info.Size() > 8192 { + return false, agentcapabilities.ErrInvalid + } + // Exact bytes also reject duplicate members, trailing data and extra fields. + expected, _ := json.Marshal(struct { + Root string `json:"capability_root"` + }{m.root}) + actual, err := io.ReadAll(io.LimitReader(file, 8193)) + if err != nil || string(actual) != string(expected)+"\n" { + return false, agentcapabilities.ErrInvalid + } + return true, nil +} + +func (m *snapshotMarker) complete() error { + if m.completed { + return nil + } + data, err := json.Marshal(struct { + Root string `json:"capability_root"` + }{m.root}) + if err != nil { + return agentcapabilities.ErrInvalid + } + file, err := m.directory.OpenFile(m.name, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0600) + if err != nil { + return agentcapabilities.ErrInvalid + } + _, writeErr := file.Write(append(data, '\n')) + syncErr := file.Sync() + closeErr := file.Close() + if writeErr != nil || syncErr != nil || closeErr != nil || m.lock.Sync() != nil { + return agentcapabilities.ErrInvalid + } + m.completed = true + return nil +} + +func (m *snapshotMarker) close() { + _ = syscall.Flock(int(m.lock.Fd()), syscall.LOCK_UN) + m.lock.Close() + m.directory.Close() +} diff --git a/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go b/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go new file mode 100644 index 000000000..f870fcaa9 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go @@ -0,0 +1,250 @@ +package localworkspace + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" +) + +func markerBinding(t *testing.T) (*Binding, proto.PromptRequestPayload) { + t.Helper() + b, request := testBinding(t) + // This host's inherited ACL gives TempDir group permissions unless cleared. + if err := os.Chmod(os.Getenv("OAC_RUNTIME_HOME"), 0700); err != nil { + t.Fatal(err) + } + configured, err := b.Configure(request) + if err != nil { + t.Fatal(err) + } + return b, configured +} +func markerPath(b *Binding) string { + identity := b.capabilityIdentity() + return filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "daemon", "capability-installations", identity.EnvironmentID+"-"+identity.SessionID+".json") +} +func TestSnapshotMarkerPrivateRoundTrip(t *testing.T) { + b, _ := markerBinding(t) + m, err := b.openSnapshotMarker() + if err != nil { + t.Fatal(err) + } + if m.completed { + t.Fatal("fresh marker complete") + } + if err := m.complete(); err != nil { + t.Fatal(err) + } + m.close() + info, err := os.Stat(markerPath(b)) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("marker not private", err) + } + raw, err := os.ReadFile(markerPath(b)) + if err != nil { + t.Fatal(err) + } + var fields map[string]string + if json.Unmarshal(raw, &fields) != nil || len(fields) != 1 || fields["capability_root"] != b.capabilityRoot { + t.Fatal("marker contains more than installation root") + } + m, err = b.openSnapshotMarker() + if err != nil { + t.Fatal(err) + } + defer m.close() + if !m.completed { + t.Fatal("completion not retained") + } + if err := m.complete(); err != nil { + t.Fatal("completed marker rewritten", err) + } +} + +func TestCompletedSnapshotLossNeverRecapturesSources(t *testing.T) { + for _, populated := range []bool{false, true} { + for _, removeRoot := range []bool{false, true} { + name := map[bool]string{false: "empty", true: "directory"}[populated] + "/" + map[bool]string{false: "manifest", true: "root"}[removeRoot] + t.Run(name, func(t *testing.T) { + b, request := markerBinding(t) + source := t.TempDir() + if populated { + writeSourceSkill(t, source, "first") + request.LocalEnvironment.Capabilities = true + request.LocalEnvironment.CapabilitySources = &agentcapabilities.Input{Directories: []string{source}} + } + if _, err := b.Prepare(t.Context(), request); err != nil { + t.Fatal(err) + } + marker, err := os.ReadFile(markerPath(b)) + if err != nil { + t.Fatal(err) + } + if removeRoot { + if err := os.RemoveAll(b.capabilityRoot); err != nil { + t.Fatal(err) + } + } else if err := os.Remove(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); err != nil { + t.Fatal(err) + } + if populated { + writeSourceSkill(t, source, "changed") + } + if _, err := b.Prepare(t.Context(), request); err == nil { + t.Fatal("lost snapshot recreated") + } + if _, err := os.Stat(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); !os.IsNotExist(err) { + t.Fatal("manifest recreated", err) + } + if removeRoot { + if _, err := os.Stat(b.capabilityRoot); !os.IsNotExist(err) { + t.Fatal("installation root recreated", err) + } + } + after, _ := os.ReadFile(markerPath(b)) + if string(after) != string(marker) { + t.Fatal("completion evidence changed") + } + }) + } + } +} + +func TestSnapshotMarkerBackfillsOnlyVerifiedManifest(t *testing.T) { + b, request := markerBinding(t) + source := t.TempDir() + writeSourceSkill(t, source, "frozen") + input := agentcapabilities.Input{Directories: []string{source}} + root, err := os.OpenRoot(b.capabilityRoot) + if err != nil { + t.Fatal(err) + } + if err := agentcapabilities.Finalize(root, input, b.capabilityIdentity(), b.resolveCapabilityDirectory); err != nil { + t.Fatal(err) + } + root.Close() + if err := os.RemoveAll(source); err != nil { + t.Fatal(err) + } + request.LocalEnvironment.Capabilities = true + request.LocalEnvironment.CapabilitySources = &input + if _, err := b.Prepare(t.Context(), request); err != nil { + t.Fatal("valid manifest was not recovered without sources", err) + } + if _, err := os.Stat(markerPath(b)); err != nil { + t.Fatal("verified completion not recorded", err) + } + previousRoot := b.capabilityRoot + b.capabilityRoot = t.TempDir() + if _, err := b.Prepare(t.Context(), request); err == nil { + t.Fatal("operator root changed after completion") + } + if _, err := os.Stat(filepath.Join(b.capabilityRoot, agentcapabilities.ManifestName)); !os.IsNotExist(err) { + t.Fatal("replacement root installed", err) + } + b.capabilityRoot = previousRoot +} + +func TestSnapshotMarkerRefusesDamagedOrAliasedPrivateState(t *testing.T) { + for _, kind := range []string{"corrupt", "foreign root", "public file", "symlink file", "hardlink file", "symlink directory", "symlink private root"} { + t.Run(kind, func(t *testing.T) { + b, request := markerBinding(t) + if _, err := b.Prepare(t.Context(), request); err != nil { + t.Fatal(err) + } + name := markerPath(b) + switch kind { + case "corrupt": + if err := os.WriteFile(name, []byte("{"), 0600); err != nil { + t.Fatal(err) + } + case "foreign root": + if err := os.WriteFile(name, []byte("{\"capability_root\":\"/another\"}\n"), 0600); err != nil { + t.Fatal(err) + } + case "public file": + if err := os.Chmod(name, 0644); err != nil { + t.Fatal(err) + } + case "symlink file": + saved := filepath.Join(t.TempDir(), "saved") + if err := os.Rename(name, saved); err != nil { + t.Fatal(err) + } + if err := os.Symlink(saved, name); err != nil { + t.Fatal(err) + } + case "hardlink file": + if err := os.Link(name, filepath.Join(t.TempDir(), "linked")); err != nil { + t.Fatal(err) + } + case "symlink directory": + saved := filepath.Join(filepath.Dir(filepath.Dir(name)), "moved") + if err := os.Rename(filepath.Dir(name), saved); err != nil { + t.Fatal(err) + } + if err := os.Symlink(saved, filepath.Dir(name)); err != nil { + t.Fatal(err) + } + case "symlink private root": + alias := filepath.Join(t.TempDir(), "private") + if err := os.Symlink(os.Getenv("OAC_RUNTIME_HOME"), alias); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_HOME", alias) + } + before, _ := os.ReadFile(name) + if _, err := b.Prepare(t.Context(), request); err == nil { + t.Fatal("invalid completion evidence accepted") + } + after, _ := os.ReadFile(name) + if string(before) != string(after) { + t.Fatal("invalid evidence overwritten") + } + }) + } +} + +func TestCapabilityFinalizeRecordsCompletionAndRejectsLaterImports(t *testing.T) { + b, _ := markerBinding(t) + identity := b.capabilityIdentity() + finalize := proto.CapabilitiesPreparePayload{Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "finalize", Sources: &agentcapabilities.Input{}} + if err := b.ApplyCapabilities(t.Context(), finalize, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(markerPath(b)); err != nil { + t.Fatal("finalize omitted marker", err) + } + // Even a fresh empty root cannot make an already completed identity writable. + if err := os.RemoveAll(b.capabilityRoot); err != nil { + t.Fatal(err) + } + skill := proto.CapabilitiesPreparePayload{Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "skill", Skill: &agentskill.Metadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 1, SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + if err := b.ApplyCapabilities(t.Context(), skill, []byte("x")); err == nil { + t.Fatal("completed identity accepted import") + } + if err := b.ApplyCapabilities(t.Context(), finalize, nil); err == nil { + t.Fatal("completed identity finalized again") + } + if _, err := os.Stat(b.capabilityRoot); !os.IsNotExist(err) { + t.Fatal("rejected operation recreated root", err) + } +} + +func TestSnapshotMarkerCancellationDoesNotPublishCompletion(t *testing.T) { + b, request := markerBinding(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := b.Prepare(ctx, request); err == nil { + t.Fatal("cancelled preparation accepted") + } + if _, err := os.Stat(markerPath(b)); !os.IsNotExist(err) { + t.Fatal("cancelled preparation published marker", err) + } +} diff --git a/contracts/agents-api/environment-files.md b/contracts/agents-api/environment-files.md index 0ca4916f4..671ee4d02 100644 --- a/contracts/agents-api/environment-files.md +++ b/contracts/agents-api/environment-files.md @@ -279,10 +279,12 @@ Decisions: - The shared `oac-codex-write` helper takes an explicit per-call mode. Only the daemon's local workspace writer, which serves only Files.create, passes - `create`. Core's initial Session file installer and the Skill installer keep the - four-argument replace mode, which older Runtime images also understand. Plugins - use the separate capability installer, and completed recovery, including cold - resume, never reinstalls files. Their semantics are unchanged. + `create`. Core's initial Session file installer keeps the four-argument replace + mode. At this historical acceptance, the Skill installer also used that helper; + current Skills and Plugins use the + [shared Runtime capability preparation](environments.md#runtime-capability-preparation). + Completed recovery, including cold resume, never reinstalls files. The Files.create + semantics recorded here remain unchanged. - Parents are created only after the complete body is verified, so incomplete or corrupt input creates nothing. Each new directory is reopened without following links. The install itself never replaces: `renameat2(RENAME_NOREPLACE)`, or diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md index 10f284c8d..da8181917 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -133,7 +133,7 @@ while template responses retain unresolved selectors. Confidential bundle conten never appears in these metadata responses. The common Runtime installation path receives frozen files and descriptive metadata, without source or template IDs. -Inline Skill ZIPs use the same initializer: +Inline Skill ZIPs use the shared Runtime capability installer: ```python import base64 @@ -163,10 +163,14 @@ resource-bound template and Session snapshots. Updates replace supplied `skills` omission preserves and null/[] clears. Existing Sessions retain their frozen content after template update/deletion. -The shared initializer installs Skills under -`/environment/initialization/capabilities/skills/` before setup and native execution. -Setup and native tools can read that tree but cannot write it; completed recovery -never reinstalls it. The public descriptor requests capability installation; the common daemon resolves protected metadata and paths for native preparation. +Core transfers frozen Skill archives over the authenticated Runtime connection. +Runtime's shared parser installs them under its operator-bound capability root +(default `/environment/initialization/capabilities/skills/` for each Skill) +before setup and native execution; +Provider commands do not install Skills or Plugins. Setup and native tools can read +that tree but cannot write it. Completed recovery loads the protected installation +without reinstalling. The common Runtime resolves installed metadata and paths before +invoking the native executor factory. Codex registers native extra roots, Claude creates its own explicit Skill plugin envelope, and MiniMax points its native user-global catalog at the shared root. MiniMax retains disabled unrestricted built-in tools and uses its existing @@ -192,18 +196,23 @@ Referencing Session Plugin lists inherit on omission/null and replace when a non-null list is supplied, including empty-list clearing. Template resource updates continue to accept null/empty clearing. -`capability_directories` currently accepts clean absolute paths within `/workspace`. -Initial files and setup can populate them. The shared initializer snapshots these -directories after setup, then publishes one protected installed manifest. Recovery -uses those installed bytes even if the source directory changes or is removed. -This timing and workspace restriction are local implementation choices, not claims -about unspecified upstream behavior. A supplied Session directory list replaces +Hosted Template and inline `capability_directories` accept clean absolute paths +within `/workspace`; self-hosted local selections have their +[separate public input](environments.md#runtime-capability-preparation). +Initial files and setup can populate hosted directories. Runtime snapshots them after +setup and writes the shared protected `installed.json`, bound to the Session, +Environment and source-selection digest. Recovery loads those installed bytes even +if a source directory changes or is removed; a new Session captures its own snapshot. +Partial or conflicting installation fails rather than silently recapturing sources. +This timing and the hosted path restriction are local implementation choices, not +claims about unspecified upstream behavior. A supplied Session directory list replaces the template list; omitted/null lists inherit. Missing, overlapping duplicate Skill names, unsupported manifests and nonregular files reject initialization without publishing completion. Directory-discovered Skills do not become fabricated inline entries in public `skills` or `plugins` metadata. -The common daemon resolves the installed manifest before executable preparation; +The common Runtime resolves the installed manifest during admitted asynchronous +preparation, before creating a native executor; read-only Files operations retain their existing minimal requirements. Native adapters consume Runtime-owned Skill and package roots. Codex uses explicit roots, MiniMax uses its native registry and Claude uses controlled envelopes with explicit @@ -218,8 +227,8 @@ combined installed capabilities are limited to 50 Skills and 50 MiB. Limits are implementation bounds. Native shell preprocessing, dependency activation and nested discovery retain the existing adapter restrictions. -Real standalone Docker acceptance on 2026-09-21 passed with the fixed official -SDK and raw HTTP against current Core/daemon/adapters on qualified native images: +Historical standalone Docker acceptance on 2026-09-21 passed with the fixed official +SDK and raw HTTP against the then-current Core/daemon/adapters on qualified native images: Codex/Kimi (258.08s), Claude/Kimi (187.81s) and MiniMax Code/MiniMax (250.23s). Each exercised two Plugin Skills with package-relative resources and one directory Skill generated during setup, public Files/Artifacts and tenant rejection, retained @@ -237,7 +246,8 @@ fixture is `services/agents-api/tests/official_environment_plugins.py`; operator runners reuse existing standalone acceptance and private model configuration. A user-authorized reused-context GPT-6 Astra high independent review of all 60 changed files found no material actionable findings. Those historical results do -not qualify Plugin MCP. Portable root `plugin.json` applicability and the unconfirmed +not qualify Plugin MCP or the later shared Runtime transport and self-hosted directory +preparation. Portable root `plugin.json` applicability and the unconfirmed semantics above remain gaps; these results do not establish complete Environment Templates or protocol compatibility. @@ -452,18 +462,19 @@ The reason names only the failed step and its exit status: | Setup command `i` | `Failed to provision environment: script "setup_commands[i]" failed with exit code N` (observed) | | Python packages | `... script "Python package installation" failed with exit code N` (observed label; the official reason appends raw pip output, Core never does) | | npm or system packages | `... script "npm package installation"` / `"System package installation"` `failed with exit code N` (unverified) | -| Initial file or Skill with a confirmed failed write | `Failed to provision environment: initial file installation failed` / `Skill installation failed` (unverified) | +| Initial file write or Runtime Skill preparation with a confirmed failure | `Failed to provision environment: initial file installation failed` / `Skill installation failed` (unverified) | | Anything else | `Failed to provision environment: initialization did not complete` | "Anything else" covers timeouts, the thirty-minute budget, unknown effects, -missing or malformed receipts, receipts without `exit_code` from Runtime images -built before this change, Plugin and capability installation, bootstrap -rejection and Core restart during initialization. Core treats a step as -confirmed failed only when the process exits 1 with empty stderr and stdout -decodes as a version-1 receipt whose `outcome` is `failed`. The decoder is -deliberately lenient so older images keep working: other receipt fields are -ignored and never read. The only value ever taken from the receipt is -`exit_code`, used only when it is an integer from 1 to 255. The Store composes the +missing or malformed receipts, Plugin installation and directory finalization, +bootstrap rejection and Core restart during initialization. Provider-executed setup +has a confirmed failure only when the process exits 1 with empty stderr and stdout +decodes as a version-1 receipt whose `outcome` is `failed`. Only its optional +`exit_code` is projected, when it is an integer from 1 to 255; unknown fields are +ignored without establishing old-version compatibility. Runtime capability +operations use typed `rejected`, `failed` or `unknown` outcomes instead of provider +process receipts. Only a confirmed Skill preparation failure gets the Skill label; +Plugin/finalization failures and uncertain effects retain the generic reason. The Store composes the reason from a fixed label and integers, so commands, env values, package names, paths and any process output never reach the reason, events, logs or responses. The failed step is diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 45b6d8755..c17a75587 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -3,7 +3,10 @@ This assessment covers the fixed [Python SDK contract](upstream.json), with partial coverage. Core-managed Docker runs the three qualified native harnesses. V1 user-managed `self_hosted` enrollment uses the same colocated Runtime for Codex, -Claude SDK and MiniMax at `/workspace`; see its [real deployment qualification](user-managed-runtime-v1.md). +Claude SDK and MiniMax. `/workspace` names the packaged workspace; a self-hosted +Session may instead select its exact canonical physical directory. The +[recorded deployment qualification](user-managed-runtime-v1.md) retains its historical +source, paths and tested capability scope. For hosted compute, the deployment selects E2B, Docker or microsandbox through [Hosted Sandbox Manager](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md). For the separate caller-managed E2B path, the user owns allocation, renewal and @@ -45,7 +48,9 @@ an existing allocation's Create. Omitted/null network defaults to enabled. Enabled, disabled and exact-host restricted policies use the same qualified image with adapter-selected immutable native policy. Templates and inline configuration share initial files, env, packages, ordered setup -and inline or tenant-owned referenced Skills through the hosted initializer. +and inline or tenant-owned referenced Skills through ordered managed initialization. +Provider operations retain file/tool/package/setup work; Runtime imports Skills and +Plugins and finalizes directory snapshots through its authenticated connection. Unsupported hostname forms and installation combinations reject explicitly; see the [Template coverage and limits](environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Service-origin @@ -78,7 +83,8 @@ Current deployment-managed E2B is a separate hosted configuration in the manager ## Initial public self-hosted profile Create a Session with `environment.type=self_hosted`, -`workspace_directory: "/workspace"` and omitted/null/empty `capability_directories`. Creation +a clean absolute `workspace_directory` and optional absolute local +`capability_directories`. `/workspace` maps to the Runtime-bound workspace. Creation accepts initial text as a string or ordered user-message array. Omitted/null input creates no Turn or connection action. Configured execution, an enabled harness and the exact local profile are validated @@ -122,9 +128,15 @@ existing function parser and remain fixed through native preparation and continu output/error field presence and ordered content keep their existing semantics. Retries retain the original call, including during later work. New results cannot bypass pending input. Function callbacks do not populate Environment installations. -Mixed events, non-text input and nonempty capability directories remain unsupported. -The public field types are unchanged; `/workspace` is the V1 deployment limit, not -an upstream schema change. `remote_url` is the configured daemon WebSocket URL, +Mixed events and non-text input remain unsupported. Local capability directories +use the same Runtime parser and installed snapshot as managed Skills and Plugins; +preparation must finish before native execution. Reconnect reuses installed bytes, +while new Sessions capture their own sources. Directory aliases and protected Runtime +state are rejected. The current implementation uses the packaged Linux Runtime +layout; cross-platform installation and isolation are not part of this profile. +The public field types are unchanged. Enrollment must match either the `/workspace` +logical alias or the exact canonical directory bound by the Runtime; selecting an +arbitrary path does not grant access. `remote_url` is the configured daemon WebSocket URL, returned unchanged. This is our private connection contract and does not claim stock `exec-server` compatibility. Service-origin HTTP MCP is explicitly rejected on `self_hosted`; `none` MCP and hosted Template Plugin MCP retain their own scope. @@ -135,6 +147,42 @@ exercise fixed SDK/raw HTTP creation/input, actual native tools, Files/Artifacts second-Turn history, Core/Runtime restart, cancellation and credential rotation/ revocation/deletion on each declared deployment. +## Runtime capability preparation + +Core freezes resource versions, metadata and source selections. Managed initialization +keeps initial files and tool configuration before Runtime Skill/Plugin import, +then runs packages and ordered setup before Runtime snapshots local capability +directories. Providers do not parse or install capability bundles. Self-hosted input +accepts only local `capability_directories` with `workspace_directory`, not managed +Skill/Plugin archive fields or a hosted Template. Local directory discovery does not +populate the public API-managed `skills` or `plugins` installation arrays. + +Both origins use the packaged Linux Runtime's shared parser and protected +`installed.json`. Its default root is `/environment/initialization/capabilities`; +the operator may freeze another root with `OAC_RUNTIME_CAPABILITY_DIRECTORY`, kept +separate from workspace and private Runtime state. Core and public requests cannot +select this installation root. The manifest binds one +Session and Environment to the source-selection digest. The admitted asynchronous +preparation owner verifies or creates it before native execution, including for an +empty selection. Reconnect and replacement executors reuse installed contents; +source edits are observed only by a new Session with its own Environment and fresh +snapshot. A private completion record retains only the operator installation root, +so loss of a completed snapshot cannot trigger source recapture. Missing, partial or +conflicting snapshots fail without automatic repair or replay. Adapters receive only the resulting Runtime-owned Skill paths and MCP +declarations. Files access retains its separate readiness and authorization. + +Resource management owns allocation and Environment creation, renewal and reclamation. +Executor close, Turn cancellation and transport loss preserve the installed snapshot, +workspace and allocation. Reclamation is an explicit operation coordinated with active +work; a disconnected socket is not proof that native effects have stopped. + +The protocol is platform-neutral, but this implementation retains the existing Linux +helpers, protected initialization storage and isolation requirements. It does not provide +a new native-host installer, another platform layout, or Windows/macOS isolation. +This implementation description does not extend historical real-deployment acceptance; +new managed and self-hosted capability coverage requires its own evidence. See the +[canonical preparation rules](../../CONTRIBUTING.md#shared-runtime-capability-preparation). + ## Contract inventory Paths below follow the SDK resource methods, before the service's `/v1` prefix. diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index c9fdde33e..c6e7418c3 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -26,7 +26,7 @@ Runtime: Executor preparation, reuse, idle expiry, recovery | Component | Responsibility | Location | | --- | --- | --- | | Core | Public API, authority, durable state, scheduling and configuration snapshots | `services/agents-api` | -| Runtime | Authenticated connection and common Executor/Turn lifecycle | `apps/parsar-daemon/internal/dispatch` | +| Runtime | Authenticated connection, shared capability preparation and common Executor/Turn lifecycle | `apps/parsar-daemon/internal/dispatch` | | Adapter | Native configuration, resources, API calls, event translation and restrictions | `apps/parsar-daemon/internal/agent/` | | Harness | Native model/tool loop and history | Pinned SDK or executable | | Service profile | Pure validation of qualified operations and placements | `services/agents-api/internal/engine` | @@ -40,7 +40,10 @@ alone does not qualify another platform. Resource management owns machine select and reclamation. Executor close does not release that allocation or delete the workspace. Environment reclamation is an explicit resource-management action that coordinates with execution. Runtime connection, installed capability snapshot, -Session Executor and Turn each have their own lifetime. Model providers supply +Session Executor and Turn each have their own lifetime. Native factories receive +capabilities only after the common Runtime has loaded its bound installed snapshot; +see [capability preparation](environments.md#runtime-capability-preparation). +Model providers supply model communication configuration, not Turn scheduling or native process ownership. ## Required adapter interfaces diff --git a/contracts/agents-api/message-input.md b/contracts/agents-api/message-input.md index cfe784f89..7d7f70f1f 100644 --- a/contracts/agents-api/message-input.md +++ b/contracts/agents-api/message-input.md @@ -74,7 +74,7 @@ before. ## Runtime boundary -Private wire 0.6.0 uses `MessageInput` for initial requests, prepared start and +Private wire 0.7.0 uses `MessageInput` for initial requests, prepared start and active steering. Each message contains ordered `InputContent` parts, also reused by function-result content. Core does not download, transcode or repair media. The separate Claude bridge reports protocol 2; daemon readiness rejects protocol 1. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 00bc1685d..c24705ceb 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -2966,84 +2966,84 @@ paths: Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile - requires Codex, an absolute workspace_directory and empty capability_directories, - with optional non-deferred function tools and HTTP MCP using service origin, - optionally authenticated by the attached Vault rules. Remote MCP and remote - Bearer authentication each require separately advertised combination support; - old peers cannot receive unsupported work. Omitted/null capability_directories - use the empty-list default; self_hosted requires configured execution plus - executor registry. Claude SDK currently requires medium verbosity and object-root - function schemas. It supports anonymous or attached static-bearer service-origin - HTTP MCP on none with boolean required and separately advertised MCP/bearer/required - runtime support. Required servers must be connected before the first native - input is released; pending or failed startup rejects execution. The shared - Vault selection and immutable binding rules apply; unsupported native labels/tool - names reject before persistence. An attached Vault with no matching credential - may remain anonymous; missing keys or failed credential lookup/decryption - never fall back to anonymous execution. Omitted stream defaults to false; - stream and agent_id cannot be null. Metadata may be null; non-string values - and limit violations return invalid_request_error with a metadata or metadata. - param. The inline agent uses the Agent create configuration validation with - agent.-prefixed params, reported before the input requirement and saved-Agent - lookup; saved configurations with conflicting tools or schema roots reject - admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. - Hosted network policy rejections return invalid_request_error with a null - param. Initial input accepts a string or ordered user-message array. Codex - and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG - image content; other image combinations and remote URLs are unsupported. None - initial input atomically starts a Turn; self_hosted initial input is reserved - while returning its Environment connection target, with execution deferred - to native readiness and Session failure on initial timeout. Initial input - is required for none and for streamed creation outside self_hosted. Omitted/null - input remains valid for non-streaming hosted and self_hosted creation. With - stream=true, returns live Session events starting with the committed creation - snapshot and closes right after the first agent.session.idle recorded when - a Turn ends or an input reservation stops being pending, or any agent.session.failed, - without sending later events. A creation that admitted nothing closes after - the snapshot; a settlement that records no event closes after events up to - the cursor read with a settled Session projection. Required actions keep it - open; disconnect does not cancel execution. The GET events stream remains - live-only. New Sessions retain their authenticated creator; all creation retries - require the same typed subject, including across key rotation. Saved-Agent - retries and inline requests using Vault attachments or credential references - retain caller intent independently of later resource changes; new hosted inline - requests also freeze caller intent before deployment defaults resolve; unrelated - non-hosted inline retries preserve resolved/default equivalences, and their - resolved hash leaves out any deployment default. Provider keys enter retry - hashes only as fingerprints keyed by the credential key. Unknown historical - creators reject retries; known creators without recorded intent retain resolved-snapshot - retry rules. These conflict policies are local and not verified hosted parity. - A same-key stream=true retry of an existing creation returns 201 with no events - and closes at once; retry with stream=false or use the GET events stream to - recover. Claude SDK on none and Core-managed Docker openai_hosted supports - qualified object-root json_schema output with medium verbosity, single-Agent - execution and ordinary functions. Hosted execution reuses native workspace - tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, - Subagent and tool_search combinations remain unqualified, including inherited - template contents. Other non-text initial input remains unsupported. Basic - Codex and Claude SDK openai_hosted creation requires an explicitly configured - managed provider. The Claude workspace profile supports non-deferred function - tools with text or successful inline PNG/JPEG results alongside native workspace - tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; - initial provisioning has no caller connection action. Network defaults to - enabled; disabled and restricted exact ASCII hostnames are supported. Restricted - policy requires 1–100 allowed domains. Unsupported hostname forms and startup - installations are rejected. Confidential env, system/npm/Python packages and - ordered setup commands use the shared initialization lifecycle; requested - network applies after setup. Initial inline and tenant-owned file_id files - freeze encrypted bytes before provisioning, then install through the common - Core lifecycle before native execution or live Files access. With a template - reference, omitted/null files, env, packages and setup_commands inherit. Non-null - files and command lists replace; env overlays by key; each package manager - inherits on omission/null and otherwise replaces its list. Empty lists clear - their selected field. Tenant-owned environment_template_id references inherit - omitted/null network and allow only narrowing overrides. Inline hosted network:null - retains the enabled default; updating a Template with network:null resets - its saved policy to enabled. Core freezes effective configuration; template - updates/deletion do not alter Session snapshots or same-intent creation retries. - Inline or tenant-owned skill_reference Skills share initialization. Templates - preserve default/latest/explicit selectors; Session creation freezes concrete - metadata and encrypted content atomically. Skill, Plugin and capability-directory + uses a qualified native harness, a clean absolute workspace_directory and + optional absolute local capability_directories prepared by Runtime, with optional + non-deferred function tools and HTTP MCP using service origin, optionally + authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication + each require separately advertised combination support; old peers cannot receive + unsupported work. Omitted/null capability_directories use the empty-list default; + self_hosted requires configured execution plus executor registry. Claude SDK + currently requires medium verbosity and object-root function schemas. It supports + anonymous or attached static-bearer service-origin HTTP MCP on none with boolean + required and separately advertised MCP/bearer/required runtime support. Required + servers must be connected before the first native input is released; pending + or failed startup rejects execution. The shared Vault selection and immutable + binding rules apply; unsupported native labels/tool names reject before persistence. + An attached Vault with no matching credential may remain anonymous; missing + keys or failed credential lookup/decryption never fall back to anonymous execution. + Omitted stream defaults to false; stream and agent_id cannot be null. Metadata + may be null; non-string values and limit violations return invalid_request_error + with a metadata or metadata. param. The inline agent uses the Agent create + configuration validation with agent.-prefixed params, reported before the + input requirement and saved-Agent lookup; saved configurations with conflicting + tools or schema roots reject admission with the same errors, and execution + limits keep unsupported_or_invalid_configuration. Hosted network policy rejections + return invalid_request_error with a null param. Initial input accepts a string + or ordered user-message array. Codex and Claude SDK on none and qualified + openai_hosted also accept inline PNG/JPEG image content; other image combinations + and remote URLs are unsupported. None initial input atomically starts a Turn; + self_hosted initial input is reserved while returning its Environment connection + target, with execution deferred to native readiness and Session failure on + initial timeout. Initial input is required for none and for streamed creation + outside self_hosted. Omitted/null input remains valid for non-streaming hosted + and self_hosted creation. With stream=true, returns live Session events starting + with the committed creation snapshot and closes right after the first agent.session.idle + recorded when a Turn ends or an input reservation stops being pending, or + any agent.session.failed, without sending later events. A creation that admitted + nothing closes after the snapshot; a settlement that records no event closes + after events up to the cursor read with a settled Session projection. Required + actions keep it open; disconnect does not cancel execution. The GET events + stream remains live-only. New Sessions retain their authenticated creator; + all creation retries require the same typed subject, including across key + rotation. Saved-Agent retries and inline requests using Vault attachments + or credential references retain caller intent independently of later resource + changes; new hosted inline requests also freeze caller intent before deployment + defaults resolve; unrelated non-hosted inline retries preserve resolved/default + equivalences, and their resolved hash leaves out any deployment default. Provider + keys enter retry hashes only as fingerprints keyed by the credential key. + Unknown historical creators reject retries; known creators without recorded + intent retain resolved-snapshot retry rules. These conflict policies are local + and not verified hosted parity. A same-key stream=true retry of an existing + creation returns 201 with no events and closes at once; retry with stream=false + or use the GET events stream to recover. Claude SDK on none and Core-managed + Docker openai_hosted supports qualified object-root json_schema output with + medium verbosity, single-Agent execution and ordinary functions. Hosted execution + reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability + directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, + including inherited template contents. Other non-text initial input remains + unsupported. Basic Codex and Claude SDK openai_hosted creation requires an + explicitly configured managed provider. The Claude workspace profile supports + non-deferred function tools with text or successful inline PNG/JPEG results + alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions + provision automatically; initial provisioning has no caller connection action. + Network defaults to enabled; disabled and restricted exact ASCII hostnames + are supported. Restricted policy requires 1–100 allowed domains. Unsupported + hostname forms and startup installations are rejected. Confidential env, system/npm/Python + packages and ordered setup commands use the shared initialization lifecycle; + requested network applies after setup. Initial inline and tenant-owned file_id + files freeze encrypted bytes before provisioning, then install through the + common Core lifecycle before native execution or live Files access. With a + template reference, omitted/null files, env, packages and setup_commands inherit. + Non-null files and command lists replace; env overlays by key; each package + manager inherits on omission/null and otherwise replaces its list. Empty lists + clear their selected field. Tenant-owned environment_template_id references + inherit omitted/null network and allow only narrowing overrides. Inline hosted + network:null retains the enabled default; updating a Template with network:null + resets its saved policy to enabled. Core freezes effective configuration; + template updates/deletion do not alter Session snapshots or same-intent creation + retries. Inline or tenant-owned skill_reference Skills share initialization. + Templates preserve default/latest/explicit selectors; Session creation freezes + concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. diff --git a/contracts/agents-api/user-managed-runtime-v1.md b/contracts/agents-api/user-managed-runtime-v1.md index 41e037fe0..52335471c 100644 --- a/contracts/agents-api/user-managed-runtime-v1.md +++ b/contracts/agents-api/user-managed-runtime-v1.md @@ -15,8 +15,9 @@ file operations continue through Core and daemon, not E2B commands or files. ## Accepted scope -Recorded on 2026-09-22 against source candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. -Documentation-only follow-up commits do not change the accepted binaries. +Historical qualification recorded on 2026-09-22 against source candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. +These records qualify only those binaries and tested inputs. Later implementation +changes, including shared Runtime capability preparation, require separate acceptance. | Deployment | Codex | Claude Code | MiniMax Code | | --- | --- | --- | --- | @@ -97,8 +98,12 @@ or a saved Agent, frozen and delivered over the same daemon transport; see [model execution](model-execution.md). Deployment default model providers never reach self-hosted executors. -The public self-hosted profile accepts `/workspace` and empty capability -directories. Service-origin HTTP MCP on self-hosted remains explicitly rejected; +The historical runs above used `/workspace` and empty capability directories. +Current self-hosted input accepts a canonical absolute workspace and optional local +capability directories, prepared through the +[shared Runtime snapshot](environments.md#runtime-capability-preparation). That added +scope is not qualified by these earlier runs. Service-origin HTTP MCP on self-hosted +remains explicitly rejected; none-environment HTTP MCP and separately qualified hosted Plugin MCP keep their own scope. Environment Templates remain hosted-only. Unspecified upstream defaults, errors, lifecycle edge cases and broader resource semantics remain in the protocol diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md index 004985864..17d12975c 100644 --- a/docs/getting-started/self-hosted.md +++ b/docs/getting-started/self-hosted.md @@ -95,6 +95,45 @@ Core delivers the Session's model provider only over this Environment's executor connection. The host keeps it in the Runtime's harness home, where the model's tools and the Files API can't read it but the host's owner can. +## Local capability directories + +A self-hosted Session may supply `capability_directories` alongside its +`workspace_directory`, for example: + +```python +environment = { + "type": "self_hosted", + "workspace_directory": "/workspace", + "capability_directories": ["/workspace/capabilities"], +} +``` + +These paths refer to directories visible inside that Runtime. Populate them before +first execution. `/workspace` maps to its bound workspace; an operator-prepared +Runtime may use its exact canonical absolute workspace instead. Directory aliases, +private credentials, native history and protected initialization trees are refused. +This does not mount arbitrary host paths into the container or add a new installer. +Self-hosted public input accepts local directories, not managed Skill/Plugin archives +or hosted Templates. + +The current implementation uses the packaged Linux helpers and protected +`installed.json`, by default under `/environment/initialization/capabilities`. +Operators may freeze another root with `OAC_RUNTIME_CAPABILITY_DIRECTORY`; it must +stay separate from the workspace and private Runtime state and is not a public API +setting. Runtime snapshots sources +before native execution and supplies the resulting Skill and MCP inventory through +the same parser as managed bundles. Reconnect reuses installed contents even after +source edits; a new Session captures its own snapshot. A missing or inconsistent +snapshot fails preparation without silently reinstalling. Local discovery does not +create entries in the public API-managed installation arrays. + +Closing an executor, cancelling a Turn or losing its connection preserves the +snapshot and workspace. The compute owner remains responsible for explicit resource +cleanup. This source-level capability behavior requires its own real acceptance; +[earlier qualification](../../contracts/agents-api/user-managed-runtime-v1.md) covered +only its recorded inputs and binaries, not local directory preparation or additional +platforms. + ## Rotate or revoke | Action in Web | Effect | diff --git a/internal/agentcapabilities/install.go b/internal/agentcapabilities/install.go index b5badc74a..ce8c24c80 100644 --- a/internal/agentcapabilities/install.go +++ b/internal/agentcapabilities/install.go @@ -12,8 +12,19 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" ) +func InstallSkill(root *os.Root, archive []byte, metadata agentskill.Metadata) error { + if unfinished(root) != nil { + return ErrInvalid + } + files, err := agentskill.Read(archive, metadata) + if err != nil { + return ErrInvalid + } + return writeTree(root, "skills/"+metadata.Name, files) +} + func InstallPlugin(root *os.Root, slot int, archive []byte, metadata agentplugin.Metadata) error { - if slot < 0 || slot >= 50 { + if slot < 0 || slot >= 50 || unfinished(root) != nil { return ErrInvalid } bundle, err := agentplugin.Read(archive, metadata) @@ -23,16 +34,20 @@ func InstallPlugin(root *os.Root, slot int, archive []byte, metadata agentplugin return writeTree(root, "plugins/"+strconv.Itoa(slot), bundle.Files) } -// Finalize runs once after setup under the existing allocation initializer. +// Finalize captures declared directories once after setup and binds the snapshot. // It emits an installation artifact, not another lifecycle or public catalog. -func Finalize(workspace, installed *os.Root, input Input) error { - if len(input.Skills) > MaxSkills || len(input.Plugins) > 50 || ValidateDirectories(input.Directories) != nil { +func Finalize(installed *os.Root, input Input, identity Identity, resolve DirectoryResolver) error { + digest, err := selectionHash(input) + if err != nil || validateIdentity(identity) != nil || (len(input.Directories) > 0 && resolve == nil) { return ErrInvalid } if _, err := installed.Lstat(ManifestName); !os.IsNotExist(err) { return ErrInvalid } - manifest := Manifest{Version: 1, Skills: []InstalledSkill{}} + if validateStaged(installed, input) != nil { + return ErrInvalid + } + manifest := Manifest{Version: 1, Identity: identity, SelectionSHA256: digest, Skills: []InstalledSkill{}} total := 0 count := func(files []agentbundle.File) error { for _, file := range files { @@ -69,12 +84,18 @@ func Finalize(workspace, installed *os.Root, input Input) error { } } for slot, source := range input.Directories { - name := strings.TrimPrefix(source, "/workspace") - name = strings.TrimPrefix(name, "/") - if name == "" { - name = "." + directory, err := resolve(source) + if err != nil || directory == nil { + if directory != nil { + directory.Close() + } + return ErrInvalid + } + files, err := ReadTree(directory, ".", false) + closeErr := directory.Close() + if closeErr != nil { + return ErrInvalid } - files, err := ReadTree(workspace, name, false) if err != nil || count(files) != nil { return ErrInvalid } @@ -84,7 +105,7 @@ func Finalize(workspace, installed *os.Root, input Input) error { } } body, err := json.Marshal(manifest) - if err != nil || writeFile(installed, ManifestName+".tmp", body, 0400) != nil || installed.Rename(ManifestName+".tmp", ManifestName) != nil { + if err != nil || len(body) > 256<<10 || writeFile(installed, ManifestName+".tmp", body, 0400) != nil || installed.Rename(ManifestName+".tmp", ManifestName) != nil { return ErrInvalid } return syncDirectory(installed, ".") @@ -186,3 +207,55 @@ func Load(root *os.Root) (Manifest, error) { } return manifest, nil } + +// Only selected, fully imported bundles may precede finalization. A leftover +// directory capture or temporary manifest is an incomplete installation, not a +// reason to silently recapture mutable sources. +func validateStaged(root *os.Root, input Input) error { + expected := map[string]map[string]bool{"skills": {}, "plugins": {}} + for _, skill := range input.Skills { + expected["skills"][skill.Name] = true + } + for slot := range input.Plugins { + expected["plugins"][strconv.Itoa(slot)] = true + } + file, err := root.Open(".") + if err != nil { + return ErrInvalid + } + entries, err := file.ReadDir(-1) + closeErr := file.Close() + if err != nil || closeErr != nil { + return ErrInvalid + } + for _, entry := range entries { + selected, ok := expected[entry.Name()] + if !ok || !entry.IsDir() { + return ErrInvalid + } + directory, err := root.Open(entry.Name()) + if err != nil { + return ErrInvalid + } + children, err := directory.ReadDir(-1) + closeErr := directory.Close() + if err != nil || closeErr != nil || len(children) != len(selected) { + return ErrInvalid + } + for _, child := range children { + if !child.IsDir() || !selected[child.Name()] { + return ErrInvalid + } + } + } + return nil +} + +func unfinished(root *os.Root) error { + for _, name := range []string{ManifestName, ManifestName + ".tmp"} { + if _, err := root.Lstat(name); !os.IsNotExist(err) { + return ErrInvalid + } + } + return nil +} diff --git a/internal/agentcapabilities/install_test.go b/internal/agentcapabilities/install_test.go index 3e510f519..2407e100f 100644 --- a/internal/agentcapabilities/install_test.go +++ b/internal/agentcapabilities/install_test.go @@ -27,7 +27,7 @@ func TestInstalledCapabilitiesPreserveSourcesWithoutRescanning(t *testing.T) { writeWorkspace(t, workspace, "generated/nested/SKILL.md", skillManifest("generated")) writeWorkspace(t, workspace, "generated/shared/proof.txt", []byte("original-resource")) input := Input{Skills: []agentskill.Metadata{inline}, Plugins: []agentplugin.Metadata{plugin}, Directories: []string{"/workspace/generated"}} - if err := Finalize(workspace, installed, input); err != nil { + if err := Finalize(installed, input, testIdentity, workspaceResolver(workspace)); err != nil { t.Fatal(err) } before, err := Load(installed) @@ -51,7 +51,7 @@ func TestInstalledCapabilitiesPreserveSourcesWithoutRescanning(t *testing.T) { t.Fatalf("snapshot changed or writable: %s", name) } } - if Finalize(workspace, installed, input) == nil { + if Finalize(installed, input, testIdentity, workspaceResolver(workspace)) == nil { t.Fatal("completed initialization was replayed") } } @@ -61,7 +61,7 @@ func TestDirectoryDiscoveryDoesNotActivateChildPluginMCP(t *testing.T) { writeWorkspace(t, workspace, "parent/child/.codex-plugin/plugin.json", []byte(`{"name":"remote","description":"Remote","skills":"./skills","mcpServers":"./.mcp.json"}`)) writeWorkspace(t, workspace, "parent/child/.mcp.json", []byte(`{"mcpServers":{"remote":{"type":"http","url":"https://example.com"}}}`)) writeWorkspace(t, workspace, "parent/child/skills/proof/SKILL.md", skillManifest("proof")) - if err := Finalize(workspace, installed, Input{Directories: []string{"/workspace/parent"}}); err != nil { + if err := Finalize(installed, Input{Directories: []string{"/workspace/parent"}}, testIdentity, workspaceResolver(workspace)); err != nil { t.Fatal(err) } manifest, err := Load(installed) @@ -72,7 +72,7 @@ func TestDirectoryDiscoveryDoesNotActivateChildPluginMCP(t *testing.T) { t.Fatal("parent directory activated child Plugin MCP") } exact := openTestRoot(t) - if err := Finalize(workspace, exact, Input{Directories: []string{"/workspace/parent/child"}}); err != nil { + if err := Finalize(exact, Input{Directories: []string{"/workspace/parent/child"}}, testIdentity, workspaceResolver(workspace)); err != nil { t.Fatal(err) } manifest, err = Load(exact) @@ -102,7 +102,7 @@ func TestInvalidDirectorySnapshotsNeverPublish(t *testing.T) { case "file": input.Directories = []string{"/workspace/first/SKILL.md"} } - if Finalize(workspace, installed, input) == nil { + if Finalize(installed, input, testIdentity, workspaceResolver(workspace)) == nil { t.Fatal("invalid capability source accepted") } if _, err := installed.Lstat(ManifestName); !os.IsNotExist(err) { diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index 3ecda2e7e..9ec9d9624 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -2,11 +2,17 @@ package agentcapabilities import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "io/fs" + "os" "path" "strings" + "unicode/utf8" + + "github.com/google/uuid" "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" @@ -21,16 +27,30 @@ var ErrInvalid = errors.New("capability installation unavailable or unsupported" // InstalledSkill contains Runtime-owned paths, never public resource metadata. type InstalledSkill struct { - Metadata agentskill.Metadata `json:"metadata"` - RelativeRoot string `json:"relative_root"` - PackageRoot string `json:"package_root"` + // InstallationRoot is resolved by Runtime and never persisted or accepted over wire. + InstallationRoot string `json:"-"` + Metadata agentskill.Metadata `json:"metadata"` + RelativeRoot string `json:"relative_root"` + PackageRoot string `json:"package_root"` +} + +// Identity binds an installation to one immutable Environment and Session. +type Identity struct { + EnvironmentID string `json:"environment_id"` + SessionID string `json:"session_id"` } +// DirectoryResolver opens a declared source after local authorization and path +// checks. Finalize owns and closes each returned root; callers retain no handle. +type DirectoryResolver func(string) (*os.Root, error) + type Manifest struct { - Version int `json:"version"` - Skills []InstalledSkill `json:"skills"` - Plugins []string `json:"plugins,omitempty"` - MCP []InstalledMCP `json:"-"` + Identity Identity `json:"identity"` + SelectionSHA256 string `json:"selection_sha256"` + Version int `json:"version"` + Skills []InstalledSkill `json:"skills"` + Plugins []string `json:"plugins,omitempty"` + MCP []InstalledMCP `json:"-"` } // InstalledMCP is resolved from a frozen package at load time. The manifest @@ -47,13 +67,29 @@ type Input struct { Directories []string `json:"directories,omitempty"` } +// ValidateDirectories preserves the managed public workspace-path contract. func ValidateDirectories(directories []string) error { + if ValidateLocalDirectories(directories) != nil { + return ErrInvalid + } + for _, directory := range directories { + if directory != "/workspace" && !strings.HasPrefix(directory, "/workspace/") { + return ErrInvalid + } + } + return nil +} + +// ValidateLocalDirectories checks source spelling, not local access authority. +// Absolute paths use the protocol path syntax; the Runtime resolver owns host +// interpretation, directory access and protected-root exclusions. +func ValidateLocalDirectories(directories []string) error { if len(directories) > 50 { return ErrInvalid } seen := map[string]bool{} for _, directory := range directories { - if (directory != "/workspace" && !strings.HasPrefix(directory, "/workspace/")) || + if !path.IsAbs(directory) || len(directory) > 4096 || !utf8.ValidString(directory) || path.Clean(directory) != directory || strings.ContainsAny(directory, "\\\x00\r\n") || seen[directory] { return ErrInvalid } @@ -81,7 +117,7 @@ func (m *Manifest) add(metadata agentskill.Metadata, root, pkg string) error { func decodeManifest(body []byte) (Manifest, error) { var result Manifest - if len(body) > 256<<10 || json.Unmarshal(body, &result) != nil || result.Version != 1 || len(result.Skills) > MaxSkills { + if len(body) > 256<<10 || json.Unmarshal(body, &result) != nil || result.Version != 1 || validateIdentity(result.Identity) != nil || !validSelectionHash(result.SelectionSHA256) || len(result.Skills) > MaxSkills { return Manifest{}, ErrInvalid } checked := Manifest{Version: 1} @@ -110,3 +146,64 @@ func (m *Manifest) addMCPPackage(root string) error { m.Plugins = append(m.Plugins, root) return nil } + +func validateIdentity(identity Identity) error { + for _, value := range []string{identity.EnvironmentID, identity.SessionID} { + id, err := uuid.Parse(value) + if err != nil || id == uuid.Nil || id.String() != value { + return ErrInvalid + } + } + return nil +} + +func validSelectionHash(value string) bool { + digest, err := hex.DecodeString(value) + return err == nil && len(digest) == sha256.Size && hex.EncodeToString(digest) == value +} + +// ValidateInput bounds source selections without reading archives or directories. +// Portable manifest grammar is checked by the shared Skill and Plugin parsers. +func ValidateInput(input Input) error { + if len(input.Skills) > MaxSkills || len(input.Plugins) > 50 || ValidateLocalDirectories(input.Directories) != nil { + return ErrInvalid + } + validMetadata := func(kind, name, description string) bool { + return kind == "inline" && name != "" && description != "" && utf8.ValidString(name) && utf8.ValidString(description) + } + for _, skill := range input.Skills { + if !validMetadata(skill.Type, skill.Name, skill.Description) { + return ErrInvalid + } + } + for _, plugin := range input.Plugins { + if !validMetadata(plugin.Type, plugin.Name, plugin.Description) { + return ErrInvalid + } + } + return nil +} + +func selectionHash(input Input) (string, error) { + if ValidateInput(input) != nil { + return "", ErrInvalid + } + // omitempty gives nil and empty selections the same canonical representation; + // list order remains significant because it selects installed package slots. + body, err := json.Marshal(input) + if err != nil { + return "", ErrInvalid + } + digest := sha256.Sum256(body) + return hex.EncodeToString(digest[:]), nil +} + +// ValidateSelection checks reconnect configuration without revisiting sources. +// Use it only with a manifest returned by Load. +func ValidateSelection(manifest Manifest, input Input, identity Identity) error { + digest, err := selectionHash(input) + if err != nil || validateIdentity(identity) != nil || manifest.Version != 1 || manifest.Identity != identity || manifest.SelectionSHA256 != digest { + return ErrInvalid + } + return nil +} diff --git a/internal/agentcapabilities/mcp_test.go b/internal/agentcapabilities/mcp_test.go index 65f80dfe2..0b110117c 100644 --- a/internal/agentcapabilities/mcp_test.go +++ b/internal/agentcapabilities/mcp_test.go @@ -12,7 +12,7 @@ func TestMCPOnlyInstalledPackageSurvivesSourceDeletion(t *testing.T) { writeWorkspace(t, workspace, "plugin/.codex-plugin/plugin.json", []byte(`{"name":"native-tools","description":"Package tools.","mcpServers":"./.mcp.json"}`)) writeWorkspace(t, workspace, "plugin/.mcp.json", []byte(`{"mcpServers":{"proof":{"command":"python3","args":["proof.py"],"env_vars":["PLUGIN_TOKEN"]}}}`)) writeWorkspace(t, workspace, "plugin/proof.py", []byte("# Preserved server resource.\n")) - if err := Finalize(workspace, installed, Input{Directories: []string{"/workspace/plugin"}}); err != nil { + if err := Finalize(installed, Input{Directories: []string{"/workspace/plugin"}}, testIdentity, workspaceResolver(workspace)); err != nil { t.Fatal(err) } before, err := Load(installed) @@ -40,7 +40,7 @@ func TestMCPOnlyInstalledPackageSurvivesSourceDeletion(t *testing.T) { func TestInstalledMCPManifestRejectsInvalidPackageRoots(t *testing.T) { for _, roots := range [][]string{{"../private"}, {"/private"}, {"plugins/0", "plugins/0"}} { - body, err := json.Marshal(Manifest{Version: 1, Plugins: roots}) + body, err := json.Marshal(Manifest{Version: 1, Identity: testIdentity, SelectionSHA256: strings.Repeat("0", 64), Plugins: roots}) if err != nil { t.Fatal(err) } diff --git a/internal/agentcapabilities/operation.go b/internal/agentcapabilities/operation.go deleted file mode 100644 index 739ad1087..000000000 --- a/internal/agentcapabilities/operation.go +++ /dev/null @@ -1,13 +0,0 @@ -package agentcapabilities - -import "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - -// Operation is confidential stdin for the packaged helper, not a public API. -type Operation struct { - Version int `json:"version"` - Action string `json:"action"` - Slot int `json:"slot,omitempty"` - Archive []byte `json:"archive,omitempty"` - Plugin agentplugin.Metadata `json:"plugin,omitempty"` - Sources Input `json:"sources,omitempty"` -} diff --git a/internal/agentcapabilities/selection_test.go b/internal/agentcapabilities/selection_test.go new file mode 100644 index 000000000..5c5dfbd47 --- /dev/null +++ b/internal/agentcapabilities/selection_test.go @@ -0,0 +1,338 @@ +package agentcapabilities + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "os" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" +) + +var testIdentity = Identity{ + EnvironmentID: "a1000000-0000-4000-8000-000000000001", + SessionID: "a1000000-0000-4000-8000-000000000002", +} + +// The fixture maps declared managed paths; Finalize knows nothing about it. +func workspaceResolver(workspace *os.Root) DirectoryResolver { + return func(source string) (*os.Root, error) { + if ValidateDirectories([]string{source}) != nil { + return nil, ErrInvalid + } + name := strings.TrimPrefix(strings.TrimPrefix(source, "/workspace"), "/") + if name == "" { + name = "." + } + info, err := workspace.Lstat(name) + if err != nil || !info.IsDir() { + return nil, ErrInvalid + } + return workspace.OpenRoot(name) + } +} + +func testArchive(t *testing.T, files map[string][]byte) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + for name, body := range files { + file, err := writer.Create("package/" + name) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write(body); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} + +func TestBundleAndLocalDirectoryInventoriesAgree(t *testing.T) { + for _, plugin := range []bool{false, true} { + name := "skill" + if plugin { + name = "plugin" + } + t.Run(name, func(t *testing.T) { + bundled, local, source := openTestRoot(t), openTestRoot(t), openTestRoot(t) + files := map[string][]byte{"SKILL.md": skillManifest("proof"), "resource.txt": []byte("preserved")} + skill := agentskill.Metadata{Type: "inline", Name: "proof", Description: "Check proof."} + input := Input{Skills: []agentskill.Metadata{skill}} + if plugin { + files = map[string][]byte{ + ".codex-plugin/plugin.json": []byte(`{"name":"package","description":"Package proof.","skills":"./skills","mcpServers":"./.mcp.json"}`), + ".mcp.json": []byte(`{"mcpServers":{"proof":{"command":"python3","args":["proof.py"]}}}`), + "skills/proof/SKILL.md": skillManifest("proof"), + "proof.py": []byte("# Preserved server.\n"), + } + metadata := agentplugin.Metadata{Type: "inline", Name: "package", Description: "Package proof."} + input = Input{Plugins: []agentplugin.Metadata{metadata}} + if err := InstallPlugin(bundled, 0, testArchive(t, files), metadata); err != nil { + t.Fatal(err) + } + } else if err := InstallSkill(bundled, testArchive(t, files), skill); err != nil { + t.Fatal(err) + } + for path, content := range files { + writeWorkspace(t, source, path, content) + } + localInput := Input{Directories: []string{"/caller/capabilities"}} + resolve := func(path string) (*os.Root, error) { + if path != "/caller/capabilities" { + t.Fatalf("unexpected source: %s", path) + } + return source.OpenRoot(".") + } + if err := Finalize(bundled, input, testIdentity, nil); err != nil { + t.Fatal(err) + } + if err := Finalize(local, localInput, testIdentity, resolve); err != nil { + t.Fatal(err) + } + left, err := Load(bundled) + if err != nil { + t.Fatal(err) + } + right, err := Load(local) + if err != nil { + t.Fatal(err) + } + if len(left.Skills) != 1 || len(right.Skills) != 1 || left.Skills[0].Metadata != right.Skills[0].Metadata { + t.Fatal("Skill inventories diverged") + } + if len(left.MCP) != len(right.MCP) { + t.Fatal("MCP inventories diverged") + } + for i := range left.MCP { + if !reflect.DeepEqual(left.MCP[i].Server, right.MCP[i].Server) { + t.Fatal("MCP declarations diverged") + } + } + if ValidateSelection(left, input, testIdentity) != nil || ValidateSelection(right, localInput, testIdentity) != nil { + t.Fatal("matching selection rejected") + } + }) + } +} + +func TestSnapshotIdentityAndSourceLifetime(t *testing.T) { + source, first, second := openTestRoot(t), openTestRoot(t), openTestRoot(t) + writeWorkspace(t, source, "SKILL.md", skillManifest("proof")) + writeWorkspace(t, source, "resource.txt", []byte("first")) + input := Input{Directories: []string{"/caller/source"}} + resolve := func(string) (*os.Root, error) { return source.OpenRoot(".") } + if err := Finalize(first, input, testIdentity, resolve); err != nil { + t.Fatal(err) + } + writeWorkspace(t, source, "resource.txt", []byte("second")) + before, err := Load(first) + if err != nil { + t.Fatal(err) + } + if content, err := first.ReadFile("directories/0/resource.txt"); err != nil || string(content) != "first" { + t.Fatal("old snapshot followed source edit") + } + next := testIdentity + next.SessionID = "a1000000-0000-4000-8000-000000000003" + for _, scenario := range []string{"session", "environment", "selection"} { + identity, selection := testIdentity, input + switch scenario { + case "session": + identity = next + case "environment": + identity.EnvironmentID = next.SessionID + case "selection": + selection.Directories = []string{"/caller/other"} + } + if !errors.Is(ValidateSelection(before, selection, identity), ErrInvalid) { + t.Fatalf("%s mismatch accepted", scenario) + } + } + if err := Finalize(second, input, next, resolve); err != nil { + t.Fatal(err) + } + if content, err := second.ReadFile("directories/0/resource.txt"); err != nil || string(content) != "second" { + t.Fatal("new Session did not capture new source bytes") + } +} + +func TestEmptySnapshotAndRequiredBinding(t *testing.T) { + installed := openTestRoot(t) + if err := Finalize(installed, Input{}, testIdentity, nil); err != nil { + t.Fatal(err) + } + manifest, err := Load(installed) + if err != nil || len(manifest.Skills) != 0 || len(manifest.MCP) != 0 { + t.Fatalf("empty snapshot: %+v %v", manifest, err) + } + if ValidateSelection(manifest, Input{Skills: []agentskill.Metadata{}, Plugins: []agentplugin.Metadata{}, Directories: []string{}}, testIdentity) != nil { + t.Fatal("nil/empty selections differ") + } + for _, scenario := range []string{"identity", "hash", "hash-uppercase", "old-manifest"} { + t.Run(scenario, func(t *testing.T) { + invalid := manifest + switch scenario { + case "identity": + invalid.Identity = Identity{} + case "hash": + invalid.SelectionSHA256 = "bad" + case "hash-uppercase": + invalid.SelectionSHA256 = strings.ToUpper(invalid.SelectionSHA256) + case "old-manifest": + invalid.Identity, invalid.SelectionSHA256 = Identity{}, "" + } + root := openTestRoot(t) + data, err := json.Marshal(invalid) + if err != nil { + t.Fatal(err) + } + if err := root.WriteFile(ManifestName, data, 0400); err != nil { + t.Fatal(err) + } + if _, err := Load(root); !errors.Is(err, ErrInvalid) { + t.Fatal("invalid binding loaded") + } + }) + } + skill := agentskill.Metadata{Type: "inline", Name: "proof", Description: "Check proof."} + if InstallSkill(installed, testArchive(t, map[string][]byte{"SKILL.md": skillManifest("proof")}), skill) == nil { + t.Fatal("completed snapshot accepted a late import") + } +} + +func TestPartialAndOversizedSnapshotsFailClosed(t *testing.T) { + for _, scenario := range []string{"temporary-manifest", "partial-directory", "unexpected-bundle", "missing-bundle", "oversized"} { + t.Run(scenario, func(t *testing.T) { + source, installed := openTestRoot(t), openTestRoot(t) + writeWorkspace(t, source, "SKILL.md", skillManifest("proof")) + input := Input{Directories: []string{"/caller/source"}} + switch scenario { + case "temporary-manifest": + writeWorkspace(t, installed, ManifestName+".tmp", []byte("partial")) + case "partial-directory": + writeWorkspace(t, installed, "directories/0/partial", []byte("partial")) + case "unexpected-bundle": + writeWorkspace(t, installed, "skills/other/SKILL.md", skillManifest("other")) + case "missing-bundle": + input.Skills = []agentskill.Metadata{{Type: "inline", Name: "missing", Description: "Check proof."}} + case "oversized": + file, err := source.OpenFile("large", os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + if err := file.Truncate(agentbundle.MaxExpandedBytes + 1); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + } + calls := 0 + resolve := func(string) (*os.Root, error) { calls++; return source.OpenRoot(".") } + if err := Finalize(installed, input, testIdentity, resolve); !errors.Is(err, ErrInvalid) { + t.Fatal("invalid snapshot finalized") + } + if _, err := Load(installed); !errors.Is(err, ErrInvalid) { + t.Fatal("partial installation loaded") + } + if _, err := installed.Lstat(ManifestName); !os.IsNotExist(err) { + t.Fatal("failure published installed.json") + } + if scenario == "partial-directory" && calls != 0 { + t.Fatal("partial installation recaptured sources") + } + }) + } +} + +func TestSourceValidationKeepsManagedBoundary(t *testing.T) { + if ValidateLocalDirectories([]string{"/home/caller/skills"}) != nil || ValidateDirectories([]string{"/home/caller/skills"}) == nil { + t.Fatal("local spelling and managed access policy were conflated") + } + for _, sources := range [][]string{{"relative"}, {"/one/../two"}, {"/one", "/one"}, {"/one\\two"}, {"/one\n"}} { + if ValidateLocalDirectories(sources) == nil { + t.Fatalf("invalid source accepted: %q", sources) + } + } +} + +func TestInstallerRejectsSourceRootSymlinksAndInvalidSkills(t *testing.T) { + source, installed := openTestRoot(t), openTestRoot(t) + writeWorkspace(t, source, "real/SKILL.md", skillManifest("proof")) + if err := source.Symlink("real", "alias"); err != nil { + t.Fatal(err) + } + if Finalize(installed, Input{Directories: []string{"/workspace/alias"}}, testIdentity, workspaceResolver(source)) == nil { + t.Fatal("source alias accepted") + } + metadata := agentskill.Metadata{Type: "inline", Name: "proof", Description: "Check proof."} + if InstallSkill(installed, testArchive(t, map[string][]byte{"SKILL.md": skillManifest("different")}), metadata) == nil { + t.Fatal("mismatched Skill metadata installed") + } + if _, err := installed.Lstat("skills"); !os.IsNotExist(err) { + t.Fatal("invalid Skill wrote installation data") + } +} + +func TestInstallRejectsAliasedParent(t *testing.T) { + root := openTestRoot(t) + if err := root.Mkdir("other", 0700); err != nil { + t.Fatal(err) + } + if err := root.Symlink("other", "skills"); err != nil { + t.Fatal(err) + } + metadata := agentskill.Metadata{Type: "inline", Name: "proof", Description: "Check proof."} + if InstallSkill(root, testArchive(t, map[string][]byte{"SKILL.md": skillManifest("proof")}), metadata) == nil { + t.Fatal("aliased installation parent accepted") + } + if _, err := root.Lstat("other/proof"); !os.IsNotExist(err) { + t.Fatal("installer wrote through aliased parent") + } +} + +func TestFailedDirectoryCaptureCannotBeReplayed(t *testing.T) { + source, installed := openTestRoot(t), openTestRoot(t) + writeWorkspace(t, source, "first/SKILL.md", skillManifest("first")) + input := Input{Directories: []string{"/workspace/first", "/workspace/second"}} + if Finalize(installed, input, testIdentity, workspaceResolver(source)) == nil { + t.Fatal("missing second source accepted") + } + writeWorkspace(t, source, "first/SKILL.md", skillManifest("changed")) + writeWorkspace(t, source, "second/SKILL.md", skillManifest("second")) + if Finalize(installed, input, testIdentity, workspaceResolver(source)) == nil { + t.Fatal("partial capture was silently retried") + } + body, err := installed.ReadFile("directories/0/SKILL.md") + if err != nil || !bytes.Equal(body, skillManifest("first")) { + t.Fatal("partial capture was replaced") + } +} + +func TestSelectionDigestKeepsSourceOrderAndMetadata(t *testing.T) { + input := Input{Directories: []string{"/one", "/two"}, Skills: []agentskill.Metadata{{Type: "inline", Name: "proof", Description: "Check proof."}}} + digest, err := selectionHash(input) + if err != nil { + t.Fatal(err) + } + manifest := Manifest{Version: 1, Identity: testIdentity, SelectionSHA256: digest} + swapped := input + swapped.Directories = []string{"/two", "/one"} + changed := input + changed.Skills = []agentskill.Metadata{{Type: "inline", Name: "proof", Description: "Changed."}} + for _, selection := range []Input{swapped, changed} { + if ValidateSelection(manifest, selection, testIdentity) == nil { + t.Fatal("changed selection accepted") + } + } +} diff --git a/internal/agentcapabilities/tree.go b/internal/agentcapabilities/tree.go index a1709f4a8..61ace6284 100644 --- a/internal/agentcapabilities/tree.go +++ b/internal/agentcapabilities/tree.go @@ -72,7 +72,7 @@ func ReadTree(root *os.Root, name string, immutable bool) ([]agentbundle.File, e // writeTree only creates a fresh owned destination. Uncertain writes are never // retried here; the existing Core initialization owner decides cleanup. func writeTree(root *os.Root, name string, files []agentbundle.File) error { - if !validRelative(name) || root.MkdirAll(path.Dir(name), 0700) != nil || root.Mkdir(name, 0700) != nil { + if !validRelative(name) || makeDirectories(root, path.Dir(name)) != nil || root.Mkdir(name, 0700) != nil { return ErrInvalid } directory, err := root.OpenRoot(name) @@ -81,7 +81,7 @@ func writeTree(root *os.Root, name string, files []agentbundle.File) error { } defer directory.Close() for _, file := range files { - if !validRelative(file.Path) || directory.MkdirAll(path.Dir(file.Path), 0700) != nil { + if !validRelative(file.Path) || makeDirectories(directory, path.Dir(file.Path)) != nil { return ErrInvalid } mode := fs.FileMode(0400) @@ -135,3 +135,23 @@ func syncDirectory(root *os.Root, name string) error { } return nil } + +// Create only real directory parents. os.Root prevents escapes, while these +// checks also reject aliases that remain inside the protected installation. +func makeDirectories(root *os.Root, name string) error { + if name == "." { + return nil + } + current := "" + for _, component := range strings.Split(name, "/") { + current = path.Join(current, component) + if err := root.Mkdir(current, 0700); err != nil && !os.IsExist(err) { + return ErrInvalid + } + info, err := root.Lstat(current) + if err != nil || !info.IsDir() { + return ErrInvalid + } + } + return nil +} diff --git a/internal/agentdaemon/gateway/cancellation_test.go b/internal/agentdaemon/gateway/cancellation_test.go index 1f0caa264..4955d8b78 100644 --- a/internal/agentdaemon/gateway/cancellation_test.go +++ b/internal/agentdaemon/gateway/cancellation_test.go @@ -58,7 +58,7 @@ func TestArchivedReceiptTracksDeliveryBeyondDoneAndRejectsNewWork(t *testing.T) if _, err := peer.TrackExecutionDelivery("new-run"); err == nil { t.Fatal("new delivery admitted while draining") } - for _, kind := range []string{proto.TypePromptRequest, proto.TypePromptSteer, proto.TypeExecutionPrepare, proto.TypeWorkspaceWrite, proto.TypeWorkspaceRead} { + for _, kind := range []string{proto.TypePromptRequest, proto.TypePromptSteer, proto.TypeExecutionPrepare, proto.TypeCapabilitiesPrepare, proto.TypeWorkspaceWrite, proto.TypeWorkspaceRead} { env, _ := proto.NewEnvelope(kind, "run", nil) if err := peer.Send(t.Context(), env); err == nil { t.Fatal("drain permitted new operation", kind) diff --git a/internal/agentdaemon/gateway/capabilities.go b/internal/agentdaemon/gateway/capabilities.go new file mode 100644 index 000000000..b0f641d19 --- /dev/null +++ b/internal/agentdaemon/gateway/capabilities.go @@ -0,0 +1,111 @@ +package gateway + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// PrepareCapabilities transfers one archive or finalization selection once. An +// interrupted or unconfirmed transfer reports unknown; callers must not replay it. +func (s *Session) PrepareCapabilities(ctx context.Context, id string, request proto.CapabilitiesPreparePayload, data []byte) (proto.CapabilitiesResultPayload, error) { + unknown := proto.CapabilitiesResultPayload{Outcome: "unknown", ErrorCode: "capabilities_unconfirmed"} + parsed, err := uuid.Parse(id) + if err != nil || parsed == uuid.Nil || parsed.String() != id || len(data) > proto.CapabilitiesMaxBytes || + (request.Step != "" && request.Step != "begin") { + return unknown, errors.New("agentdaemon gateway: invalid capability preparation") + } + request.Step = "begin" + if request.Action == "finalize" { + if len(data) != 0 { + return unknown, errors.New("agentdaemon gateway: invalid capability finalization") + } + } else { + digest := sha256.Sum256(data) + expected := hex.EncodeToString(digest[:]) + if (request.SizeBytes != 0 && request.SizeBytes != len(data)) || (request.SHA256 != "" && request.SHA256 != expected) { + return unknown, errors.New("agentdaemon gateway: capability archive mismatch") + } + request.SizeBytes, request.SHA256 = len(data), expected + } + if !proto.ValidCapabilitiesPrepareRequest(request) { + return unknown, errors.New("agentdaemon gateway: invalid capability preparation") + } + s.capabilitiesMu.Lock() + if s.IsClosed() { + s.capabilitiesMu.Unlock() + return unknown, ErrSessionClosed + } + if len(s.capabilities) != 0 { + s.capabilitiesMu.Unlock() + return unknown, errors.New("agentdaemon gateway: capability preparation capacity") + } + replies := make(chan proto.Envelope, 1) + s.capabilities = map[string]chan proto.Envelope{id: replies} + s.capabilitiesMu.Unlock() + defer func() { s.capabilitiesMu.Lock(); delete(s.capabilities, id); s.capabilitiesMu.Unlock() }() + ctx, cancel := context.WithTimeout(ctx, 195*time.Second) + defer cancel() + exchange := func(payload proto.CapabilitiesPreparePayload, outcome string, offset int) (proto.CapabilitiesResultPayload, error) { + env, err := proto.NewEnvelope(proto.TypeCapabilitiesPrepare, id, payload) + if err != nil { + return unknown, errors.New("agentdaemon gateway: invalid capability frame") + } + encoded, err := json.Marshal(env) + if err != nil || len(encoded) > proto.CapabilitiesMaxFrameBytes { + return unknown, errors.New("agentdaemon gateway: invalid capability frame") + } + reply, err := s.exchangeChunkFrame(ctx, env, replies) + if err != nil { + return unknown, err + } + var result proto.CapabilitiesResultPayload + if len(reply.Payload) > proto.CapabilitiesMaxFrameBytes || reply.DecodePayload(&result) != nil || + !proto.ValidCapabilitiesResult(result, outcome, offset, len(data)) { + return unknown, errors.New("agentdaemon gateway: invalid capability receipt") + } + if result.Outcome == "unknown" { + return result, errors.New("agentdaemon gateway: capability preparation unconfirmed") + } + return result, nil + } + result, err := exchange(request, "ready", 0) + if err != nil || result.Outcome != "ready" { + return result, err + } + for offset := 0; offset < len(data); { + end := min(offset+proto.CapabilitiesChunkBytes, len(data)) + result, err = exchange(proto.CapabilitiesPreparePayload{Step: "chunk", Offset: offset, Data: data[offset:end]}, "received", end) + if err != nil || result.Outcome != "received" { + return result, err + } + offset = end + } + return exchange(proto.CapabilitiesPreparePayload{Step: "commit"}, "completed", 0) +} + +func (s *Session) dispatchCapabilities(env proto.Envelope) { + s.capabilitiesMu.Lock() + defer s.capabilitiesMu.Unlock() + if replies := s.capabilities[env.ID]; replies != nil { + select { + case replies <- env: + default: + } + } +} + +func (s *Session) closeCapabilities() { + s.capabilitiesMu.Lock() + defer s.capabilitiesMu.Unlock() + for id, replies := range s.capabilities { + close(replies) + delete(s.capabilities, id) + } +} diff --git a/internal/agentdaemon/gateway/capabilities_test.go b/internal/agentdaemon/gateway/capabilities_test.go new file mode 100644 index 000000000..cb15e17e3 --- /dev/null +++ b/internal/agentdaemon/gateway/capabilities_test.go @@ -0,0 +1,234 @@ +package gateway + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/google/uuid" +) + +func skillPreparation() proto.CapabilitiesPreparePayload { + return proto.CapabilitiesPreparePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "skill", + Skill: &agentskill.Metadata{Type: "inline", Name: "example", Description: "Example"}} +} + +type capabilityOutcome struct { + result proto.CapabilitiesResultPayload + err error +} + +func beginCapabilities(s *Session, ctx context.Context, id string, request proto.CapabilitiesPreparePayload, data []byte) <-chan capabilityOutcome { + done := make(chan capabilityOutcome, 1) + go func() { r, err := s.PrepareCapabilities(ctx, id, request, data); done <- capabilityOutcome{r, err} }() + return done +} +func nextCapabilityFrame(t *testing.T, s *Session) proto.Envelope { + t.Helper() + select { + case env := <-s.sendCh: + return env + case <-time.After(3 * time.Second): + t.Fatal("transfer stopped unexpectedly") + return proto.Envelope{} + } +} +func finishCapabilities(t *testing.T, done <-chan capabilityOutcome) capabilityOutcome { + t.Helper() + select { + case result := <-done: + return result + case <-time.After(3 * time.Second): + t.Fatal("transfer did not settle") + return capabilityOutcome{} + } +} +func replyCapabilities(s *Session, id string, result proto.CapabilitiesResultPayload) { + reply, _ := proto.NewEnvelope(proto.TypeCapabilitiesResult, id, result) + s.dispatch(reply) +} +func noCapabilityFrame(t *testing.T, s *Session) { + t.Helper() + select { + case env := <-s.sendCh: + t.Fatal("unexpected extra frame", env.Type) + default: + } +} + +func TestCapabilitiesTransfersMoreThanFrameLimitAndCorrelates(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + data := bytes.Repeat([]byte{0, 255, 3}, 2<<20) + id := uuid.NewString() + request := skillPreparation() + done := beginCapabilities(s, t.Context(), id, request, data) + var received []byte + for { + env := nextCapabilityFrame(t, s) + encoded, err := json.Marshal(env) + var p proto.CapabilitiesPreparePayload + if err != nil || len(encoded) > proto.CapabilitiesMaxFrameBytes || env.ID != id || env.Type != proto.TypeCapabilitiesPrepare || env.DecodePayload(&p) != nil || !proto.ValidCapabilitiesPrepareRequest(p) { + t.Fatal("invalid frame") + } + result := proto.CapabilitiesResultPayload{} + switch p.Step { + case "begin": + digest := sha256.Sum256(data) + if p.EnvironmentID != request.EnvironmentID || p.SessionID != request.SessionID || p.Skill == nil || *p.Skill != *request.Skill || p.SizeBytes != len(data) || p.SHA256 != hex.EncodeToString(digest[:]) { + t.Fatal("identity, metadata or digest changed") + } + result.Outcome = "ready" + case "chunk": + if p.Offset != len(received) { + t.Fatal("noncontiguous chunks") + } + received = append(received, p.Data...) + result.Outcome, result.Offset = "received", len(received) + case "commit": + if !bytes.Equal(received, data) { + t.Fatal("archive changed") + } + result.Outcome, result.SizeBytes = "completed", len(data) + } + replyCapabilities(s, uuid.NewString(), result) + replyCapabilities(s, id, result) + if p.Step == "commit" { + break + } + } + result := finishCapabilities(t, done) + if result.err != nil || result.result.Outcome != "completed" || result.result.SizeBytes != len(data) { + t.Fatal(result) + } + noCapabilityFrame(t, s) +} + +func TestCapabilitiesFinalizeTransfersNoArchive(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + request := proto.CapabilitiesPreparePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "finalize", Sources: &agentcapabilities.Input{}} + id := uuid.NewString() + done := beginCapabilities(s, t.Context(), id, request, nil) + env := nextCapabilityFrame(t, s) + var p proto.CapabilitiesPreparePayload + if env.DecodePayload(&p) != nil || p.Step != "begin" || p.Sources == nil || p.SizeBytes != 0 || p.SHA256 != "" { + t.Fatal("invalid finalization", p) + } + replyCapabilities(s, id, proto.CapabilitiesResultPayload{Outcome: "ready"}) + env = nextCapabilityFrame(t, s) + if env.DecodePayload(&p) != nil || p.Step != "commit" { + t.Fatal("finalization sent archive") + } + replyCapabilities(s, id, proto.CapabilitiesResultPayload{Outcome: "completed"}) + if result := finishCapabilities(t, done); result.err != nil || result.result.Outcome != "completed" { + t.Fatal(result) + } +} + +func TestCapabilitiesRefusesConflictingArchiveBeforeSending(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + for _, mutate := range []func(*proto.CapabilitiesPreparePayload){ + func(p *proto.CapabilitiesPreparePayload) { p.SHA256 = "incorrect" }, + func(p *proto.CapabilitiesPreparePayload) { p.SizeBytes = 999 }, + func(p *proto.CapabilitiesPreparePayload) { p.Sources = &agentcapabilities.Input{} }, + } { + request := skillPreparation() + mutate(&request) + if result, err := s.PrepareCapabilities(t.Context(), uuid.NewString(), request, []byte("data")); err == nil || result.Outcome != "unknown" { + t.Fatal("conflict admitted", result, err) + } + noCapabilityFrame(t, s) + } +} + +func TestCapabilitiesStopsAtTerminalOrMalformedReceipt(t *testing.T) { + for name, receipt := range map[string]proto.CapabilitiesResultPayload{ + "rejected": {Outcome: "rejected", ErrorCode: "capabilities_rejected"}, + "failed": {Outcome: "failed", ErrorCode: "capabilities_failed"}, + "unknown": {Outcome: "unknown", ErrorCode: "capabilities_unconfirmed"}, + "premature completed": {Outcome: "completed", SizeBytes: 4}, + "wrong offset": {Outcome: "ready", Offset: 1}, + "unsafe code": {Outcome: "rejected", ErrorCode: "private detail"}, + } { + t.Run(name, func(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + id := uuid.NewString() + done := beginCapabilities(s, t.Context(), id, skillPreparation(), []byte("data")) + nextCapabilityFrame(t, s) + replyCapabilities(s, id, receipt) + result := finishCapabilities(t, done) + if name == "rejected" || name == "failed" { + if result.err != nil || result.result != receipt { + t.Fatal(result) + } + } else if result.err == nil || result.result.Outcome != "unknown" { + t.Fatal("uncertainty lost", result) + } + noCapabilityFrame(t, s) + }) + } +} + +func TestCapabilitiesRejectsWrongChunkReceipt(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + id := uuid.NewString() + done := beginCapabilities(s, t.Context(), id, skillPreparation(), []byte("data")) + nextCapabilityFrame(t, s) + replyCapabilities(s, id, proto.CapabilitiesResultPayload{Outcome: "ready"}) + nextCapabilityFrame(t, s) + replyCapabilities(s, id, proto.CapabilitiesResultPayload{Outcome: "received", Offset: 3}) + result := finishCapabilities(t, done) + if result.err == nil || result.result.Outcome != "unknown" { + t.Fatal(result) + } + noCapabilityFrame(t, s) +} + +func TestCapabilitiesConnectionOwnershipAndUnknownInterruption(t *testing.T) { + for _, closeConnection := range []bool{false, true} { + t.Run(map[bool]string{false: "deadline", true: "disconnect"}[closeConnection], func(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + ctx, cancel := context.WithTimeout(t.Context(), 150*time.Millisecond) + defer cancel() + done := beginCapabilities(s, ctx, uuid.NewString(), skillPreparation(), []byte("data")) + nextCapabilityFrame(t, s) + if _, err := s.PrepareCapabilities(t.Context(), uuid.NewString(), skillPreparation(), []byte("second")); err == nil { + t.Fatal("concurrent transfer admitted") + } + if closeConnection { + s.Close("lost connection") + } + result := finishCapabilities(t, done) + if result.result.Outcome != "unknown" || result.result.ErrorCode != "capabilities_unconfirmed" { + t.Fatal("interruption claimed rejection", result) + } + expected := error(context.DeadlineExceeded) + if closeConnection { + expected = ErrSessionClosed + } + if !errors.Is(result.err, expected) { + t.Fatal(result.err) + } + noCapabilityFrame(t, s) + s.capabilitiesMu.Lock() + remaining := len(s.capabilities) + s.capabilitiesMu.Unlock() + if remaining != 0 { + t.Fatal("transfer ownership retained") + } + }) + } +} diff --git a/internal/agentdaemon/gateway/chunk_exchange.go b/internal/agentdaemon/gateway/chunk_exchange.go new file mode 100644 index 000000000..b7bb2058e --- /dev/null +++ b/internal/agentdaemon/gateway/chunk_exchange.go @@ -0,0 +1,26 @@ +package gateway + +import ( + "context" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// exchangeChunkFrame sends once and waits for the owning transfer's next receipt. +// Callers validate their own frame and receipt; no transport outcome is retried. +func (s *Session) exchangeChunkFrame(ctx context.Context, env proto.Envelope, replies <-chan proto.Envelope) (proto.Envelope, error) { + if err := s.Send(ctx, env); err != nil { + return proto.Envelope{}, err + } + select { + case reply, ok := <-replies: + if !ok { + return proto.Envelope{}, ErrSessionClosed + } + return reply, nil + case <-ctx.Done(): + return proto.Envelope{}, ctx.Err() + case <-s.closed: + return proto.Envelope{}, ErrSessionClosed + } +} diff --git a/internal/agentdaemon/gateway/session.go b/internal/agentdaemon/gateway/session.go index 7e79ddfdb..1da27c584 100644 --- a/internal/agentdaemon/gateway/session.go +++ b/internal/agentdaemon/gateway/session.go @@ -107,6 +107,8 @@ type Session struct { subs map[string]*Subscription preparationMu sync.Mutex preparations map[string]*preparationSubscription + capabilitiesMu sync.Mutex + capabilities map[string]chan proto.Envelope workspaceWriteMu sync.Mutex workspaceWrites map[string]chan proto.Envelope suspendMu sync.Mutex @@ -273,6 +275,7 @@ func (s *Session) Close(reason string) { s.closeWorkspaceReads() s.closeSuspendReplies() s.closeWorkspaceWrites() + s.closeCapabilities() s.closeWorkspaceExports() s.markOfflineOnClose() s.releaseOwnerLease() @@ -605,6 +608,9 @@ func (s *Session) dispatch(env proto.Envelope) { case proto.TypeWorkspaceExportResult: s.dispatchWorkspaceExport(env) return + case proto.TypeCapabilitiesResult: + s.dispatchCapabilities(env) + return case proto.TypeWorkspaceWriteResult: s.dispatchWorkspaceWrite(env) case proto.TypeWorkspaceReadResult: diff --git a/internal/agentdaemon/gateway/workspace_write.go b/internal/agentdaemon/gateway/workspace_write.go index d614acfb3..b2ee6799f 100644 --- a/internal/agentdaemon/gateway/workspace_write.go +++ b/internal/agentdaemon/gateway/workspace_write.go @@ -44,24 +44,15 @@ func (s *Session) WriteWorkspaceFile(ctx context.Context, id string, request pro if err != nil || len(env.Payload) > proto.WorkspaceWriteMaxFrameBytes { return empty, errors.New("agentdaemon gateway: invalid write frame") } - if err := s.Send(ctx, env); err != nil { + reply, err := s.exchangeChunkFrame(ctx, env, replies) + if err != nil { return empty, err } - select { - case env, ok := <-replies: - if !ok { - return empty, ErrSessionClosed - } - var result proto.WorkspaceWriteResultPayload - if env.DecodePayload(&result) != nil || !validWorkspaceWriteResult(result, outcome, offset, len(data)) { - return empty, errors.New("agentdaemon gateway: invalid write receipt") - } - return result, nil - case <-ctx.Done(): - return empty, ctx.Err() - case <-s.closed: - return empty, ErrSessionClosed + var result proto.WorkspaceWriteResultPayload + if reply.DecodePayload(&result) != nil || !validWorkspaceWriteResult(result, outcome, offset, len(data)) { + return empty, errors.New("agentdaemon gateway: invalid write receipt") } + return result, nil } result, err := exchange(request, "ready", 0) if err != nil || result.Outcome != "ready" { diff --git a/internal/agentdaemon/proto/capabilities.go b/internal/agentdaemon/proto/capabilities.go new file mode 100644 index 000000000..e12db9a12 --- /dev/null +++ b/internal/agentdaemon/proto/capabilities.go @@ -0,0 +1,132 @@ +package proto + +import ( + "encoding/hex" + "encoding/json" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/google/uuid" +) + +const ( + TypeCapabilitiesPrepare = "capabilities_prepare" + TypeCapabilitiesResult = "capabilities_result" + CapabilitiesMaxBytes = 50 << 20 + CapabilitiesChunkBytes = WorkspaceWriteChunkBytes + CapabilitiesMaxFrameBytes = 1 << 20 +) + +// CapabilitiesPreparePayload imports an inert archive or finalizes frozen source +// selections. Runtime owns all destinations; this protocol supplies no command or +// installation path. Envelope.ID identifies one connection-local transfer. +type CapabilitiesPreparePayload struct { + Step string `json:"step"` + EnvironmentID string `json:"environment_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + Action string `json:"action,omitempty"` + Slot int `json:"slot,omitempty"` + Skill *agentskill.Metadata `json:"skill,omitempty"` + Plugin *agentplugin.Metadata `json:"plugin,omitempty"` + Sources *agentcapabilities.Input `json:"sources,omitempty"` + SizeBytes int `json:"size_bytes,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Offset int `json:"offset,omitempty"` + Data []byte `json:"data,omitempty"` +} + +type CapabilitiesResultPayload struct { + Outcome string `json:"outcome"` + Offset int `json:"offset,omitempty"` + SizeBytes int `json:"size_bytes,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +func ValidCapabilitiesPrepareRequest(p CapabilitiesPreparePayload) bool { + if p.Step == "begin" { + for _, id := range []string{p.EnvironmentID, p.SessionID} { + value, err := uuid.Parse(id) + if err != nil || value == uuid.Nil || value.String() != id { + return false + } + } + if p.Offset != 0 || len(p.Data) != 0 { + return false + } + switch p.Action { + case "skill": + if p.Skill == nil || p.Plugin != nil || p.Sources != nil || p.Slot != 0 || + agentcapabilities.ValidateInput(agentcapabilities.Input{Skills: []agentskill.Metadata{*p.Skill}}) != nil { + return false + } + case "plugin": + if p.Plugin == nil || p.Skill != nil || p.Sources != nil || p.Slot < 0 || p.Slot >= 50 || + agentcapabilities.ValidateInput(agentcapabilities.Input{Plugins: []agentplugin.Metadata{*p.Plugin}}) != nil { + return false + } + case "finalize": + if p.Skill != nil || p.Plugin != nil || p.Sources == nil || p.Slot != 0 || + p.SizeBytes != 0 || p.SHA256 != "" || agentcapabilities.ValidateInput(*p.Sources) != nil { + return false + } + default: + return false + } + if p.Action != "finalize" { + digest, err := hex.DecodeString(p.SHA256) + if err != nil || len(digest) != 32 || strings.ToLower(p.SHA256) != p.SHA256 || + p.SizeBytes <= 0 || p.SizeBytes > CapabilitiesMaxBytes { + return false + } + } + encoded, err := json.Marshal(p) + return err == nil && len(encoded) <= CapabilitiesMaxFrameBytes + } + if p.EnvironmentID != "" || p.SessionID != "" || p.Action != "" || p.Slot != 0 || + p.Skill != nil || p.Plugin != nil || p.Sources != nil || p.SizeBytes != 0 || p.SHA256 != "" { + return false + } + switch p.Step { + case "chunk": + return p.Offset >= 0 && p.Offset <= CapabilitiesMaxBytes && len(p.Data) > 0 && + len(p.Data) <= CapabilitiesChunkBytes && len(p.Data) <= CapabilitiesMaxBytes-p.Offset + case "commit": + return p.Offset == 0 && len(p.Data) == 0 + default: + return false + } +} + +// ValidCapabilitiesResult accepts only the expected success receipt or a finite +// terminal category. An unknown effect can never be represented as rejection. +func ValidCapabilitiesResult(r CapabilitiesResultPayload, expected string, offset, size int) bool { + switch r.Outcome { + case "rejected": + if r.Offset != 0 || r.SizeBytes != 0 { + return false + } + switch r.ErrorCode { + case "invalid_request", "resource_unavailable", "capabilities_capacity", "capabilities_unsupported", "capabilities_rejected": + return true + default: + return false + } + case "failed": + return r.Offset == 0 && r.SizeBytes == 0 && r.ErrorCode == "capabilities_failed" + case "unknown": + return r.Offset == 0 && r.SizeBytes == 0 && r.ErrorCode == "capabilities_unconfirmed" + } + if r.Outcome != expected || r.Offset != offset || r.ErrorCode != "" { + return false + } + switch expected { + case "completed": + return r.SizeBytes == size + case "ready", "received": + return r.SizeBytes == 0 + default: + return false + } +} diff --git a/internal/agentdaemon/proto/capabilities_test.go b/internal/agentdaemon/proto/capabilities_test.go new file mode 100644 index 000000000..e528e1767 --- /dev/null +++ b/internal/agentdaemon/proto/capabilities_test.go @@ -0,0 +1,173 @@ +package proto + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/google/uuid" +) + +func capabilityBegin() CapabilitiesPreparePayload { + return CapabilitiesPreparePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), + Action: "skill", Skill: &agentskill.Metadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 10, SHA256: strings.Repeat("a", 64)} +} + +func TestCapabilitiesRequestValidation(t *testing.T) { + valid := capabilityBegin() + if !ValidCapabilitiesPrepareRequest(valid) { + t.Fatal("valid skill refused") + } + for name, mutate := range map[string]func(*CapabilitiesPreparePayload){ + "noncanonical identity": func(p *CapabilitiesPreparePayload) { p.EnvironmentID = "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA" }, + "zero identity": func(p *CapabilitiesPreparePayload) { p.SessionID = uuid.Nil.String() }, + "begin data": func(p *CapabilitiesPreparePayload) { p.Data = []byte("x") }, + "begin offset": func(p *CapabilitiesPreparePayload) { p.Offset = 1 }, + "mixed metadata": func(p *CapabilitiesPreparePayload) { + p.Plugin = &agentplugin.Metadata{Type: "inline", Name: "example", Description: "Example"} + }, + "mixed sources": func(p *CapabilitiesPreparePayload) { p.Sources = &agentcapabilities.Input{} }, + "missing metadata": func(p *CapabilitiesPreparePayload) { p.Skill = nil }, + "wrong metadata type": func(p *CapabilitiesPreparePayload) { + p.Skill = &agentskill.Metadata{Type: "reference", Name: "example", Description: "Example"} + }, + "missing description": func(p *CapabilitiesPreparePayload) { p.Skill = &agentskill.Metadata{Type: "inline", Name: "example"} }, + "oversized header": func(p *CapabilitiesPreparePayload) { + p.Skill = &agentskill.Metadata{Type: "inline", Name: "example", Description: strings.Repeat("x", CapabilitiesMaxFrameBytes)} + }, + "skill slot": func(p *CapabilitiesPreparePayload) { p.Slot = 1 }, + "no digest": func(p *CapabilitiesPreparePayload) { p.SHA256 = "" }, + "uppercase digest": func(p *CapabilitiesPreparePayload) { p.SHA256 = strings.ToUpper(p.SHA256) }, + "short digest": func(p *CapabilitiesPreparePayload) { p.SHA256 = "aa" }, + "no archive": func(p *CapabilitiesPreparePayload) { p.SizeBytes = 0 }, + "oversized archive": func(p *CapabilitiesPreparePayload) { p.SizeBytes = CapabilitiesMaxBytes + 1 }, + } { + t.Run(name, func(t *testing.T) { + p := valid + mutate(&p) + if ValidCapabilitiesPrepareRequest(p) { + t.Fatal("invalid begin accepted") + } + }) + } + plugin := valid + plugin.Action, plugin.Skill, plugin.Plugin, plugin.Slot = "plugin", nil, &agentplugin.Metadata{Type: "inline", Name: "example", Description: "Example"}, 49 + if !ValidCapabilitiesPrepareRequest(plugin) { + t.Fatal("valid plugin refused") + } + plugin.Slot = 50 + if ValidCapabilitiesPrepareRequest(plugin) { + t.Fatal("out of range slot accepted") + } + for _, p := range []CapabilitiesPreparePayload{ + {Step: "chunk", Offset: 0, Data: []byte("x")}, + {Step: "chunk", Offset: CapabilitiesMaxBytes - CapabilitiesChunkBytes, Data: make([]byte, CapabilitiesChunkBytes)}, + {Step: "commit"}, + } { + if !ValidCapabilitiesPrepareRequest(p) { + t.Fatal("valid continuation refused", p.Step) + } + } + for _, p := range []CapabilitiesPreparePayload{ + {Step: "chunk", Data: []byte("x"), Action: "skill"}, + {Step: "chunk", Data: []byte("x"), EnvironmentID: valid.EnvironmentID}, + {Step: "chunk", Data: []byte("x"), Skill: valid.Skill}, + {Step: "chunk", Data: []byte("x"), Offset: -1}, + {Step: "chunk", Data: []byte("x"), Offset: CapabilitiesMaxBytes}, + {Step: "chunk", Data: make([]byte, CapabilitiesChunkBytes+1)}, + {Step: "chunk"}, + {Step: "commit", Offset: 1}, + {Step: "commit", Data: []byte("x")}, + {Step: "commit", Sources: &agentcapabilities.Input{}}, + } { + if ValidCapabilitiesPrepareRequest(p) { + t.Fatal("invalid continuation accepted", p.Step) + } + } +} + +func TestCapabilitiesFinalizeRequiresExplicitBoundedSelection(t *testing.T) { + p := CapabilitiesPreparePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "finalize", Sources: &agentcapabilities.Input{}} + if !ValidCapabilitiesPrepareRequest(p) { + t.Fatal("explicit empty finalization refused") + } + for _, mutate := range []func(*CapabilitiesPreparePayload){ + func(p *CapabilitiesPreparePayload) { p.Sources = nil }, + func(p *CapabilitiesPreparePayload) { p.SHA256 = strings.Repeat("a", 64) }, + func(p *CapabilitiesPreparePayload) { p.SizeBytes = 1 }, + func(p *CapabilitiesPreparePayload) { + p.Sources = &agentcapabilities.Input{Directories: make([]string, 51)} + }, + func(p *CapabilitiesPreparePayload) { + p.Sources = &agentcapabilities.Input{Directories: []string{"/workspace/../secret"}} + }, + func(p *CapabilitiesPreparePayload) { + p.Sources = &agentcapabilities.Input{Directories: []string{"/workspace/a", "/workspace/a"}} + }, + } { + bad := p + mutate(&bad) + if ValidCapabilitiesPrepareRequest(bad) { + t.Fatal("invalid finalization accepted") + } + } +} + +func TestCapabilitiesResultCannotMisrepresentUncertainty(t *testing.T) { + for _, r := range []CapabilitiesResultPayload{ + {Outcome: "rejected", ErrorCode: "capabilities_unconfirmed"}, + {Outcome: "unknown", ErrorCode: "capabilities_rejected"}, + {Outcome: "failed", ErrorCode: "private detail"}, + {Outcome: "rejected", ErrorCode: "invalid_request", Offset: 1}, + {Outcome: "unknown", ErrorCode: "capabilities_unconfirmed", SizeBytes: 10}, + {Outcome: "completed", SizeBytes: 10}, + {Outcome: "ready", Offset: 1}, + {Outcome: "ready", SizeBytes: 10}, + {Outcome: "ready", ErrorCode: "invalid_request"}, + } { + if ValidCapabilitiesResult(r, "ready", 0, 10) { + t.Fatal("unsafe receipt accepted", r) + } + } + if ValidCapabilitiesResult(CapabilitiesResultPayload{Outcome: "received", Offset: 1}, "received", 2, 10) { + t.Fatal("wrong offset accepted") + } + if ValidCapabilitiesResult(CapabilitiesResultPayload{Outcome: "completed", SizeBytes: 9}, "completed", 0, 10) { + t.Fatal("wrong size accepted") + } + for _, r := range []CapabilitiesResultPayload{ + {Outcome: "rejected", ErrorCode: "invalid_request"}, + {Outcome: "failed", ErrorCode: "capabilities_failed"}, + {Outcome: "unknown", ErrorCode: "capabilities_unconfirmed"}, + {Outcome: "completed", SizeBytes: 10}, + } { + if !ValidCapabilitiesResult(r, "completed", 0, 10) { + t.Fatal("safe receipt refused", r) + } + } +} + +func TestLocalEnvironmentCarriesSelectionButNeverInstalledRoots(t *testing.T) { + input := &agentcapabilities.Input{Directories: []string{"/workspace/capabilities"}} + original := LocalEnvironment{ID: uuid.NewString(), WorkspaceDirectory: "/workspace", CapabilitySources: input, Capabilities: true, + Skills: []agentcapabilities.InstalledSkill{{RelativeRoot: "private-installed-root"}}, + MCP: []EnvironmentMCP{{PackageRoot: "private-mcp-root"}}} + raw, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "private-") { + t.Fatal("installed roots leaked to wire") + } + var decoded LocalEnvironment + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if decoded.ID != original.ID || decoded.WorkspaceDirectory != "/workspace" || !reflect.DeepEqual(decoded.CapabilitySources, input) || !decoded.Capabilities || len(decoded.Skills) != 0 || len(decoded.MCP) != 0 { + t.Fatal("selection round trip changed", decoded) + } +} diff --git a/internal/agentdaemon/proto/envelope_test.go b/internal/agentdaemon/proto/envelope_test.go index 5c3cd96f6..c7388daca 100644 --- a/internal/agentdaemon/proto/envelope_test.go +++ b/internal/agentdaemon/proto/envelope_test.go @@ -88,8 +88,8 @@ func TestVersionCompatible(t *testing.T) { ok bool }{ {Version, true}, // exact match - {"0.6.99", true}, // patch drift OK - {"0.5.99", false}, // minor drift NOT OK + {"0.7.99", true}, // patch drift OK + {"0.6.99", false}, // minor drift NOT OK {"1.0.0", false}, // major drift NOT OK {"", false}, // missing {"garbage", false}, // unparseable diff --git a/internal/agentdaemon/proto/environment.go b/internal/agentdaemon/proto/environment.go index 2ad6c4910..74772af6a 100644 --- a/internal/agentdaemon/proto/environment.go +++ b/internal/agentdaemon/proto/environment.go @@ -5,10 +5,15 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" ) -// LocalEnvironment references a deployment-bound workspace; it never supplies a path. +// LocalEnvironment names a frozen workspace selection. Runtime must verify it +// against the bound local root before resolving capabilities or native execution. type LocalEnvironment struct { - ID string `json:"id"` - // Capabilities requests the completed, protected Runtime installation. + CapabilityRoot string `json:"-"` + ID string `json:"id"` + WorkspaceDirectory string `json:"workspace_directory"` + CapabilitySources *agentcapabilities.Input `json:"capability_sources"` + // Capabilities is derived from the frozen selection for engine qualification; + // Runtime still ensures and loads the protected installation before execution. Capabilities bool `json:"capabilities,omitempty"` // Skills is resolved by the bound daemon; wire input cannot supply paths. Skills []agentcapabilities.InstalledSkill `json:"-"` @@ -26,9 +31,11 @@ type LocalEnvironment struct { // EnvironmentMCP is transient Runtime configuration. Do not log it: HTTP headers // and the selected user bearer may be confidential. It is not agent.tools MCP. type EnvironmentMCP struct { - PackageRoot string - Server agentplugin.MCPServer - BearerToken *string + InstallationRoot string + WorkspaceRoot string + PackageRoot string + Server agentplugin.MCPServer + BearerToken *string } func (r PromptRequestPayload) EnvironmentID() string { diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go index 134ead26e..8644ad91e 100644 --- a/internal/agentdaemon/proto/version.go +++ b/internal/agentdaemon/proto/version.go @@ -5,7 +5,7 @@ package proto // upgrade query (`version=`) and in the bootstrap HTTP // response; mismatches fail closed at WS upgrade. const ( - Version = "0.6.0" + Version = "0.7.0" ) // VersionCompatible returns true when clientVersion's "X.Y" prefix diff --git a/packages/claude-sdk-adapter/src/mcp_environment.ts b/packages/claude-sdk-adapter/src/mcp_environment.ts index 65f70d56e..b60623d3d 100644 --- a/packages/claude-sdk-adapter/src/mcp_environment.ts +++ b/packages/claude-sdk-adapter/src/mcp_environment.ts @@ -1,3 +1,4 @@ +import { posix } from "node:path"; import { parseHTTPServers, type HTTPServer } from "./mcp.js"; export type StdioServer = { @@ -20,12 +21,16 @@ export function parseEnvironmentMCP(value: unknown): EnvironmentMCPServer[] | un if (Object.keys(server).some(key => !["server_label", "command", "args", "allowed_tools"].includes(key)) || typeof server.server_label !== "string" || !/^[a-zA-Z0-9_-]+$/.test(server.server_label) || server.server_label === "functions" || server.allowed_tools !== null || server.command !== "/usr/bin/python3" || - !Array.isArray(server.args) || server.args.length !== 6 || + !Array.isArray(server.args) || server.args.length !== 8 || server.args[0] !== "-I" || server.args[1] !== "-S" || server.args[2] !== "/usr/local/bin/oac-runtime-initialize" || server.args[3] !== "stdio" || - typeof server.args[4] !== "string" || !server.args[4] || server.args[4].startsWith("/") || - server.args[4].split("/").some((part: string) => !part || part === "." || part === "..") || - /[\x00-\x1f\x7f\\]/.test(server.args[4]) || server.args[5] !== server.server_label) throw new Error("invalid_request"); + typeof server.args[4] !== "string" || !posix.isAbsolute(server.args[4]) || server.args[4].endsWith("/") || posix.normalize(server.args[4]) !== server.args[4] || + /[\x00-\x1f\x7f\\]/.test(server.args[4]) || + typeof server.args[5] !== "string" || !posix.isAbsolute(server.args[5]) || server.args[5].endsWith("/") || posix.normalize(server.args[5]) !== server.args[5] || + /[\x00-\x1f\x7f\\]/.test(server.args[5]) || + typeof server.args[6] !== "string" || !server.args[6] || server.args[6].startsWith("/") || + server.args[6].split("/").some((part: string) => !part || part === "." || part === "..") || + /[\x00-\x1f\x7f\\]/.test(server.args[6]) || server.args[7] !== server.server_label) throw new Error("invalid_request"); } else { parseHTTPServers([server]); if (server.allowed_tools !== null || server.required !== undefined) throw new Error("invalid_request"); diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index 361ed5ec8..7b525d9d2 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -15,6 +15,7 @@ export type Workspace = { dependency_path: string; env_names: string[]; skills?: WorkspaceSkill[]; + capability_root?: string; mcp?: EnvironmentMCPServer[]; tool_environment?: boolean; system_packages?: boolean; @@ -49,7 +50,7 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin if (value === undefined) return undefined; if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const config = value as Record; - if (Object.keys(config).some(key => !["home", "state", "scratch", "protected_dirs", "dependency_path", "env_names", "network_access", "allowed_domains", "tool_environment", "system_packages", "skills", "mcp"].includes(key)) || + if (Object.keys(config).some(key => !["home", "state", "scratch", "protected_dirs", "dependency_path", "env_names", "network_access", "allowed_domains", "tool_environment", "system_packages", "skills", "mcp", "capability_root"].includes(key)) || (config.tool_environment !== undefined && typeof config.tool_environment !== "boolean") || (config.system_packages !== undefined && typeof config.system_packages !== "boolean") || (config.system_packages === true && config.tool_environment !== true) || @@ -59,6 +60,12 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin config.env_names.some(name => typeof name !== "string" || !environmentNames.has(name)) || new Set(config.env_names).size !== config.env_names.length) throw new Error("invalid_request"); const mcp = parseEnvironmentMCP(config.mcp); + if (config.capability_root !== undefined) { + const capabilityRoot = directory(config.capability_root, true); + if ([cwd, config.home, config.state, config.scratch].some(root => + typeof root === "string" && (contains(root, capabilityRoot) || contains(capabilityRoot, root)))) throw new Error("invalid_request"); + } + if ((Array.isArray(config.skills) && config.skills.length || mcp?.length) && !config.capability_root) throw new Error("invalid_request"); if (mcp?.length && config.network_access !== "enabled") throw new Error("invalid_request"); const domains = config.allowed_domains ?? []; const hostname = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i; @@ -105,7 +112,7 @@ export class WorkspaceProfile { env.CLAUDE_CODE_SHELL_PREFIX = "/usr/local/bin/oac-claude-shell-prefix"; env.OAC_RUNTIME_TOOL_SCRATCH = config.scratch; } - const skills = workspaceSkills(config.skills ?? []); + const skills = workspaceSkills(config.skills ?? [], config.capability_root ?? ""); this.skillNames = skills?.names ?? []; const skillTools = skills ? ["Skill"] : []; const protectedRoots = [config.home, config.state, ...config.protected_dirs]; @@ -126,7 +133,7 @@ export class WorkspaceProfile { enabled: true, failIfUnavailable: true, autoAllowBashIfSandboxed: false, allowUnsandboxedCommands: false, excludedCommands: [], enableWeakerNestedSandbox: false, enableWeakerNetworkIsolation: false, filesystem: { disabled: false, allowWrite: [cwd, config.scratch, ...(config.tool_environment ? ["/environment/packages"] : [])], denyRead: protectedRoots, - denyWrite: [...protectedRoots, ...(skills || config.mcp?.length ? ["/environment/initialization/capabilities"] : []), + denyWrite: [...protectedRoots, ...(config.capability_root ? [config.capability_root] : []), ...(config.system_packages ? ["/environment/packages/system"] : [])], allowRead: [] }, credentials: { envVars: [...new Set([...credentialNames, ...config.env_names, ...(mcp?.credentialReferences() ?? [])])].map(name => ({ name, mode: "deny" })), diff --git a/packages/claude-sdk-adapter/src/workspace_skills.ts b/packages/claude-sdk-adapter/src/workspace_skills.ts index 3d03c5a48..e15f69909 100644 --- a/packages/claude-sdk-adapter/src/workspace_skills.ts +++ b/packages/claude-sdk-adapter/src/workspace_skills.ts @@ -6,7 +6,6 @@ export type WorkspaceSkill = { relative_root: string; package_root: string; }; -export const capabilityRoot = "/environment/initialization/capabilities"; const relativePath = (value: unknown): value is string => typeof value === "string" && value !== "." && value.length > 0 && !value.startsWith("/") && !/[\\\x00-\x1f\x7f]/.test(value) && posix.normalize(value) === value && !value.split("/").includes(".."); @@ -53,7 +52,7 @@ function projectPackage(source: string, destination: string): void { // Only the generated envelope is activated. Original plugin control files stay // beneath content; exact Skill roots, not public plugin manifests, drive loading. -export function workspaceSkills(skills: readonly WorkspaceSkill[]): { paths: string[]; names: string[] } | undefined { +export function workspaceSkills(skills: readonly WorkspaceSkill[], capabilityRoot: string): { paths: string[]; names: string[] } | undefined { if (!skills.length) return undefined; const packages = new Map(); for (const skill of skills) { diff --git a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs index 38a06fdb8..d037130c4 100644 --- a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs @@ -9,18 +9,18 @@ import { immediateInput, parseStart } from "../dist/request.js"; import { WorkspaceProfile } from "../dist/workspace.js"; const stdio = { server_label: "installed", command: "/usr/bin/python3", allowed_tools: null, - args: ["-I", "-S", "/usr/local/bin/oac-runtime-initialize", "stdio", "plugins/installed", "installed"] }; + args: ["-I", "-S", "/usr/local/bin/oac-runtime-initialize", "stdio", "/private/runtime/capabilities", "/private/runtime/workspace", "plugins/installed", "installed"] }; const native = "mcp__installed__echo_v1"; const statuses = [{ name: "installed", status: "connected", tools: [{ name: "echo.v1" }] }]; const baseline = ["Bash", "Read", "Edit"]; function fixture(t, declarations = [stdio]) { const root = realpathSync(mkdtempSync(join(tmpdir(), "oac-mcp-workspace-"))); - const dirs = Object.fromEntries(["work", "home", "state", "scratch", "secrets", "deps"].map(name => { + const dirs = Object.fromEntries(["work", "home", "state", "scratch", "secrets", "deps", "capabilities"].map(name => { const path = join(root, name); mkdirSync(path); return [name, path]; })); const config = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, protected_dirs: [dirs.secrets], - dependency_path: dirs.deps, env_names: ["ANTHROPIC_AUTH_TOKEN"], network_access: "enabled", mcp: declarations }; + capability_root: dirs.capabilities, dependency_path: dirs.deps, env_names: ["ANTHROPIC_AUTH_TOKEN"], network_access: "enabled", mcp: declarations }; const previous = process.env; process.env = { HOME: dirs.home, CLAUDE_CONFIG_DIR: dirs.state, ANTHROPIC_AUTH_TOKEN: "model-secret" }; t.after(() => { process.env = previous; rmSync(root, { recursive: true, force: true }); }); @@ -37,9 +37,15 @@ test("installed MCP private projection cannot launch arbitrary unsandboxed comma assert.deepEqual(parseEnvironmentMCP([stdio]), [stdio]); for (const value of [[stdio, stdio], [{ ...stdio, command: "/bin/sh" }], [{ ...stdio, env: { TOKEN: "secret" } }], [{ ...stdio, args: ["-c", "untrusted"] }], [{ ...stdio, allowed_tools: ["*"] }], - [{ ...stdio, server_url: "https://example.invalid" }], [{ ...stdio, args: [...stdio.args.slice(0, 4), "../escape", "installed"] }]]) { + [{ ...stdio, server_url: "https://example.invalid" }], [{ ...stdio, args: [...stdio.args.slice(0, 6), "../escape", "installed"] }]]) { assert.throws(() => parseEnvironmentMCP(value), /invalid_request/); } + for (const index of [4, 5]) { + for (const invalid of ["/", "relative", "/tmp/../escape", "/tmp/root/", "/tmp/line\n"]) { + const args = [...stdio.args]; args[index] = invalid; + assert.throws(() => parseEnvironmentMCP([{ ...stdio, args }]), /invalid_request/); + } + } assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...request.workspace, network_access: "disabled" } })), /invalid_request/); }); @@ -66,7 +72,7 @@ test("combined inventory admits exact MCP identities without granting local file } assert.deepEqual(workspace.options.allowedTools, ["mcp__installed__*"]); assert.deepEqual(workspace.options.tools, baseline); - assert.ok(workspace.options.sandbox.filesystem.denyWrite.includes("/environment/initialization/capabilities")); + assert.ok(workspace.options.sandbox.filesystem.denyWrite.includes(dirs.capabilities)); assert.equal(workspace.options.sandbox.allowUnsandboxedCommands, false); mcp.close(); assert.equal((await workspace.canUseTool(native, {}, { signal })).behavior, "deny"); diff --git a/packages/claude-sdk-adapter/tests/workspace_skills.test.mjs b/packages/claude-sdk-adapter/tests/workspace_skills.test.mjs index 1b8296aa9..4f32cd444 100644 --- a/packages/claude-sdk-adapter/tests/workspace_skills.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace_skills.test.mjs @@ -15,3 +15,22 @@ test('Runtime Skill paths remain inside the declared package; public metadata is ]) assert.throws(() => parseSkills([invalid]), /invalid_request/); assert.throws(() => parseSkills([skill, skill]), /invalid_request/); }); + +test('native Skill envelope stays under the resolved Runtime root', async t => { + const { mkdtempSync, mkdirSync, writeFileSync, rmSync, statSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const { workspaceSkills } = await import('../dist/workspace_skills.js'); + const root = mkdtempSync(join(tmpdir(), 'oac-capability-root-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const source = join(root, 'plugins/0/custom/proof'); + mkdirSync(source, { recursive: true }); + writeFileSync(join(source, 'SKILL.md'), '---\nname: proof\ndescription: A proof.\n---\nUse this Skill.'); + const skill = { metadata: { type: 'inline', name: 'proof', description: 'A proof.' }, + relative_root: 'plugins/0/custom/proof', package_root: 'plugins/0' }; + const result = workspaceSkills([skill], root); + assert.deepEqual(result.paths, [join(root, 'native/claude/environment-skills-0')]); + const projected = join(result.paths[0], 'content/custom/proof/SKILL.md'); + assert.equal(statSync(projected).ino, statSync(join(source, 'SKILL.md')).ino); + assert.deepEqual(workspaceSkills([skill], root), result); +}); diff --git a/packages/mcode-harness/launch.mjs b/packages/mcode-harness/launch.mjs index ae3389f0f..994252762 100644 --- a/packages/mcode-harness/launch.mjs +++ b/packages/mcode-harness/launch.mjs @@ -2,7 +2,7 @@ import { SandboxManager } from './dist/sandbox.mjs'; import { spawn } from 'node:child_process'; import { readFileSync, mkdirSync } from 'node:fs'; import { isIP } from 'node:net'; -import { dirname, join } from 'node:path'; +import { dirname, join, isAbsolute, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const profile = JSON.parse(readFileSync(process.argv[2], 'utf8')); @@ -19,6 +19,8 @@ if (profile.systemPackages) { if (!profile.toolEnvironment) throw new Error('System packages require initialized tool configuration'); baseEnv.OAC_RUNTIME_TOOL_SCRATCH=profile.scratch; } +if (profile.capabilityRoot && (typeof profile.capabilityRoot !== 'string' || !isAbsolute(profile.capabilityRoot) || normalize(profile.capabilityRoot) !== profile.capabilityRoot || profile.capabilityRoot === '/' || /[\\\x00-\x1f\x7f]/.test(profile.capabilityRoot))) throw new Error('Invalid capability root'); +if (profile.skills && !profile.capabilityRoot) throw new Error('Missing capability root'); let child; let cancelled=false; const cancel=()=>{cancelled=true;child?.kill('SIGKILL');}; @@ -27,7 +29,7 @@ try { mkdirSync(profile.scratch,{recursive:true}); await SandboxManager.initialize({ network:{allowedDomains:domains,deniedDomains:profile.network==='disabled'?['*']:[],allowAll:profile.network==='enabled',strictAllowlist:true,allowAllUnixSockets:false,allowLocalBinding:false}, - filesystem:{denyRead:profile.protectedDirs,allowWrite:[profile.workspace,profile.scratch,...(profile.toolEnvironment ? ['/environment/packages'] : [])],denyWrite:[...(profile.skills ? ["/environment/initialization/capabilities"] : []),...(profile.systemPackages ? ['/environment/packages/system'] : [])]}, + filesystem:{denyRead:profile.protectedDirs,allowWrite:[profile.workspace,profile.scratch,...(profile.toolEnvironment ? ['/environment/packages'] : [])],denyWrite:[...(profile.capabilityRoot ? [profile.capabilityRoot] : []),...(profile.systemPackages ? ['/environment/packages/system'] : [])]}, seccomp:{applyPath:join(here,'dist/vendor/seccomp/x64/apply-seccomp')}, },undefined,false); const quote=s=>"'"+s.replaceAll("'","'\\''")+"'"; diff --git a/services/agents-api/deploy/runtime/initialize.py b/services/agents-api/deploy/runtime/initialize.py index c3e3990f7..686e21970 100644 --- a/services/agents-api/deploy/runtime/initialize.py +++ b/services/agents-api/deploy/runtime/initialize.py @@ -3,9 +3,7 @@ Core owns sequencing and the completion ledger. This helper executes one bounded operation; it never retries, schedules, selects a harness or interprets templates. """ -import base64 import ctypes -import hashlib import json import os from pathlib import Path @@ -21,8 +19,6 @@ CONFIG = ROOT / 'initialization' PACKAGES = ROOT / 'packages' ENV_FILE = CONFIG / 'tool-env.sh' -CAPABILITIES = CONFIG / 'capabilities' -SKILLS = CAPABILITIES / 'skills' MAX_INPUT = 32 * 1024 * 1024 BASE_ENV = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': '/tmp', 'LANG': 'C.UTF-8'} DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC @@ -33,78 +29,20 @@ def open_directory(parent, name): return os.open(name, DIRECTORY, dir_fd=parent) -def roots(): +def roots(workspace=None): root = os.open('/', DIRECTORY) try: environment = open_directory(root, 'environment') finally: os.close(root) try: - for name in ('workspace', 'packages', 'initialization'): + for name in (('workspace', 'packages', 'initialization') if workspace is None else ('packages', 'initialization')): child = open_directory(environment, name) os.close(child) finally: os.close(environment) - - -def install_skill(request): - name, files = request['name'], request['files'] - if not isinstance(name, str) or not re.fullmatch('[a-z0-9]+(?:[-_][a-z0-9]+)*', name) or len(name) > 64 or not isinstance(files, list) or not 0 < len(files) <= 1000: - raise ValueError('invalid skill') - directory = os.open(SKILLS, DIRECTORY) - try: - os.mkdir(name, mode=0o700, dir_fd=directory) - skill = open_directory(directory, name) - finally: - os.close(directory) - total = 0 - try: - for file in files: - relative = file['path'] - if not isinstance(relative, str) or len(relative) > 4096 or any(c in relative for c in ('\\', '\x00', '\r', '\n')): - raise ValueError('invalid skill path') - parts = relative.split('/') - if any(not part or part in ('.', '..') for part in parts): - raise ValueError('invalid skill path') - body = base64.b64decode(file['data'], validate=True) - total += len(body) - if total > 20 * 1024 * 1024: - raise ValueError('skill too large') - parent = os.dup(skill) - try: - for part in parts[:-1]: - try: - os.mkdir(part, mode=0o700, dir_fd=parent) - except FileExistsError: - pass - child = open_directory(parent, part) - os.close(parent) - parent = child - # A fresh installation cannot replace an existing member. - try: - os.stat(parts[-1], dir_fd=parent, follow_symlinks=False) - except FileNotFoundError: - pass - else: - raise ValueError('duplicate skill member') - result = subprocess.run(['/usr/local/bin/oac-codex-write', str(SKILLS / name), relative, - str(len(body)), str(ROOT / 'staging')], - input=body + hashlib.sha256(body).digest(), env=BASE_ENV, - capture_output=True, check=True) - receipt = json.loads(result.stdout) - if result.stderr or receipt != {'version': 1, 'outcome': 'completed', 'size_bytes': len(body)}: - raise ValueError('skill write unconfirmed') - fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent) - try: - os.fchmod(fd, 0o500 if file.get('executable', False) else 0o400) - os.fsync(fd) - finally: - os.close(fd) - finally: - os.close(parent) - os.fsync(skill) - finally: - os.close(skill) + if workspace is not None: + os.close(os.open(workspace, DIRECTORY)) def configure(env): @@ -123,16 +61,6 @@ def configure(env): } materialized = json.dumps(values, ensure_ascii=True) script = ''.join('export ' + name + '=' + shlex.quote(value) + '\n' for name, value in sorted(values.items())) - environment = os.open(CONFIG, DIRECTORY) - try: - os.mkdir('capabilities', mode=0o700, dir_fd=environment) - capabilities = open_directory(environment, 'capabilities') - try: - os.mkdir('skills', mode=0o700, dir_fd=capabilities) - finally: - os.close(capabilities) - finally: - os.close(environment) directory = os.open(CONFIG, DIRECTORY) try: fd = os.open('tool-env.sh', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400, dir_fd=directory) @@ -150,14 +78,14 @@ def configure(env): os.close(directory) -def sandbox(network, cwd): +def sandbox(network, cwd, workspace=None): if network not in ('enabled', 'disabled') or not isinstance(cwd, str) or not cwd.startswith('/') or '\x00' in cwd: raise ValueError('invalid execution configuration') if (CONFIG / 'system-root.json').exists(): tools = runpy.run_path('/usr/local/bin/oac-tool-root') if not tools['installed'](): raise ValueError('system tools unavailable') - return tools['initialization_sandbox'](cwd, network) + return tools['initialization_sandbox'](cwd, network, workspace=workspace or str(ROOT / 'workspace')) # One packaging contract for every Provider/harness. No native state, daemon # credential, staging payload or parent process is visible in this mount map. args = ['/usr/bin/bwrap', '--unshare-user', '--unshare-pid', '--unshare-ipc', '--unshare-uts', @@ -168,8 +96,10 @@ def sandbox(network, cwd): '--ro-bind', '/etc/resolv.conf', '/etc/resolv.conf', '--ro-bind', '/etc/hosts', '/etc/hosts', '--ro-bind', '/etc/ssl', '/etc/ssl', '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--dir', '/home', - '--dir', '/environment', '--bind', str(ROOT / 'workspace'), '/workspace', + '--dir', '/environment', '--bind', workspace or str(ROOT / 'workspace'), '/workspace', '--bind', str(PACKAGES), str(PACKAGES), '--ro-bind', str(CONFIG), str(CONFIG)] + if workspace is not None and workspace != '/workspace': + args += ['--bind', workspace, workspace] if network == 'disabled': args += ['--unshare-net'] for key, value in BASE_ENV.items(): @@ -182,9 +112,6 @@ def run(request): if action == 'configure': configure(request['env']) return - if action == 'skill': - install_skill(request) - return if action == 'system': runpy.run_path('/usr/local/bin/oac-tool-root')['install'](request['packages']) return @@ -283,24 +210,31 @@ def bind_parent(): os.close(parent_fd) -def stdio(package, server): +def stdio(installation_root, workspace, package, server): """Preserve the native MCP descriptors while entering the existing sandbox.""" - roots() - args = sandbox('enabled', '/workspace') + for value in (installation_root, workspace): + if (not isinstance(value, str) or not value.startswith('/') or value == '/' + or os.path.normpath(value) != value or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in value)): + raise ValueError('invalid Runtime directory') + path = Path(value) + if path.resolve(strict=True) != path or not path.is_dir(): + raise ValueError('invalid Runtime directory') + roots(workspace) + args = sandbox('enabled', '/workspace', workspace=workspace) helper = '/tmp/oac-mcp-exec' # System-package roots predate daemon installation. Mount only the fixed # static helper, never native configuration, credentials or Runtime state. - args[-1:-1] = ['--ro-bind', '/usr/local/bin/oac-daemon', helper] - args += [helper, 'runtime-mcp-exec', package, server] + args[-1:-1] = ['--ro-bind', '/usr/local/bin/oac-daemon', helper, '--ro-bind', installation_root, installation_root] + args += [helper, 'runtime-mcp-exec', installation_root, package, server] return stdio_lifetime(args) def main(): if len(sys.argv) != 1: try: - if len(sys.argv) != 4 or sys.argv[1] != 'stdio': + if len(sys.argv) != 6 or sys.argv[1] != 'stdio': raise ValueError('invalid stdio invocation') - return stdio(sys.argv[2], sys.argv[3]) + return stdio(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]) except Exception: print('Environment MCP unavailable', file=sys.stderr) return 1 diff --git a/services/agents-api/deploy/runtime/initialize_receipt_test.py b/services/agents-api/deploy/runtime/initialize_receipt_test.py index 8b92f2ab0..c97641100 100644 --- a/services/agents-api/deploy/runtime/initialize_receipt_test.py +++ b/services/agents-api/deploy/runtime/initialize_receipt_test.py @@ -10,6 +10,7 @@ import json from pathlib import Path import subprocess +import tempfile import unittest from unittest import mock @@ -42,6 +43,77 @@ def assert_receipt(self, failure, expected): self.assertNotIn(CANARY, stdout) self.assertNotIn('echo', stdout) + def test_configure_does_not_create_or_change_runtime_capabilities(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) + capabilities = config / 'capabilities' + capabilities.mkdir() + manifest = capabilities / 'installed.json' + manifest.write_bytes(b'fixed-runtime-fixture') + with mock.patch.object(initialize, 'CONFIG', config): + initialize.configure({'VALUE': 'configured'}) + self.assertEqual(manifest.read_bytes(), b'fixed-runtime-fixture') + self.assertFalse((capabilities / 'skills').exists()) + self.assertIn('configured', (config / 'tool-env.sh').read_text()) + with self.assertRaises(FileExistsError): + initialize.configure({'VALUE': 'changed'}) + + def test_skill_is_not_a_provider_initializer_action(self): + with mock.patch.object(initialize, 'sandbox', return_value=[]), \ + mock.patch.object(initialize.subprocess, 'run') as run: + with self.assertRaisesRegex(ValueError, 'invalid action'): + initialize.run({'action': 'skill', 'network': 'enabled'}) + run.assert_not_called() + + def test_stdio_forwards_and_mounts_resolved_runtime_directories(self): + with tempfile.TemporaryDirectory() as directory: + base = Path(directory).resolve() + root, workspace = base / 'capabilities', base / 'actual-work' + root.mkdir() + workspace.mkdir() + root, workspace = str(root), str(workspace) + with mock.patch.object(initialize, 'roots') as roots, \ + mock.patch.object(initialize, 'sandbox', return_value=['bwrap', '--']) as sandbox, \ + mock.patch.object(initialize, 'stdio_lifetime', return_value=0) as launch: + with mock.patch.object(initialize.sys, 'argv', ['initialize', 'stdio', root, workspace, 'plugins/0', 'server']): + self.assertEqual(initialize.main(), 0) + roots.assert_called_once_with(workspace) + sandbox.assert_called_once_with('enabled', '/workspace', workspace=workspace) + argv = launch.call_args.args[0] + self.assertEqual(argv[-5:], ['/tmp/oac-mcp-exec', 'runtime-mcp-exec', root, 'plugins/0', 'server']) + self.assertIn(['--ro-bind', root, root], [argv[i:i+3] for i in range(len(argv)-2)]) + alias = base / 'alias' + alias.symlink_to(workspace, target_is_directory=True) + for invalid in ('/', 'relative', '/tmp/../secret', workspace + '/', workspace + '\n', str(alias)): + for values in ((invalid, workspace), (root, invalid)): + with self.assertRaises(ValueError): + initialize.stdio(*values, 'plugins/0', 'server') + + def test_stdio_mounts_workspace_at_native_alias_and_physical_path(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) + workspace = str(config / 'actual-work') + with mock.patch.object(initialize, 'CONFIG', config): + args = initialize.sandbox('enabled', '/workspace', workspace=workspace) + mounts = [args[i:i+3] for i in range(len(args)-2)] + self.assertIn(['--bind', workspace, '/workspace'], mounts) + self.assertIn(['--bind', workspace, workspace], mounts) + self.assertNotIn(['--bind', '/environment/workspace', '/workspace'], mounts) + self.assertIn(['--ro-bind', str(config), str(config)], mounts) + default = initialize.sandbox('enabled', '/workspace') + self.assertIn(['--bind', '/environment/workspace', '/workspace'], + [default[i:i+3] for i in range(len(default)-2)]) + tools = initialize.runpy.run_path(str(Path(__file__).with_name('tool-root.py'))) + (config / 'system-root.json').write_text('{"version":1}') + tools['installed'] = lambda: True + with mock.patch.object(initialize.runpy, 'run_path', return_value=tools): + args = initialize.sandbox('enabled', '/workspace', workspace=workspace) + mounts = [args[i:i+3] for i in range(len(args)-2)] + self.assertIn(['--bind', workspace, '/workspace'], mounts) + self.assertIn(['--bind', workspace, workspace], mounts) + self.assertIn(['--ro-bind', '/environment/initialization', '/environment/initialization'], mounts) + self.assertIn(['--chdir', '/workspace'], [args[i:i+2] for i in range(len(args)-1)]) + def test_sandboxed_step_reports_only_its_exit_status(self): for status in (1, 3, 100, 255): failure = subprocess.CalledProcessError(status, SANDBOXED, output=CANARY.encode(), stderr=CANARY.encode()) diff --git a/services/agents-api/deploy/runtime/initialize_test.py b/services/agents-api/deploy/runtime/initialize_test.py index a10c4cc32..722d9fb7f 100644 --- a/services/agents-api/deploy/runtime/initialize_test.py +++ b/services/agents-api/deploy/runtime/initialize_test.py @@ -4,7 +4,6 @@ support the same nested isolation as its deployed Provider. No model is mocked; these checks exercise initialization only, not public native-model acceptance. """ -import base64 import json import os from pathlib import Path @@ -41,15 +40,13 @@ def main(): env.update(http_proxy=os.environ['OAC_TEST_PACKAGE_PROXY'], https_proxy=os.environ['OAC_TEST_PACKAGE_PROXY']) invoke('configure', env=env) - skill = [{'path': 'SKILL.md', 'data': base64.b64encode(b'---\nname: proof\ndescription: A proof.\n---\nRead check.sh.').decode()}, - {'path': 'scripts/check.sh', 'data': base64.b64encode(b'#!/bin/sh\nprintf skill-proof').decode(), 'executable': True}, - {'path': 'data.bin', 'data': base64.b64encode(bytes(range(256))).decode()}] - invoke('skill', name='proof', files=skill) - assert Path('/environment/initialization/capabilities/skills/proof/data.bin').read_bytes() == bytes(range(256)) - assert Path('/environment/initialization/capabilities/skills/proof/scripts/check.sh').stat().st_mode & 0o777 == 0o500 - invoke('skill', succeeds=False, name='proof', files=skill) - invoke('skill', succeeds=False, name='invalid', files=[{'path': '../../private/credential', 'data': 'YmFk'}]) - assert Path('/environment/private/credential').read_text() == CANARY + # A fixed Runtime-installed fixture tests setup access, not archive parsing. + skill = Path('/environment/initialization/capabilities/skills/proof') + (skill / 'scripts').mkdir(parents=True) + (skill / 'SKILL.md').write_text('---\nname: proof\ndescription: A proof.\n---\nRead check.sh.') + (skill / 'SKILL.md').chmod(0o400) + (skill / 'scripts/check.sh').write_text('#!/bin/sh\nprintf skill-proof') + (skill / 'scripts/check.sh').chmod(0o500) invoke('setup', command='/environment/initialization/capabilities/skills/proof/scripts/check.sh > /workspace/skill-result') assert Path('/environment/workspace/skill-result').read_text() == 'skill-proof' if '--system' in sys.argv: diff --git a/services/agents-api/deploy/runtime/tool-root.py b/services/agents-api/deploy/runtime/tool-root.py index f2693ccd9..f5f5ea499 100644 --- a/services/agents-api/deploy/runtime/tool-root.py +++ b/services/agents-api/deploy/runtime/tool-root.py @@ -38,6 +38,8 @@ def sandbox(cwd, *, writable=False, network='enabled', workspace='/workspace', s '--bind', workspace, '/environment/workspace', '--bind', '/environment/packages', '/environment/packages', '--ro-bind', str(ROOT), str(ROOT), '--ro-bind', str(CONFIG), str(CONFIG)] + if workspace not in ('/workspace', '/environment/workspace'): + args += ['--bind', workspace, workspace] if network == 'disabled': args += ['--unshare-net'] elif network != 'enabled': @@ -47,8 +49,8 @@ def sandbox(cwd, *, writable=False, network='enabled', workspace='/workspace', s return args + ['--chdir', cwd, '--'] -def initialization_sandbox(cwd, network='enabled', *, writable=False): - args = sandbox(cwd, writable=writable, network=network, workspace='/environment/workspace') +def initialization_sandbox(cwd, network='enabled', *, writable=False, workspace='/environment/workspace'): + args = sandbox(cwd, writable=writable, network=network, workspace=workspace) args[1:1] = ['--new-session', '--clearenv'] for key, value in {**BASE_ENV, 'DEBIAN_FRONTEND': 'noninteractive'}.items(): args[-1:-1] = ['--setenv', key, value] diff --git a/services/agents-api/internal/api/environment_request.go b/services/agents-api/internal/api/environment_request.go index faee62171..1970cb331 100644 --- a/services/agents-api/internal/api/environment_request.go +++ b/services/agents-api/internal/api/environment_request.go @@ -2,10 +2,9 @@ package api import ( "encoding/json" - "path" - "strings" v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -21,7 +20,7 @@ func decodeSessionEnvironment(raw json.RawMessage) (*v1.Environment, error) { return decodeHostedEnvironment(raw) case "self_hosted": fields = append(fields, "workspace_directory", "capability_directories") - if !path.IsAbs(environment.WorkspaceDirectory) || strings.ContainsRune(environment.WorkspaceDirectory, 0) { + if agentcapabilities.ValidateLocalDirectories([]string{environment.WorkspaceDirectory}) != nil || agentcapabilities.ValidateLocalDirectories(environment.CapabilityDirectories) != nil { return nil, store.ErrInvalidInput } default: diff --git a/services/agents-api/internal/api/environment_request_test.go b/services/agents-api/internal/api/environment_request_test.go new file mode 100644 index 000000000..38b70a541 --- /dev/null +++ b/services/agents-api/internal/api/environment_request_test.go @@ -0,0 +1,29 @@ +package api + +import ( + "encoding/json" + "testing" +) + +func TestSelfHostedPathsDoNotCreateManagedInitialization(t *testing.T) { + raw := json.RawMessage(`{"type":"self_hosted","workspace_directory":"/home/user/work","capability_directories":["/opt/skills","/home/user/plugins"]}`) + request, err := (decodedSessionRequest{Environment: raw}).validated() + if err != nil { + t.Fatal(err) + } + if !request.initialization.Empty() || request.Environment.WorkspaceDirectory != "/home/user/work" || len(request.Environment.CapabilityDirectories) != 2 { + t.Fatal("local selections became managed initialization", request.initialization, request.Environment) + } + for _, raw := range []string{ + `{"type":"self_hosted","workspace_directory":"/a/../b"}`, + `{"type":"self_hosted","workspace_directory":"/work/"}`, + `{"type":"self_hosted","workspace_directory":"/work","capability_directories":["relative"]}`, + `{"type":"self_hosted","workspace_directory":"/work","capability_directories":["/a","/a"]}`, + `{"type":"self_hosted","workspace_directory":"/work","plugins":[]}`, + `{"type":"self_hosted","workspace_directory":"/work","skills":[]}`, + } { + if _, err := (decodedSessionRequest{Environment: json.RawMessage(raw)}).validated(); err == nil { + t.Fatal("invalid local input accepted", raw) + } + } +} diff --git a/services/agents-api/internal/api/environments.go b/services/agents-api/internal/api/environments.go index 475043b83..e80c918bf 100644 --- a/services/agents-api/internal/api/environments.go +++ b/services/agents-api/internal/api/environments.go @@ -36,7 +36,7 @@ func (h *Handler) getEnvironment(w http.ResponseWriter, r *http.Request) { func environmentResponse(environment store.Environment) (v1.EnvironmentInfo, error) { configuration, err := storedEnvironment(environment.Configuration) - if err != nil || (configuration.Type != "self_hosted" && configuration.Type != "openai_hosted") || (configuration.Type == "self_hosted" && len(configuration.CapabilityDirectories) != 0) || environment.ID == "" { + if err != nil || (configuration.Type != "self_hosted" && configuration.Type != "openai_hosted") || environment.ID == "" { return v1.EnvironmentInfo{}, errors.New("unsupported stored environment metadata configuration") } switch environment.Status { diff --git a/services/agents-api/internal/api/environments_test.go b/services/agents-api/internal/api/environments_test.go index 94df3e386..22801539b 100644 --- a/services/agents-api/internal/api/environments_test.go +++ b/services/agents-api/internal/api/environments_test.go @@ -74,7 +74,7 @@ func TestEnvironmentResourceExactProjectionWithoutExecution(t *testing.T) { } }) } - for _, capabilities := range []string{"", `,"capability_directories":null`, `,"capability_directories":[]`} { + for _, capabilities := range []string{"", `,"capability_directories":null`, `,"capability_directories":[]`, `,"capability_directories":["/skills"]`} { _, f := environmentResourceHandler(t) f.environment.Configuration = json.RawMessage(`{"type":"self_hosted","workspace_directory":"/workspace"` + capabilities + `}`) value, err := environmentResponse(f.environment) @@ -86,15 +86,15 @@ func TestEnvironmentResourceExactProjectionWithoutExecution(t *testing.T) { func TestEnvironmentResourceRejectsUnknownInventoryAndInvalidState(t *testing.T) { for name, configuration := range map[string]string{ - "missing": `{}`, - "none": `{"type":"none"}`, - "hosted": `{"type":"openai_hosted","env":{"SECRET":"private-canary"}}`, - "capabilities": `{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":["/skills"]}`, - "files": `{"type":"self_hosted","workspace_directory":"/workspace","files":[{"data":"private-canary"}]}`, - "plugins": `{"type":"self_hosted","workspace_directory":"/workspace","plugins":[]}`, - "skills": `{"type":"self_hosted","workspace_directory":"/workspace","skills":[]}`, - "wrong type": `{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":false}`, - "invalid": `{"type":"self_hosted","workspace_directory":`, + "missing": `{}`, + "none": `{"type":"none"}`, + "hosted": `{"type":"openai_hosted","env":{"SECRET":"private-canary"}}`, + "unclean directories": `{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":["/skills/../private"]}`, + "files": `{"type":"self_hosted","workspace_directory":"/workspace","files":[{"data":"private-canary"}]}`, + "plugins": `{"type":"self_hosted","workspace_directory":"/workspace","plugins":[]}`, + "skills": `{"type":"self_hosted","workspace_directory":"/workspace","skills":[]}`, + "wrong type": `{"type":"self_hosted","workspace_directory":"/workspace","capability_directories":false}`, + "invalid": `{"type":"self_hosted","workspace_directory":`, } { t.Run(name, func(t *testing.T) { h, f := environmentResourceHandler(t) diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 318132d41..2a7112fe1 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -159,7 +159,7 @@ func (h *Handler) routes() *chi.Mux { // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. +// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/api/session_request.go b/services/agents-api/internal/api/session_request.go index 40db4f215..edaac5777 100644 --- a/services/agents-api/internal/api/session_request.go +++ b/services/agents-api/internal/api/session_request.go @@ -61,9 +61,15 @@ func (request decodedSessionRequest) validated() (sessionRequest, error) { return input, store.ErrInvalidInput } var err error - input.initialization, err = decodeEnvironmentSetup(environmentFields) - if err != nil { - return input, err + var environmentType string + _ = json.Unmarshal(environmentFields["type"], &environmentType) + // Self-hosted paths are frozen in Environment.Configuration. They do not + // create managed initialization state or require a managed allocation. + if environmentType != "self_hosted" { + input.initialization, err = decodeEnvironmentSetup(environmentFields) + if err != nil { + return input, err + } } input.initialFiles, err = decodeInitialFiles(environmentFields["files"]) if err != nil { diff --git a/services/agents-api/internal/execution/environment_capabilities_test.go b/services/agents-api/internal/execution/environment_capabilities_test.go new file mode 100644 index 000000000..b39950ea3 --- /dev/null +++ b/services/agents-api/internal/execution/environment_capabilities_test.go @@ -0,0 +1,34 @@ +package execution + +import ( + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "slices" + "testing" +) + +func TestSelfHostedCapabilitySourcesAreFrozenAndStrict(t *testing.T) { + session := store.Session{ID: "session", TenantID: "tenant"} + environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, + Configuration: []byte(`{"type":"self_hosted","workspace_directory":"/home/user/project","capability_directories":["/home/user/skills","/opt/plugins"]}`)} + var request proto.PromptRequestPayload + if err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &request); err != nil { + t.Fatal(err) + } + local := request.LocalEnvironment + if local.WorkspaceDirectory != "/home/user/project" || !local.Capabilities || local.ToolEnvironment || len(local.Skills) != 0 || + local.CapabilitySources == nil || !slices.Equal(local.CapabilitySources.Directories, []string{"/home/user/skills", "/opt/plugins"}) { + t.Fatal("frozen source selections lost", local) + } + for _, configuration := range []string{ + `{"type":"self_hosted","workspace_directory":"/a/../b"}`, + `{"type":"self_hosted","workspace_directory":"relative"}`, + `{"type":"self_hosted","workspace_directory":"/work","capability_directories":["/a","/a"]}`, + `{"type":"self_hosted","workspace_directory":"/work","skills":[]}`, + `{"type":"self_hosted","workspace_directory":"/work","initialization":true}`, + } { + if LocalWorkspaceConfiguration([]byte(configuration)) { + t.Fatal("invalid self-hosted shape accepted", configuration) + } + } +} diff --git a/services/agents-api/internal/execution/environment_placement.go b/services/agents-api/internal/execution/environment_placement.go index bbd3f1121..aeb6fe3db 100644 --- a/services/agents-api/internal/execution/environment_placement.go +++ b/services/agents-api/internal/execution/environment_placement.go @@ -38,11 +38,19 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem } switch placement.Type { case "self_hosted": - if placement.WorkspaceDirectory == "/workspace" && len(placement.CapabilityDirectories) == 0 { + var local struct { + Type string `json:"type"` + WorkspaceDirectory string `json:"workspace_directory"` + CapabilityDirectories []string `json:"capability_directories"` + } + decoder := json.NewDecoder(bytes.NewReader(configuration)) + decoder.DisallowUnknownFields() + if decoder.Decode(&local) == nil && validSelfHostedPlacement(placement) { placement.NetworkAccess = "enabled" return placement, nil } case "openai_hosted": + placement.WorkspaceDirectory = "/workspace" // Stored policy is shared by preparation and provider bootstrap. var local struct { Plugins []agentplugin.Metadata `json:"plugins,omitempty"` @@ -90,14 +98,26 @@ func (d *Dispatcher) configurePreparedEnvironment(session store.Session, environ if placement.SystemPackages && !placement.ToolEnvironment { return store.ErrInvalidInput } - req.LocalEnvironment = &proto.LocalEnvironment{ID: environment.ID, Capabilities: len(placement.Plugins)+len(placement.CapabilityDirectories) > 0, ToolEnvironment: placement.ToolEnvironment, SystemPackages: placement.SystemPackages} + sources := &agentcapabilities.Input{Plugins: append([]agentplugin.Metadata(nil), placement.Plugins...), Directories: append([]string(nil), placement.CapabilityDirectories...)} for _, metadata := range placement.Skills { if store.ValidateInstalledSkillMetadata(metadata) != nil { return store.ErrInvalidInput } - req.LocalEnvironment.Capabilities = true + sources.Skills = append(sources.Skills, (store.EnvironmentSkill{Metadata: metadata}).InstallationMetadata()) + } + req.LocalEnvironment = &proto.LocalEnvironment{ + ID: environment.ID, WorkspaceDirectory: placement.WorkspaceDirectory, + CapabilitySources: sources, + Capabilities: len(sources.Skills)+len(sources.Plugins)+len(sources.Directories) > 0, + ToolEnvironment: placement.ToolEnvironment, SystemPackages: placement.SystemPackages, } req.LocalEnvironment.NetworkAccess = placement.NetworkAccess req.LocalEnvironment.AllowedDomains = append([]string(nil), placement.AllowedDomains...) return nil } + +func validSelfHostedPlacement(placement environmentPlacement) bool { + return agentcapabilities.ValidateLocalDirectories([]string{placement.WorkspaceDirectory}) == nil && + agentcapabilities.ValidateLocalDirectories(placement.CapabilityDirectories) == nil && + !placement.ToolEnvironment && len(placement.Skills)+len(placement.Plugins) == 0 +} diff --git a/services/agents-api/internal/execution/environment_placement_test.go b/services/agents-api/internal/execution/environment_placement_test.go index 922962670..647e57538 100644 --- a/services/agents-api/internal/execution/environment_placement_test.go +++ b/services/agents-api/internal/execution/environment_placement_test.go @@ -119,11 +119,11 @@ func TestSelfHostedUsesSameLocalBinding(t *testing.T) { for _, binding := range []string{"", "foreign", environment.ID} { var request proto.PromptRequestPayload err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, store.ExecutionDevice{EnvironmentID: binding}, &request) - valid := binding == environment.ID && workspace == "/workspace" + valid := binding == environment.ID if (err == nil) != valid { t.Fatal(workspace, binding, err) } - if valid && (request.LocalEnvironment == nil || request.LocalEnvironment.ID != environment.ID || request.LocalEnvironment.NetworkAccess != "enabled") { + if valid && (request.LocalEnvironment == nil || request.LocalEnvironment.ID != environment.ID || request.LocalEnvironment.NetworkAccess != "enabled" || request.LocalEnvironment.WorkspaceDirectory != workspace || request.LocalEnvironment.CapabilitySources == nil) { t.Fatal("self-hosted placement did not retain common local contract", request.LocalEnvironment) } } diff --git a/services/agents-api/internal/execution/runtime_capabilities_test.go b/services/agents-api/internal/execution/runtime_capabilities_test.go new file mode 100644 index 000000000..2959dbf06 --- /dev/null +++ b/services/agents-api/internal/execution/runtime_capabilities_test.go @@ -0,0 +1,98 @@ +package execution + +import ( + "bytes" + "context" + "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" + "testing" +) + +type capabilityProviderTrap struct { + sandbox.Provider + calls int +} + +func (p *capabilityProviderTrap) RunCommand(context.Context, sandbox.Reference, sandbox.Command) (sandbox.CommandResult, error) { + p.calls++ + return sandbox.CommandResult{}, nil +} + +type capabilityFixture struct { + request proto.CapabilitiesPreparePayload + body []byte + requestID string + outcome string + err error +} + +func (f *capabilityFixture) PrepareCapabilities(_ context.Context, id string, request proto.CapabilitiesPreparePayload, body []byte) (proto.CapabilitiesResultPayload, error) { + f.requestID, f.request, f.body = id, request, append([]byte(nil), body...) + return proto.CapabilitiesResultPayload{Outcome: f.outcome, ErrorCode: "safe_failure"}, f.err +} +func TestRuntimeCapabilitiesPreserveRawBundlesAndSetupOrdering(t *testing.T) { + archive := []byte("opaque archive bytes must be expanded only by Runtime") + setup := store.EnvironmentSetup{ + Skills: []store.EnvironmentSkill{{Metadata: store.EnvironmentSkillMetadata{Type: "skill_reference", SkillID: "private-reference", Version: "1", Name: "example", Description: "Safe description"}, Archive: archive}}, + Plugins: []store.EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin", Description: "Safe plugin"}, Archive: archive}}, + Commands: []store.SetupCommand{{Command: "prepare directory"}}, + CapabilityDirectories: []string{"/workspace/generated"}, + } + operations := setupOperations(setup) + if len(operations) != 5 || operations[0].Action != "configure" || operations[1].Capabilities.Action != "skill" || operations[2].Capabilities.Action != "plugin" || operations[3].Action != "setup" || operations[4].Capabilities.Action != "finalize" { + t.Fatal("bundle-before-setup or directory-after-setup ordering changed", operations) + } + owner := store.RuntimeAllocation{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString()} + for _, op := range []runtimeSetupOperation{operations[1], operations[2], operations[4]} { + peer := &capabilityFixture{outcome: "completed"} + if err := runRuntimeCapabilities(t.Context(), peer, owner, op); err != nil { + t.Fatal(err) + } + if _, err := uuid.Parse(peer.requestID); err != nil { + t.Fatal("request ID", err) + } + if peer.request.EnvironmentID != owner.EnvironmentID || peer.request.SessionID != owner.SessionID { + t.Fatal("allocation identity lost") + } + if op.Capabilities.Action != "finalize" && !bytes.Equal(peer.body, archive) { + t.Fatal("Core expanded or changed archive") + } + if op.Capabilities.Action == "skill" && (peer.request.Skill.Type != "inline" || peer.request.Skill.Name != "example") { + t.Fatal("unsafe or unresolved metadata") + } + if op.Capabilities.Action == "finalize" && (len(peer.body) != 0 || peer.request.Sources == nil || len(peer.request.Sources.Skills) != 1 || len(peer.request.Sources.Plugins) != 1 || peer.request.Sources.Directories[0] != "/workspace/generated") { + t.Fatal("finalization selections changed") + } + provider := &capabilityProviderTrap{} + if err := runRuntimeSetup(t.Context(), provider, sandbox.Reference{}, op); err == nil || provider.calls != 0 { + t.Fatal("capability operation reached provider command path") + } + } +} +func TestRuntimeCapabilitiesConfirmedAndUnknownFailures(t *testing.T) { + for _, outcome := range []string{"failed", "rejected", "unknown", "unexpected"} { + peer := &capabilityFixture{outcome: outcome} + err := runRuntimeCapabilities(t.Context(), peer, store.RuntimeAllocation{}, runtimeSetupOperation{Capabilities: &proto.CapabilitiesPreparePayload{Action: "finalize"}}) + var confirmed *runtimeStepFailure + if err == nil || errors.As(err, &confirmed) != (outcome == "failed" || outcome == "rejected") { + t.Fatal(outcome, err) + } + } + peer := &capabilityFixture{outcome: "completed", err: errors.New(setupCanary)} + err := runRuntimeCapabilities(t.Context(), peer, store.RuntimeAllocation{}, runtimeSetupOperation{Capabilities: &proto.CapabilitiesPreparePayload{}}) + if err == nil || bytes.Contains([]byte(err.Error()), []byte(setupCanary)) { + t.Fatal("transport error leaked or succeeded", err) + } +} +func TestRuntimeInitializationWaitsForMissingSocket(t *testing.T) { + lifecycle := runtimeLifecycle{registry: gateway.NewRegistry()} + peer, err := lifecycle.initializationPeer(t.Context(), store.RuntimeAllocation{DeviceID: uuid.NewString()}) + if err != nil || peer != nil || lifecycle.initializing != nil { + t.Fatal("missing socket became initialization failure", peer, err) + } +} diff --git a/services/agents-api/internal/execution/runtime_initialization.go b/services/agents-api/internal/execution/runtime_initialization.go index b04fdec28..920acd9ec 100644 --- a/services/agents-api/internal/execution/runtime_initialization.go +++ b/services/agents-api/internal/execution/runtime_initialization.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -57,6 +58,16 @@ func (r *runtimeLifecycle) observeInitialization(ctx context.Context, owner stor _, err = r.store.RequestRuntimeCleanup(ctx, owner) return err } + // A not-yet-connected Runtime is not a failed initialization attempt. + for _, operation := range operations { + if operation.Capabilities != nil { + peer, err := r.initializationPeer(ctx, owner) + if err != nil || peer == nil { + return err + } + break + } + } claimed, err := r.store.ClaimRuntimeInitialization(ctx, owner) if err != nil { return err @@ -108,7 +119,18 @@ func (r *runtimeLifecycle) advanceInitialization(ctx context.Context) error { } else { setup := active.operations[active.next-active.files] step = setup.provisioningFailure(0) - err = runRuntimeSetup(operation, r.config.Provider, runtimeReference(owner), setup) + if setup.Capabilities != nil { + var peer *gateway.Session + peer, err = r.initializationPeer(operation, owner) + if err == nil && peer == nil { + return nil + } + if err == nil { + err = runRuntimeCapabilities(operation, peer, owner, setup) + } + } else { + err = runRuntimeSetup(operation, r.config.Provider, runtimeReference(owner), setup) + } } if err != nil { // Clearing the in-memory owner makes the next observation request cleanup, @@ -187,3 +209,16 @@ for component in parts[:-1]: os.close(fd) os.execv('/usr/local/bin/oac-codex-write', ['oac-codex-write', '/environment/workspace', sys.argv[1], sys.argv[2], '/environment/staging']) ` + +// Missing authority/socket before sending consumes no initialization operation. +// Once PrepareCapabilities is called, unknown effects use the existing cleanup. +func (r *runtimeLifecycle) initializationPeer(ctx context.Context, owner store.RuntimeAllocation) (*gateway.Session, error) { + if r.registry == nil { + return nil, nil + } + peer, err := authorizedRuntimePeer(ctx, r.store, r.registry, owner.DeviceID) + if errors.Is(err, store.ErrNotFound) || errors.Is(err, gateway.ErrSessionClosed) || errors.Is(err, gateway.ErrDeviceNotRegistered) { + return nil, nil + } + return peer, err +} diff --git a/services/agents-api/internal/execution/runtime_setup.go b/services/agents-api/internal/execution/runtime_setup.go index 584fb9156..34ddd06bc 100644 --- a/services/agents-api/internal/execution/runtime_setup.go +++ b/services/agents-api/internal/execution/runtime_setup.go @@ -5,27 +5,25 @@ import ( "encoding/json" "errors" - "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" ) // runtimeSetupOperation is the packaged initializer's confidential stdin contract. // Public templates and native harness configuration never cross this boundary. type runtimeSetupOperation struct { - Capabilities *agentcapabilities.Operation `json:"-"` - Skill *store.EnvironmentSkill `json:"-"` - Name string `json:"name,omitempty"` - Files []agentbundle.File `json:"files,omitempty"` - Version int `json:"version"` - Action string `json:"action"` - Network string `json:"network,omitempty"` - Env map[string]string `json:"env"` - Packages []string `json:"packages,omitempty"` - Command string `json:"command,omitempty"` - CWD string `json:"cwd,omitempty"` + Capabilities *proto.CapabilitiesPreparePayload `json:"-"` + Archive []byte `json:"-"` + Version int `json:"version"` + Action string `json:"action"` + Network string `json:"network,omitempty"` + Env map[string]string `json:"env"` + Packages []string `json:"packages,omitempty"` + Command string `json:"command,omitempty"` + CWD string `json:"cwd,omitempty"` // Index is the setup command position, used only for the public failure label. Index int `json:"-"` } @@ -56,10 +54,11 @@ func setupOperations(setup store.EnvironmentSetup) []runtimeSetupOperation { } result := []runtimeSetupOperation{{Version: 1, Action: "configure", Env: env}} for i := range setup.Skills { - result = append(result, runtimeSetupOperation{Version: 1, Action: "skill", Skill: &setup.Skills[i]}) + metadata := setup.Skills[i].InstallationMetadata() + result = append(result, runtimeSetupOperation{Action: "skill", Capabilities: &proto.CapabilitiesPreparePayload{Action: "skill", Skill: &metadata}, Archive: setup.Skills[i].Archive}) } for i, plugin := range setup.Plugins { - result = append(result, runtimeSetupOperation{Capabilities: &agentcapabilities.Operation{Version: 1, Action: "plugin", Slot: i, Archive: plugin.Archive, Plugin: plugin.Metadata}}) + result = append(result, runtimeSetupOperation{Capabilities: &proto.CapabilitiesPreparePayload{Action: "plugin", Slot: i, Plugin: &plugin.Metadata}, Archive: plugin.Archive}) } // The public network policy applies after setup completes. Provisioning uses // the isolated initializer's network; adapters enforce the runtime policy. @@ -85,28 +84,17 @@ func setupOperations(setup store.EnvironmentSetup) []runtimeSetupOperation { for _, skill := range setup.Skills { sources.Skills = append(sources.Skills, skill.InstallationMetadata()) } - result = append(result, runtimeSetupOperation{Capabilities: &agentcapabilities.Operation{Version: 1, Action: "finalize", Sources: sources}}) + result = append(result, runtimeSetupOperation{Capabilities: &proto.CapabilitiesPreparePayload{Action: "finalize", Sources: &sources}}) } return result } func runRuntimeSetup(ctx context.Context, provider sandbox.Provider, reference sandbox.Reference, operation runtimeSetupOperation) error { - if provider == nil { + if provider == nil || operation.Capabilities != nil { return sandbox.ErrInvalid } - if operation.Skill != nil { - files, err := agentskill.Read(operation.Skill.Archive, operation.Skill.InstallationMetadata()) - if err != nil { - return err - } - operation.Name, operation.Files = operation.Skill.Metadata.Name, files - } var payload any = operation args := []string{"/usr/bin/python3", "-I", "-S", "/usr/local/bin/oac-runtime-initialize"} - if operation.Capabilities != nil { - payload = operation.Capabilities - args = []string{"/usr/local/bin/oac-daemon", "runtime-capabilities"} - } input, err := json.Marshal(payload) if err != nil { return err @@ -135,3 +123,26 @@ func runRuntimeSetup(ctx context.Context, provider sandbox.Provider, reference s } return errors.New("environment initialization operation unconfirmed") } + +// capabilityPreparer is the authenticated Runtime operation; no provider command +// or expanded Skill files cross this boundary. +type capabilityPreparer interface { + PrepareCapabilities(context.Context, string, proto.CapabilitiesPreparePayload, []byte) (proto.CapabilitiesResultPayload, error) +} + +func runRuntimeCapabilities(ctx context.Context, peer capabilityPreparer, owner store.RuntimeAllocation, operation runtimeSetupOperation) error { + if peer == nil || operation.Capabilities == nil { + return sandbox.ErrInvalid + } + request := *operation.Capabilities + request.EnvironmentID, request.SessionID = owner.EnvironmentID, owner.SessionID + result, err := peer.PrepareCapabilities(ctx, uuid.NewString(), request, operation.Archive) + if err == nil && result.Outcome == "completed" { + return nil + } + if err == nil && (result.Outcome == "rejected" || result.Outcome == "failed") { + return &runtimeStepFailure{} + } + // Keep transport details and Runtime-native text out of public errors. + return errors.New("environment capability preparation unconfirmed") +} diff --git a/services/agents-api/internal/execution/runtime_setup_test.go b/services/agents-api/internal/execution/runtime_setup_test.go index b64d0a0aa..4fa5de2dd 100644 --- a/services/agents-api/internal/execution/runtime_setup_test.go +++ b/services/agents-api/internal/execution/runtime_setup_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -29,7 +29,7 @@ func (p receiptProvider) RunCommand(context.Context, sandbox.Reference, sandbox. // Other receipt fields are ignored, never read; everything else stays generic. func TestRuntimeSetupReceiptOutcomes(t *testing.T) { setup := runtimeSetupOperation{Version: 1, Action: "setup", Network: "enabled", Command: "echo " + setupCanary, CWD: "/workspace", Index: 2} - plugin := runtimeSetupOperation{Capabilities: &agentcapabilities.Operation{Version: 1, Action: "plugin"}} + plugin := runtimeSetupOperation{Capabilities: &proto.CapabilitiesPreparePayload{Action: "plugin"}} unconfirmed := -1 for _, test := range []struct { name string diff --git a/services/agents-api/internal/execution/support.go b/services/agents-api/internal/execution/support.go index 43798f733..9982f8f81 100644 --- a/services/agents-api/internal/execution/support.go +++ b/services/agents-api/internal/execution/support.go @@ -26,7 +26,7 @@ func (p Policy) ValidateSessionConfiguration(engine string, configuration json.R return err } if snapshot.Environment != nil && snapshot.Environment.Type == "self_hosted" { - if snapshot.Daemon != nil || strings.TrimSpace(snapshot.Agent.Model) == "" || snapshot.Environment.WorkspaceDirectory != "/workspace" || len(snapshot.Environment.CapabilityDirectories) != 0 { + if snapshot.Daemon != nil || strings.TrimSpace(snapshot.Agent.Model) == "" || !validSelfHostedPlacement(environmentPlacement{WorkspaceDirectory: snapshot.Environment.WorkspaceDirectory, CapabilityDirectories: snapshot.Environment.CapabilityDirectories}) { return store.ErrInvalidInput } } diff --git a/services/agents-api/internal/store/runtime_capabilities_pending_test.go b/services/agents-api/internal/store/runtime_capabilities_pending_test.go new file mode 100644 index 000000000..6571c94e0 --- /dev/null +++ b/services/agents-api/internal/store/runtime_capabilities_pending_test.go @@ -0,0 +1,82 @@ +package store_test + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestManagedCapabilitiesWaitBeforeInitializationClaim(t *testing.T) { + _, pool := store.NewManagedTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{9}, 32)) + if err != nil { + t.Fatal(err) + } + s := store.NewWithCredentialCipher(pool, cipher) + tenant := uuid.NewString() + session, err := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{ + Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), + Configuration: json.RawMessage(`{"environment":{"type":"openai_hosted"}}`), + Initialization: store.EnvironmentSetup{CapabilityDirectories: []string{"/workspace/generated"}}, + }) + if err != nil { + t.Fatal(err) + } + env, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + provider := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}} + key := uuid.NewString() + worker, _ := managedWorker(t, s, key, provider) + owner, err := worker.ProvisionEnvironment(t.Context(), tenant, env.ID, key) + if err != nil || owner.Initialization != "pending" { + t.Fatal(owner, err) + } + for range 4 { + if err := worker.ReconcileManagedRuntimes(t.Context()); err != nil { + t.Fatal(err) + } + } + owner, err = s.GetRuntimeAllocation(t.Context(), tenant, env.ID) + if err != nil || owner.Initialization != "pending" || owner.State != "running" || provider.writes != 0 || provider.kills != 0 { + t.Fatal("missing socket consumed initialization or requested cleanup", owner, err, provider.writes, provider.kills) + } + if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("ordinary readiness gate bypassed", err) + } +} + +func TestSelfHostedCapabilityConfigurationNeedsNoManagedSetup(t *testing.T) { + s, pool := store.NewManagedTestStore(t) + tenant := uuid.NewString() + configuration := json.RawMessage(`{"environment":{"type":"self_hosted","workspace_directory":"/home/user/project","capability_directories":["/opt/skills"]}}`) + session, err := s.CreateSession(t.Context(), tenant, store.CreateSessionInput{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: configuration}) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + var frozen struct { + Workspace string `json:"workspace_directory"` + Directories []string `json:"capability_directories"` + } + if json.Unmarshal(environment.Configuration, &frozen) != nil || frozen.Workspace != "/home/user/project" || len(frozen.Directories) != 1 || frozen.Directories[0] != "/opt/skills" { + t.Fatal("frozen paths lost") + } + var setupCount int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM environment_setups WHERE session_id=$1", session.ID).Scan(&setupCount); err != nil || setupCount != 0 { + t.Fatal("self-hosted initialization row created", setupCount, err) + } + if _, err := s.GetRuntimeAllocation(t.Context(), tenant, environment.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatal("self-hosted managed allocation created", err) + } +} From 2c3de024809426eb00b819f7fedb408d33489c0e Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 15:38:59 +0000 Subject: [PATCH 21/78] Deliver cancellation before joining pending native receipts --- CONTRIBUTING.md | 7 +- .../dispatch/executor_cancel_receipt_test.go | 249 ++++++++++++++++++ .../internal/dispatch/prepared_handoff.go | 18 +- .../prepared_handoff_mutation_test.go | 30 +-- .../agents-api/tests/official_user_runtime.py | 4 +- 5 files changed, 284 insertions(+), 24 deletions(-) create mode 100644 apps/parsar-daemon/internal/dispatch/executor_cancel_receipt_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb9655194..679da1691 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1758,7 +1758,12 @@ channel, and retains the terminal observation until Start publication, Turn settlement and admitted operation receipts finish. Natural Done never calls Cancel. Input, function and interaction admission close before settlement, and operations already admitted hold their barrier through native receipts and -outbound acknowledgement. Only then forward Done or an applied cancellation +outbound acknowledgement. Send cancellation to the fixed Turn before waiting +for that barrier: a written input may need native interruption to produce its +receipt. Join native settlement, any required confirmed Executor close, output +drain and all admitted operations before an applied acknowledgement or reuse. +A failed Close may report failure while retaining the same Run and outstanding +operations for retry. Closing a caller wait cannot manufacture an applied input receipt. Only then forward Done or an applied cancellation receipt. Commit native continuity and release the old Run admission before publishing Done, since the receiver may immediately start another Turn. A late terminal-send failure belongs to the old Run; it cannot invalidate a successor diff --git a/apps/parsar-daemon/internal/dispatch/executor_cancel_receipt_test.go b/apps/parsar-daemon/internal/dispatch/executor_cancel_receipt_test.go new file mode 100644 index 000000000..8ea3b6ee2 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/executor_cancel_receipt_test.go @@ -0,0 +1,249 @@ +package dispatch_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +type receiptCancelSender struct { + *recSender + entered, release chan struct{} + once sync.Once +} + +func (s *receiptCancelSender) Send(ctx context.Context, env proto.Envelope) error { + if env.Type == proto.TypePromptSteerAck { + var ack proto.PromptSteerAckPayload + _ = env.DecodePayload(&ack) + if ack.InputID == "pending" && !ack.Written { + s.once.Do(func() { close(s.entered) }) + select { + case <-s.release: + case <-ctx.Done(): + return ctx.Err() + } + } + } + return s.recSender.Send(ctx, env) +} + +type receiptCancelExecutor struct { + turn chan *receiptCancelTurn + current *receiptCancelTurn + cancelFails, closeFailsFirst bool + closeEntered, closeRelease chan struct{} + closes atomic.Int32 + closeOnce sync.Once +} + +func (e *receiptCancelExecutor) StartTurn(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + turn := &receiptCancelTurn{id: id, out: out, owner: e, written: make(chan struct{}), cancelled: make(chan struct{}), closed: make(chan struct{}), settled: make(chan struct{}), awaiting: make(chan struct{})} + e.current = turn + e.turn <- turn + return turn, nil +} +func (e *receiptCancelExecutor) Close(context.Context) error { + attempt := e.closes.Add(1) + if e.closeFailsFirst && attempt == 1 { + return errors.New("native close unconfirmed") + } + e.closeOnce.Do(func() { close(e.closeEntered) }) + <-e.closeRelease + if e.current != nil { + e.current.closeOnce.Do(func() { close(e.current.closed) }) + e.current.finish() + } + return nil +} + +type receiptCancelTurn struct { + id string + out chan<- proto.Envelope + owner *receiptCancelExecutor + written, cancelled, closed, settled, awaiting chan struct{} + cancelOnce, closeOnce, finishOnce, terminalOnce, awaitOnce sync.Once + cancels atomic.Int32 +} + +func (t *receiptCancelTurn) terminal() { + t.terminalOnce.Do(func() { event, _ := proto.NewEnvelope(proto.TypeDone, t.id, t.CancellationOutcome()); t.out <- event }) +} +func (t *receiptCancelTurn) finish() { + t.finishOnce.Do(func() { t.terminal(); close(t.out); close(t.settled) }) +} +func (t *receiptCancelTurn) Cancel(context.Context) error { + t.cancels.Add(1) + t.cancelOnce.Do(func() { close(t.cancelled) }) + if t.owner.cancelFails { + return errors.New("native cancellation unconfirmed") + } + t.finish() + return nil +} +func (t *receiptCancelTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + t.awaitOnce.Do(func() { close(t.awaiting) }) + select { + case <-t.settled: + return agent.TurnSettlement{Reusable: !t.owner.cancelFails}, nil + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} +func (t *receiptCancelTurn) CancellationOutcome() proto.DonePayload { + return proto.DonePayload{Content: "observed", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-session"}} +} +func (t *receiptCancelTurn) Steer(ctx context.Context, input proto.PromptSteerPayload) error { + return t.SteerWithReceipt(ctx, input, nil) +} +func (t *receiptCancelTurn) SteerWithReceipt(ctx context.Context, _ proto.PromptSteerPayload, written func()) error { + written() + close(t.written) + native := t.cancelled + if t.owner.cancelFails { + native = t.closed + } + select { + case <-native: + case <-ctx.Done(): + return ctx.Err() + } + return errors.New("native input consumption is unknown") +} + +func TestExecutorCancellationReachesNativeBeforeDurableReceiptJoin(t *testing.T) { + for _, mode := range []string{"cancel", "done_then_cancel", "cancel_failure", "close_failure_retry"} { + t.Run(mode, func(t *testing.T) { + sender := &receiptCancelSender{recSender: &recSender{}, entered: make(chan struct{}), release: make(chan struct{})} + owner := &receiptCancelExecutor{turn: make(chan *receiptCancelTurn, 2), cancelFails: mode == "cancel_failure" || mode == "close_failure_retry", closeFailsFirst: mode == "close_failure_retry", closeEntered: make(chan struct{}), closeRelease: make(chan struct{})} + reg := agent.NewRegistry() + reg.RegisterKind(proto.SupportedAgentKind{Kind: "reusable", Available: true, Capabilities: proto.AgentKindCapabilities{EnvironmentNone: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("legacy factory forbidden") + }) + reg.RegisterExecutor("reusable", func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return owner, nil }) + r, err := dispatch.New(dispatch.Config{Registry: reg, Sender: sender, IdleTimeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + var receiptOnce, closeOnce sync.Once + releaseReceipt := func() { receiptOnce.Do(func() { close(sender.release) }) } + releaseClose := func() { closeOnce.Do(func() { close(owner.closeRelease) }) } + t.Cleanup(func() { + releaseReceipt() + releaseClose() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := r.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + wait := func(ch <-chan struct{}, reason string) { + t.Helper() + select { + case <-ch: + case <-time.After(time.Second): + t.Fatal(reason) + } + } + p := executorAdmission(t, r, sender.recSender, "first", executorRequest()) + startExecutorTurn(t, r, sender.recSender, "first", "run", p) + turn := <-owner.turn + input := proto.PromptSteerPayload{InputID: "pending", Input: proto.TextInput("second input"), DurableReceipt: true} + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptSteer, "run", input)); err != nil { + t.Fatal(err) + } + wait(turn.written, "input did not reach native write") + if mode == "done_then_cancel" { + turn.terminal() + wait(turn.awaiting, "natural Done did not begin settlement") + } + cancel := func(id string) { + t.Helper() + if err := r.Handle(t.Context(), mustEnv(t, proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: id})); err != nil { + t.Fatal(err) + } + } + cancel("cancel") + wait(turn.cancelled, "receipt barrier prevented native cancellation") + acknowledgements := 0 + if mode == "close_failure_retry" { + waitFor(t, func() bool { return len(cancellationAcks(sender.recSender)) == 1 }, "failed Close acknowledgement") + ack := cancellationAcks(sender.recSender)[0] + if ack.Applied || ack.Outcome != nil || r.ActiveRuns() != 1 { + t.Fatal("failed Close released ownership or fabricated cancellation") + } + acknowledgements = 1 + cancel("retry") + } + if owner.cancelFails { + wait(owner.closeEntered, "failed cancellation did not reach resource cleanup") + if r.ActiveRuns() != 1 || len(cancellationAcks(sender.recSender)) != acknowledgements { + t.Fatal("cleanup was acknowledged before resource close") + } + releaseClose() + } + wait(sender.entered, "native cancellation did not unblock the written input") + if r.ActiveRuns() != 1 || len(cancellationAcks(sender.recSender)) != acknowledgements { + t.Fatal("cancellation passed an uncommitted input receipt") + } + for _, env := range sender.snapshot() { + if env.Type == proto.TypeDone { + t.Fatal("Done preceded the admitted input receipt") + } + } + releaseReceipt() + waitFor(t, func() bool { + return len(cancellationAcks(sender.recSender)) == acknowledgements+1 && r.ActiveRuns() == 0 && hasFrame(sender.recSender, proto.TypeDone, "run") + }, "joined cancellation") + frames := sender.snapshot() + inputAt, doneAt, cancelAt := -1, -1, -1 + for i, env := range frames { + switch env.Type { + case proto.TypePromptSteerAck: + var ack proto.PromptSteerAckPayload + _ = env.DecodePayload(&ack) + if !ack.Written { + if ack.Accepted || ack.ErrorCode != "outcome_unknown" { + t.Fatalf("unknown input fabricated receipt: %+v", ack) + } + inputAt = i + } + case proto.TypeDone: + doneAt = i + case proto.TypeInteractionDecisionAck: + cancelAt = i + } + } + if inputAt < 0 || doneAt < inputAt || cancelAt < inputAt || !owner.cancelFails && cancelAt < doneAt { + t.Fatalf("receipt/Done/cancel order=%d/%d/%d", inputAt, doneAt, cancelAt) + } + ack := cancellationAcks(sender.recSender)[acknowledgements] + if ack.Applied == owner.cancelFails || (ack.Outcome != nil) == owner.cancelFails { + t.Fatalf("cancellation evidence changed during cleanup: %+v", ack) + } + if !owner.cancelFails { + req := executorRequest() + req.Configuration.AgentSessionID = "native-session" + req.Configuration.RequireExistingNativeSession = true + next := executorAdmission(t, r, sender.recSender, "next", req) + if next.ExecutorID != p.ExecutorID || !next.Reused { + t.Fatal("healthy cancellation replaced owner") + } + startExecutorTurn(t, r, sender.recSender, "next", "next-run", next) + successor := <-owner.turn + cancel("late-old-run") + if successor.cancels.Load() != 0 || owner.closes.Load() != 0 { + t.Fatal("old cancellation reached successor") + } + successor.finish() + } + }) + } +} diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff.go index 1b845b300..89c3d1862 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff.go +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff.go @@ -53,7 +53,8 @@ type preparedHandoff struct { outputDone chan struct{} // Every admitted native mutation holds a read lock through its receipt. - // A release attempt takes the write lock before native cancellation. + // Release joins these receipts after native cancellation/settlement, since + // an admitted receipt may itself require cancellation to finish. operations sync.RWMutex published bool // started status committed; protected by Router.mu @@ -164,9 +165,8 @@ func (r *Router) runPreparedRelease(state *sessionState, handoff *preparedHandof } <-handoff.startDone } - // Admission is closed; all earlier mutations retain their native receipt wait. - handoff.operations.Lock() - handoff.operations.Unlock() + // Admission is closed, but native cancellation must be able to release an + // already-written input or tool operation that is waiting for its receipt. turn := handoff.turn var settlement agent.TurnSettlement var nativeErr error @@ -221,6 +221,15 @@ func (r *Router) runPreparedRelease(state *sessionState, handoff *preparedHandof // A failed Turn settlement is not repaired into a cancellation receipt by Close. nativeErr = errors.Join(nativeErr, closeErr) } + if closeErr == nil { + // Native settlement (or confirmed resource close after failure) comes + // first. Keep every admitted operation and its outbound receipt owned + // until it returns; neither cancellation acknowledgement nor reuse can + // pass this join. Failed Close retains the same owner for a later retry. + handoff.operations.Lock() + handoff.operations.Unlock() + <-handoff.outputDone + } r.mu.Lock() attempt.err = nativeErr if closeErr != nil { @@ -236,7 +245,6 @@ func (r *Router) runPreparedRelease(state *sessionState, handoff *preparedHandof release.succeeded, release.outcome = true, outcome close(attempt.done) r.mu.Unlock() - <-handoff.outputDone r.mu.Lock() outputErr, terminal, closed := handoff.outputErr, handoff.terminal, r.closed r.mu.Unlock() diff --git a/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go b/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go index fa0717b4f..fce4c8640 100644 --- a/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go +++ b/apps/parsar-daemon/internal/dispatch/prepared_handoff_mutation_test.go @@ -15,16 +15,12 @@ type preparedMutationSession struct { *fakeSession cancelEntered chan struct{} cancelOnce sync.Once - beforeCancel func() functions atomic.Int32 steers atomic.Int32 reads atomic.Int32 } func (s *preparedMutationSession) Cancel(ctx context.Context) error { - if s.beforeCancel != nil { - s.beforeCancel() - } s.cancelOnce.Do(func() { close(s.cancelEntered) }) return s.fakeSession.Cancel(ctx) } @@ -162,8 +158,11 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { } select { case <-session.cancelEntered: - t.Fatal("native release overtook an admitted receipt") - case <-time.After(50 * time.Millisecond): + case <-time.After(2 * time.Second): + t.Fatal("native cleanup was blocked by the outbound receipt") + } + if r.ActiveRuns() != 1 || hasFrame(sender.recSender, proto.TypeDone, "run") { + t.Fatal("Run ownership or Done overtook the admitted receipt") } close(sender.release) select { @@ -179,7 +178,7 @@ func TestPreparedHandoffReleaseWaitsForMutationReceipt(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("release did not follow the completed receipt") } - waitFor(t, func() bool { return r.ActiveRuns() == 0 }, "mutation release cleanup") + waitFor(t, func() bool { return r.ActiveRuns() == 0 && hasFrame(sender.recSender, proto.TypeDone, "run") }, "mutation release cleanup") if session.cancels() != 1 { t.Fatalf("Session release calls = %d, want 1", session.cancels()) } @@ -239,7 +238,6 @@ func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { t.Run(operation, func(t *testing.T) { sender := &blockingPreparedReceiptSender{recSender: &recSender{}, deliveryID: operation + "-delivery", inputID: operation + "-input", entered: make(chan struct{}), release: make(chan struct{}), exited: make(chan struct{})} defer close(sender.release) - var cancelBeforeReceipt atomic.Bool session := &preparedMutationSession{fakeSession: &fakeSession{closeOutOnCancel: true}, cancelEntered: make(chan struct{})} p := &controlledPreparation{closed: make(chan struct{})} p.start = func(_ context.Context, _ string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Session, error) { @@ -250,13 +248,6 @@ func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { startCancellationPreparation(t, r, sender.recSender) waitPreparationStatus(t, sender.recSender, "request", "started", "") - session.beforeCancel = func() { - select { - case <-sender.exited: - default: - cancelBeforeReceipt.Store(true) - } - } var mutation proto.Envelope switch operation { case "function": @@ -283,8 +274,13 @@ func TestPreparedHandoffRouterShutdownWaitsForReceiptAttempt(t *testing.T) { if err := r.Shutdown(ctx); err != nil { t.Fatal(err) } - if cancelBeforeReceipt.Load() || session.cancels() != 1 || r.ActiveRuns() != 0 { - t.Fatalf("shutdown crossed receipt barrier: early=%t cancels=%d active=%d", cancelBeforeReceipt.Load(), session.cancels(), r.ActiveRuns()) + select { + case <-sender.exited: + default: + t.Fatal("shutdown returned before the receipt attempt ended") + } + if session.cancels() != 1 || r.ActiveRuns() != 0 { + t.Fatalf("shutdown did not join native cleanup: cancels=%d active=%d", session.cancels(), r.ActiveRuns()) } }) } diff --git a/services/agents-api/tests/official_user_runtime.py b/services/agents-api/tests/official_user_runtime.py index 43b3b7631..9101634b0 100644 --- a/services/agents-api/tests/official_user_runtime.py +++ b/services/agents-api/tests/official_user_runtime.py @@ -250,7 +250,9 @@ def check_artifacts(): try: assert expected_environment_type in ("self_hosted", "openai_hosted") - assert session.environment.type == expected_environment_type and session.environment.workspace_directory == "/workspace" + assert session.environment.type == expected_environment_type + if expected_environment_type == "self_hosted": + assert session.environment.workspace_directory == "/workspace" assert client.beta.agents.environments.retrieve(eid).status == "connected", "Runtime is not connected" assert not list(sessions.turns.list(sid)) and not list(sessions.items.list(sid)), "Use an unused Session" assert not list(sessions.artifacts.list(sid)), "Use a Session without existing Artifacts" From f9c46331224d589fc823ff69c2bc0fdc21525aec Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 15:55:32 +0000 Subject: [PATCH 22/78] Retry queued Turns after Runtime preparation capacity rejection --- CONTRIBUTING.md | 8 +- .../agents-api/internal/execution/worker.go | 8 + .../internal/store/worker_capacity_test.go | 189 ++++++++++++++++++ 3 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 services/agents-api/internal/store/worker_capacity_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 679da1691..73d7a03ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1785,8 +1785,12 @@ later Turn. Cancellation uses the exact Run identity. Preparations and Start execute outside the receive loop and Router lock. Admission expires after five minutes; retries do not extend that deadline. Bound active preparation and execution separately from idle retained resources, and count -closing or uncertain resources until cleanup succeeds. At most 64 admission -records are retained; old handles never consume replacement admissions. Idle expiry +closing or uncertain resources until cleanup succeeds. A definite +`execution_prepare` rejection with `preparation_capacity` leaves an unclaimed +queued Turn for the existing Worker scheduler to retry, including capacity held +by cleanup. Other errors and uncertain input delivery do not authorize replay. +At most 64 admission records are retained; old handles never consume replacement +admissions. Idle expiry is a Runtime resource policy, not Core active-Turn concurrency. Shutdown tracks and closes active and idle Executors, retains failed close targets, and allows a later serialized retry. Ordinary disconnection closes the failed transport and keeps diff --git a/services/agents-api/internal/execution/worker.go b/services/agents-api/internal/execution/worker.go index 93ebb0524..57d3fabfa 100644 --- a/services/agents-api/internal/execution/worker.go +++ b/services/agents-api/internal/execution/worker.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -343,6 +344,8 @@ func (w *Worker) runClaim(ctx context.Context, item store.ExecutionWork) error { if err == nil || errors.Is(err, store.ErrTurnConflict) { return nil } + var rejection *preparationRejection + capacityRejected := errors.As(err, &rejection) && rejection.operation == proto.TypeExecutionPrepare && rejection.code == "preparation_capacity" outcome := json.RawMessage(`{"error_code":"execution_unavailable"}`) if errors.Is(err, store.ErrModelProviderRequired) { outcome = json.RawMessage(`{"error_code":"model_provider_required"}`) @@ -353,6 +356,11 @@ func (w *Worker) runClaim(ctx context.Context, item store.ExecutionWork) error { if err != nil { return err } + if turn.Status == store.TurnQueued && capacityRejected { + // No input was sent. Leave durable work for the existing scheduler tick; + // active and cleanup-held Runtime capacity have the same rejection. + return nil + } if turn.Status == store.TurnCompleted || turn.Status == store.TurnFailed || turn.Status == store.TurnCancelled { return nil } diff --git a/services/agents-api/internal/store/worker_capacity_test.go b/services/agents-api/internal/store/worker_capacity_test.go new file mode 100644 index 000000000..ccb884df2 --- /dev/null +++ b/services/agents-api/internal/store/worker_capacity_test.go @@ -0,0 +1,189 @@ +package store_test + +import ( + "fmt" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +// Consume the actual controls so capacity rejection is exercised before any +// input, without the ordinary fixture's automatic preparation acknowledgement. +func capacityWorkerFrames(t *testing.T, h *dispatchHarness) <-chan proto.Envelope { + t.Helper() + frames := make(chan proto.Envelope, 64) + go func() { + defer close(frames) + for { + var frame proto.Envelope + if h.conn.ReadJSON(&frame) != nil { + return + } + select { + case frames <- frame: + case <-t.Context().Done(): + return + } + } + }() + return frames +} + +func TestWorkerDefersPreparationCapacityUntilCleanupReleasesSlot(t *testing.T) { + h := newDispatchHarness(t) + enableWorkerEnvironment(t, h) + h.d.MaxConcurrentExecutions = 5 + sessions := make(map[string]string) + for i := range 5 { + h.session = publicSession(t, h, fmt.Sprintf("capacity-%d", i)) + receipt := h.message("work", "once") + sessions[h.session.ID] = receipt.TurnID + } + frames := capacityWorkerFrames(t, h) + _, stop := startEnvironmentExpiryWorker(t, h.d) + defer stop() + admissions := make(map[string]string) + started := make(map[string]int) + var blocked, first string + capacityReleased := false + rejections := 0 + process := func(frame proto.Envelope) { + switch frame.Type { + case proto.TypeExecutionPrepare: + var prepare proto.ExecutionPreparePayload + if frame.DecodePayload(&prepare) != nil || sessions[prepare.SessionID] == "" || len(prepare.Configuration.Input) != 0 || prepare.Configuration.RunID != "" { + t.Fatal("invalid input-free preparation", prepare) + } + if blocked == "" && len(admissions) == 4 { + blocked = prepare.SessionID + } + if prepare.SessionID == blocked && !capacityReleased { + rejections++ + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{State: "rejected", Operation: proto.TypeExecutionPrepare, ErrorCode: "preparation_capacity"}) + return + } + admissions[frame.ID] = prepare.SessionID + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: frame.ID, ExecutorID: "executor-" + prepare.SessionID, Revision: 1, State: "ready"}) + case proto.TypeExecutionStart: + var start proto.ExecutionStartPayload + session := admissions[frame.ID] + if frame.DecodePayload(&start) != nil || session == "" || start.RunID != sessions[session] || inputTextForTest(t, start.Input) != "once" { + t.Fatal("Start changed the original input or Turn", start) + } + started[session]++ + if started[session] != 1 || (session == blocked && !capacityReleased) { + t.Fatal("capacity rejection replayed or prematurely started input") + } + if first == "" { + first = session + } + h.write(frame.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: start.Handle, ExecutorID: start.ExecutorID, Revision: 2, State: "started", RunID: start.RunID}) + case proto.TypeExecutionRelease: + default: + t.Fatal("unexpected worker frame", frame.Type) + } + } + next := func() { + t.Helper() + select { + case frame, ok := <-frames: + if !ok { + t.Fatal("Runtime connection closed") + } + process(frame) + case <-time.After(5 * time.Second): + t.Fatal("worker did not send preparation or Start") + } + } + for len(started) != 4 || rejections == 0 { + next() + } + assertQueued := func() { + t.Helper() + turn, err := h.s.GetTurn(t.Context(), h.tenant, blocked, sessions[blocked]) + if err != nil || turn.Status != store.TurnQueued { + t.Fatal("capacity rejection failed queued work", turn, err) + } + } + // Observe multiple scheduler ticks with four active native owners. The + // bounded attempt count detects a completion-triggered busy retry loop. + observeCapacity := func() { + t.Helper() + before := rejections + timer := time.NewTimer(800 * time.Millisecond) + defer timer.Stop() + for { + select { + case frame := <-frames: + process(frame) + if rejections-before > 6 { + t.Fatal("capacity rejection caused a busy retry loop") + } + case <-timer.C: + if rejections == before { + t.Fatal("queued work was not retried by scheduler") + } + assertQueued() + return + } + } + } + observeCapacity() + // After completion, Runtime can retire the idle Executor. Its cleanup + // still owns a slot and reports the same capacity rejection. + h.write(sessions[first], proto.TypeDone, proto.DonePayload{Content: "done"}) + awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "first completed Turn", func() bool { + turn, err := h.s.GetTurn(t.Context(), h.tenant, first, sessions[first]) + return err == nil && turn.Status == store.TurnCompleted + }) + observeCapacity() + capacityReleased = true + for started[blocked] == 0 { + next() + } + for session, turn := range sessions { + if session != first { + h.write(turn, proto.TypeDone, proto.DonePayload{Content: "done"}) + } + } + awaitDaemonRemoteCondition(t, t.Context(), 5*time.Second, "all five Turns completed", func() bool { + for session, id := range sessions { + turn, err := h.s.GetTurn(t.Context(), h.tenant, session, id) + if err != nil || turn.Status != store.TurnCompleted { + return false + } + } + return true + }) + if len(started) != 5 || started[blocked] != 1 { + t.Fatal("queued Turn did not start exactly once", started) + } +} + +func TestWorkerDoesNotDeferOtherPreparationOrStartRejections(t *testing.T) { + for _, test := range []struct{ name, operation, code string }{ + {"invalid_configuration", proto.TypeExecutionPrepare, "invalid_configuration"}, + {"resource_unavailable", proto.TypeExecutionPrepare, "resource_unavailable"}, + {"unknown_operation", "", "preparation_capacity"}, + {"start_capacity", proto.TypeExecutionStart, "preparation_capacity"}, + } { + t.Run(test.name, func(t *testing.T) { + h := newDispatchHarness(t) + enableWorkerEnvironment(t, h) + h.session = publicSession(t, h, test.name) + receipt := h.message("work", "once") + frames := capacityWorkerFrames(t, h) + _, stop := startEnvironmentExpiryWorker(t, h.d) + defer stop() + prepare := nextWorkerFrame(t, frames, proto.TypeExecutionPrepare) + if test.operation == proto.TypeExecutionStart { + h.write(prepare.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{Handle: prepare.ID, Revision: 1, State: "ready"}) + nextWorkerFrame(t, frames, proto.TypeExecutionStart) + } + h.write(prepare.ID, proto.TypePreparationStatus, proto.PreparationStatusPayload{State: "rejected", Operation: test.operation, ErrorCode: test.code}) + waitTurn(t, h, receipt.TurnID, store.TurnFailed) + }) + } +} From 996512a917b753d2de0f6dcec51edfcc853fc0f0 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 16:20:06 +0000 Subject: [PATCH 23/78] Regenerate guides and API reference for Runtime preparation --- CONTRIBUTING.md | 6 +- apps/docs/content/docs/admin-api.mdx | 24 ++--- apps/docs/content/docs/agents-and-tools.mdx | 40 ++++----- .../content/docs/api-reference/sessions.mdx | 41 ++++----- .../content/docs/bootstrap-projects-keys.mdx | 4 +- apps/docs/content/docs/concepts.mdx | 6 +- apps/docs/content/docs/configure.mdx | 16 ++-- apps/docs/content/docs/console.mdx | 18 ++-- .../content/docs/environments-and-files.mdx | 90 ++++++++++++++----- apps/docs/content/docs/execution-model.mdx | 6 +- apps/docs/content/docs/hosted-providers.mdx | 8 +- apps/docs/content/docs/index.mdx | 8 +- apps/docs/content/docs/install.mdx | 6 +- apps/docs/content/docs/observability.mdx | 10 +-- apps/docs/content/docs/public-api.mdx | 64 ++++++------- apps/docs/content/docs/quickstart.mdx | 8 +- .../content/docs/self-hosted-execution.mdx | 43 ++++++++- apps/docs/content/docs/sessions.mdx | 26 +++--- apps/docs/content/docs/troubleshooting.mdx | 8 +- apps/docs/content/guide-sources.json | 40 ++++----- apps/docs/openapi/public-api.yaml | 2 +- apps/docs/openapi/sources.json | 6 +- apps/docs/scripts/generate-guides.mjs | 2 +- 23 files changed, 287 insertions(+), 195 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73d7a03ea..db8df5f35 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3939,7 +3939,11 @@ parser writes `installed.json` below the operator-owned capability directory private Runtime state and is never a public input. All adapters consume the same resolved installation root. The manifest binds Session/Environment identities and the ordered source-selection digest. -Filesystem locking prevents overlapping installation. A partial, invalid or foreign +A private completion receipt records only the installation root outside the +installed tree, so a deleted snapshot cannot be mistaken for first preparation. +It is written durably before execution, contains no capability configuration and +is never exposed as a second inventory. Filesystem locking prevents overlapping +installation. A partial, invalid or foreign snapshot fails closed without deleting files or silently reinstalling. Directory sources are copied only during first preparation; reconnect loads protected installed contents and validates the selection without rereading source directories. A new diff --git a/apps/docs/content/docs/admin-api.mdx b/apps/docs/content/docs/admin-api.mdx index ec9674f6b..efd8aeff3 100644 --- a/apps/docs/content/docs/admin-api.mdx +++ b/apps/docs/content/docs/admin-api.mdx @@ -8,7 +8,7 @@ resource inspection, Project and key management, credential issuance, audit, usa and sandbox operations. It never calls `/v1` or `/api/v1`, and has no Agent execution, copy or arbitrary asset editing operation. -Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/core-errors.md), +Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/core-errors.md), including optional typed safe details. Console sign-in endpoints retain their separate error shape described below. @@ -49,9 +49,9 @@ label, but could set any value. Never use it for authorization or as proof of origin. Web calls Core only through the typed Core clients in `packages/agents-client`: -[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/packages/agents-client/src/admin-client.ts) (`/core/v1`), +[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/packages/agents-client/src/admin-client.ts) (`/core/v1`), `SandboxAdminClient` (`/core/v1/sandbox`) and `CoreMetricsClient` -(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md) +(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md) for methods, fields, filters, pagination and response shapes. Routes below are relative to `/core/v1`: @@ -62,8 +62,8 @@ Routes below are relative to `/core/v1`: | Resource lists/details/deletion | `/projects/{id}/agents`, `/sessions`, `/environment-templates`, `/skills`, `/files`, `/vaults`, including the documented nested reads | | Asset ownership | `GET /projects/{id}/resource-owners` with batched resource IDs | | Key operation history | `GET /projects/{id}/write-operations` with key/resource/time filters | -| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-executor-credentials.md)) | -| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/model-execution.md#deployment-defaults)) | +| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-executor-credentials.md)) | +| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/model-execution.md#deployment-defaults)) | | Usage and health | `GET /summary`, `/sandbox/runtime-observations`, `/metrics` | | Administrator audit | `GET /audit-log`; deployment-wide entries have `project_id: null` | @@ -76,9 +76,9 @@ it is operational attribution, not per-key billing. ## Sandbox administration -The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/sandbox-deployment.md), -[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md) -and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/core.openapi.yaml) +The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/sandbox-deployment.md), +[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/core.openapi.yaml) define deployment and node operations: - `GET/POST/PUT /core/v1/sandbox/deployment` and @@ -115,7 +115,7 @@ is a durable serving pin, not proof of current connectivity. Target unknown, fai or update-required state does not by itself invalidate confirmed old-generation service; consume Core's connection/provider facts separately. -Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md#administrative-session-archive) +Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md#administrative-session-archive) at the current generation without reset. History and persisted Files/Artifacts survive; unpersisted workspace is lost and the original Session cannot resume. Cancel reset stops further archives, not cleanup already requested. After zero @@ -156,7 +156,7 @@ generated schema; do not interpret every empty or failed read as an absent resou `GET /core/v1/metrics?range=1h|6h|24h|7d` returns Core process, execution queue/slots, PostgreSQL and background-job measurements. It requires the Core key, rejects arbitrary query filters and never grants -Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/core-metrics.md) +Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/core-metrics.md) for complete buckets, null values, units and process-local retention. Frontend implementation is maintained separately; this backend change does not modify Agent metrics or the public Agent API. @@ -165,7 +165,7 @@ Agent metrics or the public Agent API. The Core key reads a node and its host history through `GET /core/v1/sandbox/nodes/{node_id}?range=1h|6h|24h`. See the -[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/node-host-history.md) +[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/node-host-history.md) for nullable observations, freshness and aggregation. The node list is unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/api/web-management.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/api/web-management.md) diff --git a/apps/docs/content/docs/agents-and-tools.mdx b/apps/docs/content/docs/agents-and-tools.mdx index 100956bd9..fc82c996d 100644 --- a/apps/docs/content/docs/agents-and-tools.mdx +++ b/apps/docs/content/docs/agents-and-tools.mdx @@ -4,7 +4,7 @@ description: "Native harness tools, shared declarations and the limits of execut --- Assessed against Core `206474a4c10901442b1faa094281bfb5559082b5` on 2026-09-22. -The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/upstream.json), Beta +The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/upstream.json), Beta `agents=v1`. This matrix records the bounded execution/tools milestone. It does not establish complete protocol compatibility; the remaining limits below stay open. @@ -22,20 +22,20 @@ and Environment Plugin MCP have separate inventories and qualification. | Operation | Implemented behavior and real qualification | Unsupported or unverified boundary | | --- | --- | --- | -| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | -| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | -| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | -| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | -| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | -| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | +| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | +| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | +| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | +| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | +| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | +| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | | Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. MiniMax functions and self-hosted image workflows are unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | -| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | -| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | -| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | -| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | -| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | -| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | -| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | +| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | +| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | +| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | +| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | +| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | +| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | +| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | ## Required-action recovery contract @@ -52,7 +52,7 @@ application already performed the function, keep and submit that saved result; do not run an external effect again merely because an action remains visible. Core's acknowledgement boundary is native application. For an environment action, connect its exact Environment using the existing enrollment authorization. -[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/store/environment_connections.go) +[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/store/environment_connections.go) fence stale generations; transport connection can clear pre-Turn activity before native preparation. Neither registration nor an action disappearing proves that a model ran successfully. Query the matching Turn and Items for its outcome. @@ -61,8 +61,8 @@ Two timing questions remain open. Repeated `requires_action` notification order and acknowledgement timing are local implementation choices, not proven upstream semantics. Also, an admitted result that is cancelled before native observation can remain internally saved without a public output Item or `item.added`. -[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/internal/store/item_projection.go) and -[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md#public-function-configuration) preserve +[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/store/item_projection.go) and +[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md#public-function-configuration) preserve this gap. Successful retry cannot manufacture the missing observation or establish that the model consumed the result. @@ -82,12 +82,12 @@ not a claim that every historical workflow was rerun at the current baseline. | M2 | `20260922/workspace-images/validation-summary.json`; Codex `public-run-_lr5uiy_`, Claude `public-run-sb65p9e9`; candidate `2c295116d66ed9aafc808ec49a8cb6dcb5c674c2`, merge `1adb2489bc3415039440224e9a7905c53e68a557` ([#17](https://github.com/MiniMax-AI/parsar-core/pull/17)). Kimi K3, Codex 0.153.4, Claude SDK 0.3.269/native 2.1.269; Docker seven-Turn image/workspace workflow. Codex's receipt conclusion is superseded by F2. | | F1 | `20260922/function-result-images/validation-summary.json`, `validated/function-image-public-2567456666/public.json`; candidate `d3cdb225bc7628b968b76c7e1b400101894ce8cd`, merge `fd23f169e1ede1b2f1c39a1d9dcce71886e3c006` ([#16](https://github.com/MiniMax-AI/parsar-core/pull/16)). Claude SDK 0.3.269/native 2.1.269 with Kimi K3 on `none`; success PNG/JPEG, failed text, retries, cancellation, history continuation. Earlier Codex transport-only evidence does not qualify current receipts. | | F2 | `20260922/function-result-receipts/validation-summary.json`, `candidate-public-owner-sdk.log`; hosted `public-run-4fn70foy` (104.13s), `none` `function-image-public-945075091` (83.918s). Candidate `6dceb98e1c56469fcc70cd82025ce77fc870ed3d`, merge `206474a4c10901442b1faa094281bfb5559082b5` ([#20](https://github.com/MiniMax-AI/parsar-core/pull/20)); real Kimi, Codex 0.153.4. Qualifies native receipts, not completed provider consumption. | -| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | +| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | | S1 | `20260922/structured-output/acceptance-summary.json`, `structured-public-1905281777/public.json`; real candidate `7f533d46c472f3cdf7c16ce9471c225a2ba7eded`, merge `8716e699dbc34904499c94b6b0b03857bbebfaad` ([#11](https://github.com/MiniMax-AI/parsar-core/pull/11)). Claude/Kimi `none` schema/function execution and cold continuation. Schema-specific active steering was not separately qualified here. | | S2 | `20260922/hosted-structured-output/validation-summary.json`, `validated/public-inline.log`, `public-run-qvq3csf1` (186.09s); candidate `f6d2c3f2a1dc2ff5f177213bd2b20d78496011d0`, merge `aa85d8ecc60e0a8b7f2494b73caa81216ee197e2` ([#18](https://github.com/MiniMax-AI/parsar-core/pull/18)). Claude SDK 0.3.269/native 2.1.269, real Kimi; Docker saved/inline schema, prepared/active input, native files and four completed structured answers. | | D1 | `20260922/deferred-tools/tool-search-public-2039155387/public.json`, `public-image-isolated-tests.log` (86.71s); merge `178507ae7a63d4068e1e82bef9cd256ba398ae00` ([#13](https://github.com/MiniMax-AI/parsar-core/pull/13)). Claude SDK 0.3.269/native 2.1.269/Kimi K3 `none`; text results with initial/active PNGs, cancellation and cold continuation. `claude-native-1790052750` separately records provider schema inventories and native feasibility. | | P1 | `20260922/tool-policy/acceptance.json`, `server-evidence/tool-policy-{codex-2495445746,claude_sdk-2539346823,mcode-3747575401}/public.json`; candidate `eeb432c7495265ae3a9309e32f5b4323611c3245`, merge `4b8754a6eda6696d9b18eb8d583953ac16c6800f` ([#14](https://github.com/MiniMax-AI/parsar-core/pull/14)). Codex/Claude Kimi K3, MiniMax M2.7; four `none` Sessions per harness, native disable controls and cold continuation. | -| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | +| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | | E2 | `20260922/execution-tools-closure/environment-actions/result.json` contains Codex/Claude Kimi passes (13 checks each) and the retained MiniMax direct-network failure; `mcode-relay-retry/result.json` separately passes MiniMax M2.7 (13 checks) after correcting provider routing. Source `206474a`; same shared SDK/raw workflow, isolated Core/PostgreSQL and user-managed Docker. Covers initial creation SSE, client disconnect, exact pending Environment/no Turn or Items, scoped enrollment, one actual completed model Turn and creation retries preserving history. This is connection-action qualification, not another full lifecycle or E2B regression. | Controlled tests establish parser, projection, atomicity and race behavior; native @@ -121,4 +121,4 @@ remain separate limitations. They do not authorize silently relabeling an unprov principal workflow as deferred. OAuth, full Usage, release publication, new tool kinds and new execution loops are outside this closure batch. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/execution-tools.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/execution-tools.md) diff --git a/apps/docs/content/docs/api-reference/sessions.mdx b/apps/docs/content/docs/api-reference/sessions.mdx index 2aa51289f..5de4d4f81 100644 --- a/apps/docs/content/docs/api-reference/sessions.mdx +++ b/apps/docs/content/docs/api-reference/sessions.mdx @@ -51,26 +51,27 @@ _openapi: startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and - native OAuth login remain unsupported. The self_hosted profile - requires Codex, an absolute workspace_directory and empty - capability_directories, with optional non-deferred function tools and - HTTP MCP using service origin, optionally authenticated by the - attached Vault rules. Remote MCP and remote Bearer authentication each - require separately advertised combination support; old peers cannot - receive unsupported work. Omitted/null capability_directories use the - empty-list default; self_hosted requires configured execution plus - executor registry. Claude SDK currently requires medium verbosity and - object-root function schemas. It supports anonymous or attached - static-bearer service-origin HTTP MCP on none with boolean required - and separately advertised MCP/bearer/required runtime support. - Required servers must be connected before the first native input is - released; pending or failed startup rejects execution. The shared - Vault selection and immutable binding rules apply; unsupported native - labels/tool names reject before persistence. An attached Vault with no - matching credential may remain anonymous; missing keys or failed - credential lookup/decryption never fall back to anonymous execution. - Omitted stream defaults to false; stream and agent_id cannot be null. - Metadata may be null; non-string values and limit violations return + native OAuth login remain unsupported. The self_hosted profile uses a + qualified native harness, a clean absolute workspace_directory and + optional absolute local capability_directories prepared by Runtime, + with optional non-deferred function tools and HTTP MCP using service + origin, optionally authenticated by the attached Vault rules. Remote + MCP and remote Bearer authentication each require separately + advertised combination support; old peers cannot receive unsupported + work. Omitted/null capability_directories use the empty-list default; + self_hosted requires configured execution plus executor registry. + Claude SDK currently requires medium verbosity and object-root + function schemas. It supports anonymous or attached static-bearer + service-origin HTTP MCP on none with boolean required and separately + advertised MCP/bearer/required runtime support. Required servers must + be connected before the first native input is released; pending or + failed startup rejects execution. The shared Vault selection and + immutable binding rules apply; unsupported native labels/tool names + reject before persistence. An attached Vault with no matching + credential may remain anonymous; missing keys or failed credential + lookup/decryption never fall back to anonymous execution. Omitted + stream defaults to false; stream and agent_id cannot be null. Metadata + may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and diff --git a/apps/docs/content/docs/bootstrap-projects-keys.mdx b/apps/docs/content/docs/bootstrap-projects-keys.mdx index c99088ea3..5060f4d64 100644 --- a/apps/docs/content/docs/bootstrap-projects-keys.mdx +++ b/apps/docs/content/docs/bootstrap-projects-keys.mdx @@ -69,7 +69,7 @@ and display only. Keep deployment, application, node and provider credentials ou Installation creates no Project or application key. An administrator creates a Project and issues named keys on the console's **Projects and keys** page, which -uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md), or with that +uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md), or with that API directly. The Project owns one tenant and one principal; its keys share assets and permissions. Writes retain each key's provenance. All Projects and application keys live in @@ -98,4 +98,4 @@ management request points to the console's Core key or Core connection. A resour deletion conflict must remain visible; it does not authorize an execution call. Node and daemon `/api/v1` routes and native Runtime interfaces retain their own authentication. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/web/core-connection.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/web/core-connection.md) diff --git a/apps/docs/content/docs/concepts.mdx b/apps/docs/content/docs/concepts.mdx index e48f1a37f..209c77792 100644 --- a/apps/docs/content/docs/concepts.mdx +++ b/apps/docs/content/docs/concepts.mdx @@ -83,12 +83,12 @@ ownership. No request bodies, secrets or file contents enter audit records. Do not add product users, RBAC, cross-Project shared assets, administrator execution, or old private-protocol compatibility to this management model. Existing public -execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/CONTRIBUTING.md). +execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/CONTRIBUTING.md). Native failure classification is adapter-owned and uses finite structured native values. Optional Runtime error metadata is normalized once and retained through normal delivery and journal draining; it never replaces Core terminal authority, cancellation receipts, Usage or native identity. See -[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/native-error-classification.md). +[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/native-error-classification.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/design-principles.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/design-principles.md) diff --git a/apps/docs/content/docs/configure.mdx b/apps/docs/content/docs/configure.mdx index 798ea32fe..e5a1fa0ad 100644 --- a/apps/docs/content/docs/configure.mdx +++ b/apps/docs/content/docs/configure.mdx @@ -116,7 +116,7 @@ output or in Core's settings snapshot. Model providers are not process settings; The schema is -[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/deploy/install/config.schema.json); each +[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/deploy/install/config.schema.json); each installation keeps a copy in `generated/config.schema.json` for editors. Core serves the non-secret settings snapshot, with the path of `config.json` and the apply command, at `GET /core/v1/installation`. @@ -145,7 +145,7 @@ Which harnesses are enabled, and the default one, are process settings Exactly one provider serves the deployment: Docker, microsandbox or E2B. The deployment specification holds the per-sandbox resources and, for Docker and microsandbox, the Runtime release. Web's setup proposes the Standard size from -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/apps/web/src/features/sandbox/standard-sizes.json) and +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/apps/web/src/features/sandbox/standard-sizes.json) and derives Small and Large from it; the installer applies the bundle's copy of the same file. The database owns the saved values. @@ -183,8 +183,8 @@ Current Runtime generation coexistence and ownership-scoped garbage collection remain supported. They do not upgrade the installed node program or convert an old installation. History stays, and existing Sessions never move between providers. See the [nodes guide](/hosted-providers#change-the-sandbox-backend-or-size), -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) -and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/sandbox-deployment.md). +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/sandbox-deployment.md). ### Node capacity @@ -211,7 +211,7 @@ Session freezes the provider it starts with, so a later change affects only new Sessions. Self-hosted Sessions never use it. A hosted or self-hosted Session with no provider is refused with 400 `model_provider_required`. See [model providers](/quickstart#model-providers) and the -[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/model-execution.md#deployment-defaults). +[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/model-execution.md#deployment-defaults). The operator file `AGENTS_API_EXECUTION_OPTIONS_FILE` is retired; see [installation version policy](/troubleshooting#installation-version-policy). @@ -231,7 +231,7 @@ A Web-only installation has only `secrets/core.key`, a copy of its Core's key. Core reads only its environment. The installer renders `generated/core.env` from `config.json`; if you run Core yourself (see -[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/maintainers.md)), set these variables. Compose +[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/maintainers.md)), set these variables. Compose loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose must be 2.26.0 or newer. @@ -246,7 +246,7 @@ loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose mu | `OAC_INSTALLATION_ID` | `state.json`; pinned to the database | | `OAC_SETTINGS_FILE` | `generated/settings.json`, the snapshot Core serves at `GET /core/v1/installation`; Core does not act on it | | `OAC_EXECUTION_CONCURRENCY`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS` | The matching `core.*` settings | -| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime-history-api.md) | +| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime-history-api.md) | | `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | `log.*`; Web reads the same three | | `OAC_E2B_STATE_DIR`, `OAC_E2B_PROVIDER_BIN` | The E2B receipt directory and, for native Core, the bundled helper. The E2B key and template live in the database | @@ -268,4 +268,4 @@ paths must be canonical absolute paths without control characters, quotes, backs or wildcards. Keep the installation ID and the database together; Core refuses a missing installation ID when its database already has a deployment. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/configuration.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/configuration.md) diff --git a/apps/docs/content/docs/console.mdx b/apps/docs/content/docs/console.mdx index 544c13e9a..62a345834 100644 --- a/apps/docs/content/docs/console.mdx +++ b/apps/docs/content/docs/console.mdx @@ -27,7 +27,7 @@ management client, and it sends nothing to `/v1`. | Platform | Projects and keys, Nodes, System | Project and key lifecycle; sandbox deployment and nodes; System: the installation's public address, API base URL, ID and source commit (read-only), each harness's default model provider (write-only key) beside its read-only startup state, the sandbox configuration every Project shares: provider, sandbox size, Runtime or E2B template build, idle suspension and reset, and Core's config.json startup settings with where to change them | Missing data is shown as missing (—), never as zero. How each figure is read and -bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/web/protocol-coverage.md). +bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/web/protocol-coverage.md). ## Management scope @@ -60,15 +60,15 @@ only the console server sends it to Core. - [Connection and authentication](/bootstrap-projects-keys) - [Architecture and ownership](/execution-model) -- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/web/protocol-coverage.md) -- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/web/roadmap.md) -- [React application](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/apps/web/README.md) +- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/web/protocol-coverage.md) +- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/web/roadmap.md) +- [React application](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/apps/web/README.md) -The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md) defines -management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md) +The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md) defines +management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md) define the separate application interface. See the [design principles](/concepts) -for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/CONTRIBUTING.md) for required checks. +for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/CONTRIBUTING.md) for required checks. -OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/LICENSE). +OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/LICENSE). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/web/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/web/README.md) diff --git a/apps/docs/content/docs/environments-and-files.mdx b/apps/docs/content/docs/environments-and-files.mdx index ff296f227..bfb5e6b36 100644 --- a/apps/docs/content/docs/environments-and-files.mdx +++ b/apps/docs/content/docs/environments-and-files.mdx @@ -3,16 +3,19 @@ title: "Environments and files" description: "Execution placement, Environment resources and workspace ownership." --- -This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/upstream.json), with partial +This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/upstream.json), with partial coverage. Core-managed Docker runs the three qualified native harnesses. V1 user-managed `self_hosted` enrollment uses the same colocated Runtime for Codex, -Claude SDK and MiniMax at `/workspace`; see its [real deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/user-managed-runtime-v1.md). +Claude SDK and MiniMax. `/workspace` names the packaged workspace; a self-hosted +Session may instead select its exact canonical physical directory. The +[recorded deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/user-managed-runtime-v1.md) retains its historical +source, paths and tested capability scope. For hosted compute, the deployment selects E2B, Docker or microsandbox through -[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md). +[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md). For the separate caller-managed E2B path, the user owns allocation, renewal and -cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/deploy/e2b/README.md). -[Templates](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; -[Files](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. +cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/deploy/e2b/README.md). +[Templates](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; +[Files](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. The internal Store now owns a durable Environment association for newly created `self_hosted` and `openai_hosted` snapshots, atomically with Session creation. @@ -30,15 +33,15 @@ readiness. Rotation/revocation, Session deletion and ownership loss deny further access without promising immediate cessation of native effects. Native history remains local to the bound Runtime and cannot be replaced on retry. There is no registry/Noise relay or transient service-side harness credential. -See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/README.md#user-managed-runtime-enrollment). +See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/README.md#user-managed-runtime-enrollment). ## Basic public Docker-hosted profile An explicitly configured default managed Docker provider enables `type=openai_hosted` for the qualified Codex, Claude Code and MiniMax Code profiles. Each uses the same Runtime lifecycle and workspace interfaces with its own native adapter/isolation. -See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. -The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) +See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. +The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) selects the qualified immutable Runtime image; advertised capabilities alone do not enable admission. An idle or initial-text creation commits Session, Environment and retry identity before the existing leased Worker provisions its allocation. @@ -48,9 +51,11 @@ an existing allocation's Create. Omitted/null network defaults to enabled. Enabled, disabled and exact-host restricted policies use the same qualified image with adapter-selected immutable native policy. Templates and inline configuration share initial files, env, packages, ordered setup -and inline or tenant-owned referenced Skills through the hosted initializer. +and inline or tenant-owned referenced Skills through ordered managed initialization. +Provider operations retain file/tool/package/setup work; Runtime imports Skills and +Plugins and finalizes directory snapshots through its authenticated connection. Unsupported hostname forms and installation combinations reject explicitly; see -the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-templates.md). Empty/null installation +the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Service-origin hosted MCP remains unsupported; Environment Plugin MCP has its own qualified transport matrix. @@ -70,18 +75,19 @@ semantics; this profile does not establish complete Environment compatibility. The application creates, renews and destroys its E2B sandbox through the official SDK. It deploys the shared Runtime, then enrolls that Runtime into a `self_hosted` Session. Core neither keeps an E2B allocation nor issues Provider renew/kill calls. -The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/deploy/e2b/README.md) owns packaging and +The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/deploy/e2b/README.md) owns packaging and user-side lifecycle instructions. Expiry or lost workspace/history must not trigger -transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/user-managed-runtime-v1.md) +transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/user-managed-runtime-v1.md) records its own real deployment evidence. -The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md#e2b-v1-qualification) records a historical +The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md#e2b-v1-qualification) records a historical Core-managed route; it does not establish this caller-managed enrollment result. Current deployment-managed E2B is a separate hosted configuration in the manager guide. ## Initial public self-hosted profile Create a Session with `environment.type=self_hosted`, -`workspace_directory: "/workspace"` and omitted/null/empty `capability_directories`. Creation +a clean absolute `workspace_directory` and optional absolute local +`capability_directories`. `/workspace` maps to the Runtime-bound workspace. Creation accepts initial text as a string or ordered user-message array. Omitted/null input creates no Turn or connection action. Configured execution, an enabled harness and the exact local profile are validated @@ -111,7 +117,7 @@ connection alone is not readiness. Retries preserve identity and the five-minute database deadline. HTTP disconnect retains the reservation. Local expired/cancelled outcomes return 409, lost ownership 503, and deletion 404; exact hosted error status/body and pending-input crash recovery are unverified. See the -[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/CONTRIBUTING.md#environment-ownership-and-placement). +[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/CONTRIBUTING.md#environment-ownership-and-placement). Cancellation-only batches use the existing locked admission and native delivery. An idle cancellation creates no Turn, and retry identity preserves the original @@ -125,9 +131,15 @@ existing function parser and remain fixed through native preparation and continu output/error field presence and ordered content keep their existing semantics. Retries retain the original call, including during later work. New results cannot bypass pending input. Function callbacks do not populate Environment installations. -Mixed events, non-text input and nonempty capability directories remain unsupported. -The public field types are unchanged; `/workspace` is the V1 deployment limit, not -an upstream schema change. `remote_url` is the configured daemon WebSocket URL, +Mixed events and non-text input remain unsupported. Local capability directories +use the same Runtime parser and installed snapshot as managed Skills and Plugins; +preparation must finish before native execution. Reconnect reuses installed bytes, +while new Sessions capture their own sources. Directory aliases and protected Runtime +state are rejected. The current implementation uses the packaged Linux Runtime +layout; cross-platform installation and isolation are not part of this profile. +The public field types are unchanged. Enrollment must match either the `/workspace` +logical alias or the exact canonical directory bound by the Runtime; selecting an +arbitrary path does not grant access. `remote_url` is the configured daemon WebSocket URL, returned unchanged. This is our private connection contract and does not claim stock `exec-server` compatibility. Service-origin HTTP MCP is explicitly rejected on `self_hosted`; `none` MCP and hosted Template Plugin MCP retain their own scope. @@ -138,6 +150,42 @@ exercise fixed SDK/raw HTTP creation/input, actual native tools, Files/Artifacts second-Turn history, Core/Runtime restart, cancellation and credential rotation/ revocation/deletion on each declared deployment. +## Runtime capability preparation + +Core freezes resource versions, metadata and source selections. Managed initialization +keeps initial files and tool configuration before Runtime Skill/Plugin import, +then runs packages and ordered setup before Runtime snapshots local capability +directories. Providers do not parse or install capability bundles. Self-hosted input +accepts only local `capability_directories` with `workspace_directory`, not managed +Skill/Plugin archive fields or a hosted Template. Local directory discovery does not +populate the public API-managed `skills` or `plugins` installation arrays. + +Both origins use the packaged Linux Runtime's shared parser and protected +`installed.json`. Its default root is `/environment/initialization/capabilities`; +the operator may freeze another root with `OAC_RUNTIME_CAPABILITY_DIRECTORY`, kept +separate from workspace and private Runtime state. Core and public requests cannot +select this installation root. The manifest binds one +Session and Environment to the source-selection digest. The admitted asynchronous +preparation owner verifies or creates it before native execution, including for an +empty selection. Reconnect and replacement executors reuse installed contents; +source edits are observed only by a new Session with its own Environment and fresh +snapshot. A private completion record retains only the operator installation root, +so loss of a completed snapshot cannot trigger source recapture. Missing, partial or +conflicting snapshots fail without automatic repair or replay. Adapters receive only the resulting Runtime-owned Skill paths and MCP +declarations. Files access retains its separate readiness and authorization. + +Resource management owns allocation and Environment creation, renewal and reclamation. +Executor close, Turn cancellation and transport loss preserve the installed snapshot, +workspace and allocation. Reclamation is an explicit operation coordinated with active +work; a disconnected socket is not proof that native effects have stopped. + +The protocol is platform-neutral, but this implementation retains the existing Linux +helpers, protected initialization storage and isolation requirements. It does not provide +a new native-host installer, another platform layout, or Windows/macOS isolation. +This implementation description does not extend historical real-deployment acceptance; +new managed and self-hosted capability coverage requires its own evidence. See the +[canonical preparation rules](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/CONTRIBUTING.md#shared-runtime-capability-preparation). + ## Contract inventory Paths below follow the SDK resource methods, before the service's `/v1` prefix. @@ -191,7 +239,7 @@ from Session and Turn status. ## Ownership and placement decision -The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/CONTRIBUTING.md#environment-ownership-and-placement) +The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/CONTRIBUTING.md#environment-ownership-and-placement) keep Environment lifecycle common while leaving process placement and native transport to adapters. Logical ownership does not require a machine per object. @@ -215,4 +263,4 @@ native history independently of disposable compute, or prove native restoration; never treat an Environment ID as a filesystem or history backup. Do not silently move an existing Session away from its bound device. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environments.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environments.md) diff --git a/apps/docs/content/docs/execution-model.mdx b/apps/docs/content/docs/execution-model.mdx index 54e8f52ed..45e65ef01 100644 --- a/apps/docs/content/docs/execution-model.mdx +++ b/apps/docs/content/docs/execution-model.mdx @@ -10,7 +10,7 @@ Agents API independently with their own Project keys. The management backend, `AdminClient` and the React console built on them are implemented. The [design principles](/concepts) define identity and authority. -The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md) defines exact +The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md) defines exact routes, payloads, pagination and audit records. ## Request boundaries @@ -106,6 +106,6 @@ comes from the deployment's allocations; the hosted Runtime total counts hosted observation records across projects and reported lifecycle states. These sources have different coverage and refresh independently, so the console does not infer resource retention or cleanup from their difference. -Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/CONTRIBUTING.md). +Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/CONTRIBUTING.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/web/architecture.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/web/architecture.md) diff --git a/apps/docs/content/docs/hosted-providers.mdx b/apps/docs/content/docs/hosted-providers.mdx index c781c135a..088e01fcf 100644 --- a/apps/docs/content/docs/hosted-providers.mdx +++ b/apps/docs/content/docs/hosted-providers.mdx @@ -10,7 +10,7 @@ With E2B you need no nodes. Self-hosted executors, which applications run for th own Sessions, are a different thing; see [Self-hosted executors](/self-hosted-execution). You add a node by generating a command in Web and pasting it on the host. The -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the node protocol, manual registration, placement and failure handling. ## Before you add a node @@ -187,7 +187,7 @@ The installation ID is in the command (`--installation-id`) and on the **System* 1. In Web, open **Nodes** and choose **Remove node** on the node's page, or **Remove** in its list row, then **Confirm removal**. Core refuses while the node still holds sandboxes, snapshots or pending cleanup; let them finish, or - [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) + [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) through the Core API. Removal is permanent: the host can come back only as a new node. 2. Web then shows **Clean up the host** with the uninstall command. Run it on the host: @@ -290,7 +290,7 @@ files. Shared daemon images remain; a host administrator may remove them only af confirming no installation still needs them. Microsandbox image cleanup remains scoped to the installation's private native store and verified ownership receipts. -The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) describes reset and API behavior. Same-team E2B edits have a separate online Core API path and do not involve node installation. @@ -303,4 +303,4 @@ convert or delete it. Preserve the old installation, Runtime resources and histo provision a fresh node separately through **Add node**. The current installer does not adopt historical resources or remove another installation's state. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/getting-started/nodes.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/getting-started/nodes.md) diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 00c7aa681..385f9f6ee 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -19,10 +19,10 @@ applications call Core with. | [API reference](/public-api) | Developers | The `/v1`, `/core/v1` and `/api/v1` namespaces and their contracts | Deeper references: the -[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md) reference for operators, the -[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md), and -[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/maintainers.md) for building distributions +[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md), and +[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/maintainers.md) for building distributions and running Core without the installer. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/getting-started/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/getting-started/README.md) diff --git a/apps/docs/content/docs/install.mdx b/apps/docs/content/docs/install.mdx index e32668a5b..d92d4e684 100644 --- a/apps/docs/content/docs/install.mdx +++ b/apps/docs/content/docs/install.mdx @@ -140,7 +140,7 @@ A deployment runs its sandboxes on exactly one backend: | `none` | Nothing yet | Choose the backend on the **Nodes** page in Web | The Standard sizes come from the bundle's copy of Web's -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/apps/web/src/features/sandbox/standard-sizes.json). The +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/apps/web/src/features/sandbox/standard-sizes.json). The installer saves the choice through the same administrator API that Web uses once the services are healthy. If Core refuses it, for example because E2B rejects the key, the installer prints Core's message and exits with an error; the services keep @@ -164,7 +164,7 @@ it installs anything: ``` Prepare the template build with the -[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/deploy/e2b/README.md). To change the backend or +[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/deploy/e2b/README.md). To change the backend or the sandbox size later, use the Nodes page in Web; see [Nodes](/hosted-providers#change-the-sandbox-backend-or-size). @@ -531,4 +531,4 @@ missing images, restores a missing `oac` command, copies node files newly placed `--install-dir`. It refuses a bundle from another release; see [installation version policy](/troubleshooting#installation-version-policy). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/getting-started/install.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/getting-started/install.md) diff --git a/apps/docs/content/docs/observability.mdx b/apps/docs/content/docs/observability.mdx index 6b12575e5..6f4da2538 100644 --- a/apps/docs/content/docs/observability.mdx +++ b/apps/docs/content/docs/observability.mdx @@ -7,7 +7,7 @@ Status: Phase 2 and initial Core Web consumption implemented. The current-snapsh `packages/agents-client` projection, and generated `core.openapi.yaml` contract are implemented and consumed by the Dashboard through complete Session/observation identity joins. Durable history uses the separate optional -[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside +[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside this phase. These are administrator reads under `/core/v1`, authenticated by the Core key, @@ -92,7 +92,7 @@ Authorization: Bearer ... ``` This returns the same object shape as a list item's `observation`, without the -administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md)). It never starts a Turn, creates +administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md)). It never starts a Turn, creates an Environment, provisions compute, renews a lease, or changes lifecycle state. A valid `environment:none` Session returns `200` with status `unsupported`; the @@ -193,7 +193,7 @@ Use the existing Agents API error envelope. | 400 | `invalid_request_error` / `invalid_request_error` | List: a repeated supported query key, or an empty or invalid limit or order, with the shared Beta list messages. Unknown list query keys are ignored. | | 400 | `invalid_request_error` / `unsupported_parameter` | Single-Session retrieval with any query parameter. | | 401 | `invalid_request_error` / `invalid_admin_key` | Missing or invalid Core key. | -| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | +| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | | 500 | `server_error` / `internal_error` | Integrity, ownership, or invalid provider evidence. | | 503 | `server_error` / `execution_unavailable` | Required Runtime observation service is not configured, or list collection exceeded its request budget. | @@ -273,10 +273,10 @@ deletion makes the candidate incomplete and prevents publication. - Token usage: use existing Session/Turn Usage. - Billing and cost: product/backend concern. - Historical series in these routes: the optional capability uses the separate - [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime-history-api.md). + [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime-history-api.md). - Container logs and command output. - Provider credentials or native configuration. - Start, stop, pause, resume, restart, renew, or delete operations. - Idle classification and automatic shutdown. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime-observability-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime-observability-api.md) diff --git a/apps/docs/content/docs/public-api.mdx b/apps/docs/content/docs/public-api.mdx index 13597b96a..7424c8053 100644 --- a/apps/docs/content/docs/public-api.mdx +++ b/apps/docs/content/docs/public-api.mdx @@ -9,8 +9,8 @@ no credential works in another namespace. | Namespace | Caller | Credential | Contents | Reference | | --- | --- | --- | --- | --- | | `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`) | [Public API](/sessions) | -| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/core.openapi.yaml) | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime.openapi.yaml) | +| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/core.openapi.yaml) | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime.openapi.yaml) | A Project API key gets 401 on `/core/v1` and `/api/v1`; the Core key gets 401 on `/v1` and `/api/v1`. Projects own assets. Multiple equally privileged keys share @@ -34,7 +34,7 @@ with the Core key on Core's loopback port, not through the public entry. Applications call every `/v1` route with a Project API key. The routes are exactly the 58 method and path pairs in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/upstream-routes.json); the +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/upstream-routes.json); the [Public API guide](/sessions) groups them by resource. `/v1` has no Core-only route. @@ -45,18 +45,18 @@ with the Core key. | Routes | Contents | Contract | | --- | --- | --- | -| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/execution-configuration.md) | -| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/session-diagnostics.md) | +| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/execution-configuration.md) | +| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/session-diagnostics.md) | | `projects/{project_id}/sessions/{session_id}/runtime-observation`, `sandbox/runtime-observations` | Current Runtime observations | [Runtime observations](/observability) | -| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime-history-api.md) | -| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-executor-credentials.md) | -| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/installation.md) | -| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/core-metrics.md) | -| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/node-host-history.md) | -| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/model-execution.md#deployment-defaults) | +| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime-history-api.md) | +| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-executor-credentials.md) | +| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/installation.md) | +| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/core-metrics.md) | +| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/node-host-history.md) | +| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/model-execution.md#deployment-defaults) | ## Machine connection API @@ -65,42 +65,42 @@ the Core key or a Project API key. | Routes | Caller | Credential | Contract | | --- | --- | --- | --- | -| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | -| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-executor-credentials.md) | -| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/services/agents-api/README.md#user-managed-runtime-enrollment) | +| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | +| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-executor-credentials.md) | +| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/README.md#user-managed-runtime-enrollment) | ## Contract sources -- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/upstream.json): OpenAI +- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/upstream.json): OpenAI Python SDK 3.13.0, exact upstream commit and `agents=v1`. -- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/upstream-routes.json) and - [fields](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/upstream-fields.json): extracted from the +- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/upstream-routes.json) and + [fields](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/upstream-fields.json): extracted from the pinned SDK by `scripts/extract-agents-api-upstream.py`. Contract tests require the public OpenAPI to have exactly these routes and to keep every other field inside `x_agents_core`. -- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/openapi.yaml): public schema snapshot; - combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/operation-evidence.md). -- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/core.openapi.yaml): generated `/core/v1` +- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/openapi.yaml): public schema snapshot; + combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/operation-evidence.md). +- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/core.openapi.yaml): generated `/core/v1` routes, all authenticated by the Core key. -- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/runtime.openapi.yaml): - generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/node-generation-protocol.md); the private +- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime.openapi.yaml): + generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/node-generation-protocol.md); the private daemon transport is described in the Runtime credential guide. -- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md): Project/key +- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md): Project/key lifecycle, resources, explicit hosted Session archive, summary, errors/deletion preconditions, audit and historical copy provenance. - [Web integration](/admin-api): browser, console and Core boundaries and frontend handoff. -- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/CONTRIBUTING.md): +- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/CONTRIBUTING.md): ownership, security and change requirements. Generated schemas do not establish complete compatibility or real execution -support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md) identifies +support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md) identifies qualified workflows, native differences and unresolved behavior. Update the relevant contract and this index when adding or moving an API surface. -Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/core-errors.md), +Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/core-errors.md), including typed optional safe details and distinct console proxy rejection codes. The public and machine error contracts remain unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/api/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/api/README.md) diff --git a/apps/docs/content/docs/quickstart.mdx b/apps/docs/content/docs/quickstart.mdx index 3df89ed80..785889f14 100644 --- a/apps/docs/content/docs/quickstart.mdx +++ b/apps/docs/content/docs/quickstart.mdx @@ -101,8 +101,8 @@ The provider's protocol must match the harness: | Claude Code (`claude_sdk`) | `anthropic` | The provider's exact model ID | | MiniMax Code (`mcode`) | `anthropic` | `context_window` and `max_output_tokens` | -See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/harness-selection.md) and -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/model-execution.md) for the full contract. +See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/harness-selection.md) and +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/model-execution.md) for the full contract. Selecting a harness doesn't make an unsupported model or operation work. ## Model providers @@ -142,8 +142,8 @@ not replay history. Don't submit the same work as a new Session when a response lost. Files, Artifacts, cancellation and their current limits are in the -[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md). MCP credentials use the +[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md). MCP credentials use the separate Vault API; they are neither Project API keys nor model provider keys. The [API index](/public-api) lists every route. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/getting-started/quickstart.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/getting-started/quickstart.md) diff --git a/apps/docs/content/docs/self-hosted-execution.mdx b/apps/docs/content/docs/self-hosted-execution.mdx index 3c6ae812e..faa45627a 100644 --- a/apps/docs/content/docs/self-hosted-execution.mdx +++ b/apps/docs/content/docs/self-hosted-execution.mdx @@ -98,6 +98,45 @@ Core delivers the Session's model provider only over this Environment's executor connection. The host keeps it in the Runtime's harness home, where the model's tools and the Files API can't read it but the host's owner can. +## Local capability directories + +A self-hosted Session may supply `capability_directories` alongside its +`workspace_directory`, for example: + +```python +environment = { + "type": "self_hosted", + "workspace_directory": "/workspace", + "capability_directories": ["/workspace/capabilities"], +} +``` + +These paths refer to directories visible inside that Runtime. Populate them before +first execution. `/workspace` maps to its bound workspace; an operator-prepared +Runtime may use its exact canonical absolute workspace instead. Directory aliases, +private credentials, native history and protected initialization trees are refused. +This does not mount arbitrary host paths into the container or add a new installer. +Self-hosted public input accepts local directories, not managed Skill/Plugin archives +or hosted Templates. + +The current implementation uses the packaged Linux helpers and protected +`installed.json`, by default under `/environment/initialization/capabilities`. +Operators may freeze another root with `OAC_RUNTIME_CAPABILITY_DIRECTORY`; it must +stay separate from the workspace and private Runtime state and is not a public API +setting. Runtime snapshots sources +before native execution and supplies the resulting Skill and MCP inventory through +the same parser as managed bundles. Reconnect reuses installed contents even after +source edits; a new Session captures its own snapshot. A missing or inconsistent +snapshot fails preparation without silently reinstalling. Local discovery does not +create entries in the public API-managed installation arrays. + +Closing an executor, cancelling a Turn or losing its connection preserves the +snapshot and workspace. The compute owner remains responsible for explicit resource +cleanup. This source-level capability behavior requires its own real acceptance; +[earlier qualification](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/user-managed-runtime-v1.md) covered +only its recorded inputs and binaries, not local directory preparation or additional +platforms. + ## Rotate or revoke | Action in Web | Effect | @@ -143,7 +182,7 @@ key_id=$(python3 -c 'import uuid; print(uuid.uuid4())'); echo "credential ID: $k Rotate with `{"key_id":"…","rotate":true}` on the same route; revoke with `DELETE …/executor-credentials/`. After an uncertain response, list the credentials with `GET` before trying again. The -[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-executor-credentials.md) +[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-executor-credentials.md) has every rule and error. @@ -159,4 +198,4 @@ continuation or automatic data deletion. Credential replacement for executors created by the current installer updates and restarts the same owned container. It does not authorize adopting an older executor. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/getting-started/self-hosted.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/getting-started/self-hosted.md) diff --git a/apps/docs/content/docs/sessions.mdx b/apps/docs/content/docs/sessions.mdx index 28f6e5292..d92f0f337 100644 --- a/apps/docs/content/docs/sessions.mdx +++ b/apps/docs/content/docs/sessions.mdx @@ -34,8 +34,8 @@ leaves the others valid; archiving the Project revokes all its keys. The optiona ## Public resources Paths are relative to `/v1`. Detailed fields, union types and response shapes are -in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/openapi.yaml), fixed SDK source -and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/README.md#upstream-resource-inventory) +in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/openapi.yaml), fixed SDK source +and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md#upstream-resource-inventory) lists each operation and its coverage. | Resource | Route family | Semantics | @@ -44,11 +44,11 @@ lists each operation and its coverage. | Sessions and Turns | `/agents/sessions`, nested `/turns` | Admission, metadata, state and recovery queries | | Items and events | Session `/items`, `/events` | Persisted history and live SSE | | Artifacts | Session `/artifacts` | Captured output metadata, immutable content and deletion | -| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/subagents.md) | -| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-files.md) | -| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-templates.md) | -| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/source-files.md), distinct from live workspace files | -| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/file-resource-semantics.md) | +| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/subagents.md) | +| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-files.md) | +| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-templates.md) | +| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/source-files.md), distinct from live workspace files | +| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/file-resource-semantics.md) | | Vaults and Credentials | `/vaults`, nested `/credentials` | [Scoped write-only credential storage](/environments-and-files) | SSE is live, not a historical replay service. After a disconnect, query the Session, @@ -58,14 +58,14 @@ native execution. Do not automatically replay uncertain tool or file effects. ## Core extension fields The `/v1` route set is exactly the pinned SDK's operations, listed in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/upstream-routes.json); Core adds +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/upstream-routes.json); Core adds no route. It adds fields only inside `x_agents_core`, because it runs several harnesses and accepts custom model access: | Field | Where | Reference | | --- | --- | --- | -| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/harness-selection.md) | -| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/model-execution.md) | +| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/harness-selection.md) | +| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/model-execution.md) | Any other member of `x_agents_core` is rejected with 400. Deployment, placement, credential issuance and operational reads are not part of `/v1`; they belong to @@ -78,7 +78,7 @@ never to `self_hosted`; `none` Sessions accept only the deployment default. `openai_hosted` and `self_hosted` Sessions must end up with one: otherwise creation returns 400 with code `model_provider_required` and param `x_agents_core.model_provider`, and nothing is written. See -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/model-execution.md). +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/model-execution.md). An application reads a Session's model from `agent.model` and an explicitly selected harness from `agent.x_agents_core.harness`; a Session on the deployment @@ -90,8 +90,8 @@ The deployment chooses E2B or its own Docker/microsandbox nodes. A public `self_hosted` Environment uses caller-owned compute and the same colocated Runtime. For a `self_hosted` Session, the deployment operator issues the Environment's executor credential with the Core key; see -[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/environment-executor-credentials.md). +[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-executor-credentials.md). Our daemon transport and the supported native harness differences are explicit; they do not imply interoperability with OpenAI's stock executor transport. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/api/public-agent-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/api/public-agent-api.md) diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx index 5e72d6770..80a3a168d 100644 --- a/apps/docs/content/docs/troubleshooting.mdx +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -123,7 +123,7 @@ refuses. `rotate-core-key` refuses to run on a Web-only installation. ## Projects and API keys Create Projects and issue their keys in Web, on **Projects and keys**, or with the -[Core API](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's +[Core API](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's plaintext is shown once; Core stores only its digest. All keys of a Project share its assets and execution principal, and each write records the key that made it. @@ -241,9 +241,9 @@ Core records which key made each public resource write, for the console. Set `core.write_audit_retention` in `config.json` (Go duration, at least `1h`, default `2160h`) to control how long non-creation history is kept; creation ownership is kept for good. Removing keys or resources does not delete these records. See the -[query contract](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/write-audit.md); administrator mutations +[query contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/write-audit.md); administrator mutations have a separate -[audit log](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither +[audit log](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither logs bodies or secrets. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/cd6a32c3de31d26b73ab92d21ba2aa7b067ba226/docs/getting-started/operations.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/getting-started/operations.md) diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 4459951fd..dc03fbfdf 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -1,5 +1,5 @@ { - "revision": "cd6a32c3de31d26b73ab92d21ba2aa7b067ba226", + "revision": "f9c46331224d589fc823ff69c2bc0fdc21525aec", "sources": { "docs/getting-started/README.md": "4eb3d21e073b25e2e6ffb8800e13b33032104ba44d8d93ea3e59394549dc7962", "docs/design-principles.md": "71285ff0b9a7796481b9fe42f7c4b71d0377c767157980ca6386792d9ca8951b", @@ -11,9 +11,9 @@ "docs/api/README.md": "592fc2aef474e36eb760be0dd1b2269b6766c0d984f826eaf6f47711ea139387", "contracts/agents-api/execution-tools.md": "e0b61f6c0c236c362186c5f6d0ad69a16dd8a8d9afabe1329a1fe22e5f4a71e9", "docs/api/public-agent-api.md": "18829b366bd2e49fe5beb76dc8a709bc1aa8e35d4fbfd2484a0c52b46cf75417", - "contracts/agents-api/environments.md": "89ffc903ee44d00ee87c16950202a3ca4c8345160780dcdd4ed2a71b25b1c2dc", + "contracts/agents-api/environments.md": "1708597619bfe1ca38a42142db46f9ef11a129f56de5451b59092d18c32c7062", "docs/getting-started/nodes.md": "c1bc854c4798b9757bbb8cea4474a03442ba6f4c10444d5505733e8b94046038", - "docs/getting-started/self-hosted.md": "55d6b29b1826f5cb4aee193c4ac9011bfb59d20192938962b2572840c42e242b", + "docs/getting-started/self-hosted.md": "ac46bb2518f7c2f8cb9a53cd0696b5fe442bd99e2fb62a1eba65d39c899e91e7", "apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "docs/web/README.md": "9fce295f6f6ff04acefe338679c1e54723e123dd0027a778202adfa9ab95b6c4", "docs/api/web-management.md": "dc79df33dade6139749752c9ce53d4e126354cfabccada3e7826de4dd20fae62", @@ -22,40 +22,40 @@ "apps/docs/scripts/guides.json": "1f6036e3cccaf5a71cdd9ea790be6f395acdf871c4552c490b6747957a10b174" }, "outputs": { - "content/docs/index.mdx": "0e0f91fcd4a2cdb65a85c39d81c4cc508a5750829380e2c8ad7d2955f16f1330", + "content/docs/index.mdx": "b9c5fafe1cd57a5ecefae7836c5c8bf855b8b16fbc922c0867e90c2d79a94131", "content/docs/index.zh.mdx": "5bdd41893946f1d229eb4cf1599a97f2e38cb9e09722d2a69f62053376ac88a9", - "content/docs/concepts.mdx": "5e20f86486bf9d8b4147241ca124d3fbdf7edee59e5c6bd2ec1f82b4e2adcf10", + "content/docs/concepts.mdx": "ef058d6f37c584fc946f16556ce6b643863f204eca66e38797197bba0b48d313", "content/docs/concepts.zh.mdx": "c60644f395dbac5ee607cdb053249e05ec8352d28de97ef1c38d6c1e4443bbb1", - "content/docs/execution-model.mdx": "ac5bd0d655c5737b00019498be900fe2bf8a680f34e9d34ac08cbc2718ab1d3f", + "content/docs/execution-model.mdx": "33e39c4b439f8aadcca14fedef176b4d470af7e50760d09c1a75ba40140f31ac", "content/docs/execution-model.zh.mdx": "9976a9203c6a83d49c2c753e3a8528a369078e87aeb88850654da948ac8c17e1", - "content/docs/install.mdx": "32d58f72107f79e74f0ab291bc39a6d83908f57330f1ca5ee5c9bcc9d44f9bad", + "content/docs/install.mdx": "09041889ed7157abcfa727ff91aa5640a2404e53ec2e62bc9c316513f2e9ef16", "content/docs/install.zh.mdx": "f207607f0cfceef2012a4e772fc347ef254486327584289fee1c85414dffc907", - "content/docs/configure.mdx": "523a8ed974e82634df690b3396e33e5645db55551e4f34d4e131aa411ff4d218", + "content/docs/configure.mdx": "3e63da5868385af3dd315570dc86d51a784321dcb6300d9c8556711ad08645bf", "content/docs/configure.zh.mdx": "1c4b149c332ade6f2eeca52319e3e7ccfaac3213db2fd084d66e2da517b6bbd6", - "content/docs/bootstrap-projects-keys.mdx": "a88aac6c38abf5237b67a54f59a68b272ecbcd7d53280bb60efb566b14a1b906", + "content/docs/bootstrap-projects-keys.mdx": "2ad2ef37d87e6df51e5a234f0d8b611497872533767a2bbd9031993097c683d5", "content/docs/bootstrap-projects-keys.zh.mdx": "509b4dcea4c865d6834bcaef78baa681180d48274931230f958a39e46011c88f", - "content/docs/quickstart.mdx": "23525ecf2c7d35c8920545a8490ad3f8b3ed7b28ba8eed6f0f794a8d179211c5", + "content/docs/quickstart.mdx": "18a1526b0fc32140c4b85246dc86b230398d80303ffa2fd7c2e1eb055a0bb05e", "content/docs/quickstart.zh.mdx": "c5f119b100b102918a45763834d0ed7fd436479fb411e16a62a408411dbcb3e1", - "content/docs/public-api.mdx": "1f1270ee51840ea1a61f5d6010a1d9b344ab13c800d6f4c14419081cb0fd3ba0", + "content/docs/public-api.mdx": "abe9c3afadb6afd2fc4507a6b9c89ada175363c4aaefa6377b3f371cd75c7671", "content/docs/public-api.zh.mdx": "fb0efb9be2fba3f4a3dc71bf053d2c7eddffbfe010f59fa5a1ae60a3f068545b", - "content/docs/agents-and-tools.mdx": "ebd8239cb0cef20b9104abacd75cb6d6dc37f143347d7e370fa072b9569b1e05", + "content/docs/agents-and-tools.mdx": "20f3f4aa67f01e18ed29494175bd3e88e6176594b380bfa2287e87d7502241f6", "content/docs/agents-and-tools.zh.mdx": "cc8d4e0ef0ea894f068f3bbd143d5ee70c6346bf98f425b1d5fe07ec3931f128", - "content/docs/sessions.mdx": "d742bdb741475e68620a312ca604b13c758aa32b1f7c7fb8ecbde65465fd1ce3", + "content/docs/sessions.mdx": "6e094466de44a327c5cdbae0dcc45178ea7e8244e5667abd69c7bb0682991806", "content/docs/sessions.zh.mdx": "b1b1a7f8aacd9a902320f9692173ede24036124f85761cf02f23ef3ef2bcc4c1", - "content/docs/environments-and-files.mdx": "324b055462ad01e505d225f501bdfb7ccbe0f60f92d583b3a018c08ae2bcc9bd", + "content/docs/environments-and-files.mdx": "c25c722a12e9ac41c50532977bdcb69ab988822a4a50e8fe7ede22d8fd27921b", "content/docs/environments-and-files.zh.mdx": "ee64577220a798f023b330c65302c14438f25a34a78c4e36144bfdb4304a3ae5", - "content/docs/hosted-providers.mdx": "ce582156fe078421312e0ef2e09ea134b98494ab498b6c6b1ca0174c79133297", + "content/docs/hosted-providers.mdx": "762e748a29023ab5f5db18a59c59ab20bda97afad3f15d59f41e33efa019333e", "content/docs/hosted-providers.zh.mdx": "d7f6115f052a09dc943f0ed516ff30a27da5847ed24d387d9d1c7e7faa006ab0", - "content/docs/self-hosted-execution.mdx": "f4f93d8d29a37959b1fdf6f1d0c2e472f72738586990ee74a42901eb4d3f688e", + "content/docs/self-hosted-execution.mdx": "4145809b2d61d4f05f27ccaeaf191794e726e7784fd99a38880c506e292840e6", "content/docs/self-hosted-execution.zh.mdx": "f11d34ce30d48039376256474bc9509a508a2f6b9906f49b5cf6e396707ae5c0", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", - "content/docs/console.mdx": "c4d80ef14420f2206beefa0d6d85bb3e0fbd982f40123f05773d469c4cd238e4", + "content/docs/console.mdx": "f05571cc0a5b0254b49b5d0e0713761a768e9e2d850a462bd0f77f51f3d0cb31", "content/docs/console.zh.mdx": "72f953ba46dcc95933773ac0c4dffdc0d0a499be9d9a1cb9467db2659f1fa10d", - "content/docs/admin-api.mdx": "867eb84df53abf071fadef346d7f9795bfc1be6bad4a0e69475e687a5b0ae16c", + "content/docs/admin-api.mdx": "e0111965a0f1d36ce9765f27b35981e7c28ef0e9aceaff2a8a61847b6feba1d6", "content/docs/admin-api.zh.mdx": "07006de30df3080e54c3cd79ed0ea5337dce557aa2f11afea84edb7f92884d38", - "content/docs/observability.mdx": "60a13191cc35fb7ca0ae8ed411988e085a0cdb9ada642adc3615ef603353ca86", + "content/docs/observability.mdx": "7938fa836c51daf91c7843fa55921485241a87255442917cf74a688229a39be6", "content/docs/observability.zh.mdx": "9574b3412a5754f41ab2d3a16062ac1cad06766be481a8e6bff1b84acbde7e7e", - "content/docs/troubleshooting.mdx": "5fd41876e66537ab7631da818a0c3a3b9cbcc17ce2032da5debf5d7d297ca5ce", + "content/docs/troubleshooting.mdx": "b896d24e469a1659fbc328bbfd5d5022fedab2ee938a16c9a16e3bf11f11345d", "content/docs/troubleshooting.zh.mdx": "6d8b4668e8c371120a9275daabbebc0d1ecf21a42ba32beeb92853c14b5a459a" } } diff --git a/apps/docs/openapi/public-api.yaml b/apps/docs/openapi/public-api.yaml index 29898c58f..090594dd8 100644 --- a/apps/docs/openapi/public-api.yaml +++ b/apps/docs/openapi/public-api.yaml @@ -864,7 +864,7 @@ paths: tags: - Sessions post: - description: The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. + description: The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. parameters: - schema: type: string diff --git a/apps/docs/openapi/sources.json b/apps/docs/openapi/sources.json index bf2a7c62a..d9d0f0b17 100644 --- a/apps/docs/openapi/sources.json +++ b/apps/docs/openapi/sources.json @@ -1,6 +1,6 @@ { "sources": { - "contracts/agents-api/openapi.yaml": "a8896e8b67d78ed0608c7cc5636077ec33f4b4895a04bd6cfb8b038afe941e01", + "contracts/agents-api/openapi.yaml": "e54687e7b3c8cba023432fc5b8ec56ee4e1312f840b8f355b5ca2ddb41917d1d", "contracts/agents-api/core.openapi.yaml": "7801fc77259893815fe9cdcb404f045b375711b9b075eab2b2ed8c5dba55e24b", "contracts/agents-api/runtime.openapi.yaml": "23852205d3b45892f2de9ecf3d52518c0dd0808962c0c512f219b565aa7ccef2" }, @@ -8,7 +8,7 @@ "content/docs/api-reference/agents.mdx": "1f7697959e9c52c9d24fbe70e116e807f49912b9a27ac638b86782e2f326af44", "content/docs/api-reference/environments.mdx": "6a9e08a67479a745983c45b9711137617c13fdf7599a64533997b00a26ac7282", "content/docs/api-reference/environment-templates.mdx": "c1daeaec159aea40b0aa5ed72b1e77ac1715471431b558d0d89e05bfe9ad8945", - "content/docs/api-reference/sessions.mdx": "cbebb6a1705b486fb228cbe0065df893227132f9b3884180a3871a3e8c78f311", + "content/docs/api-reference/sessions.mdx": "9709476af57c645f441bb287c3455ee09dd1fd2d1043558439dc6c32676a3c82", "content/docs/api-reference/artifacts.mdx": "8df8d008126b49b1df88f28a676c4b237ccffc45a470b35ec30eab2daf1c0f7f", "content/docs/api-reference/events.mdx": "83d7f56a788d073e0b19f0bc9197f39ec364375b9cc3a7ec69b79b54bc71e3eb", "content/docs/api-reference/items.mdx": "fa4d248ac5623beb3f00a4ebb5a6c4ff477865ebee351e0c543a970a7ec3654d", @@ -18,7 +18,7 @@ "content/docs/api-reference/skills.mdx": "774ea05a7a8250fe5af5164388b98dafca99d56dc2eae686e8f628acb4e7c10b", "content/docs/api-reference/vaults.mdx": "d80eade1c2f8100030866abbb0fb6b7a09c94250a859c97cdf1abce32fdfdf1e", "content/docs/api-reference/credentials.mdx": "f129a51f74a3c7021c016600518e2350023f8d29571666ac5d64b7584cd7c7d0", - "openapi/public-api.yaml": "84ae8a33717c817950d757990018edd10a4d31d5a343c8172baa099d101c5a1f", + "openapi/public-api.yaml": "17ae24606c26be371ffa0c90904fdba3deac5a744ac985034a68aa35873b9a94", "content/docs/api-reference/meta.json": "56bf1dc145ccc8adde38c5f956f1a06f18ef18b646122250d01a294c516f8f83", "content/docs/api-reference/index.mdx": "9fd6b1c54149874d2999235067c101326f9e8a2e707a70b6672c81eb3ebfe63f", "content/docs/api-reference/core/core-administration.mdx": "5fc3ada51d2190a141f13738151e4d513390cc3bd66ce6f5013994bda68569b0", diff --git a/apps/docs/scripts/generate-guides.mjs b/apps/docs/scripts/generate-guides.mjs index 5e8e157a0..d97d41c28 100644 --- a/apps/docs/scripts/generate-guides.mjs +++ b/apps/docs/scripts/generate-guides.mjs @@ -8,7 +8,7 @@ const app = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const repo = path.resolve(app, '../..') const guides = JSON.parse(fs.readFileSync(path.join(app, 'scripts/guides.json'), 'utf8')) const routes = new Map(guides.map(g => [g.source, g.slug === 'index' ? '/' : '/' + g.slug])) -const sourceRevision = 'cd6a32c3de31d26b73ab92d21ba2aa7b067ba226' +const sourceRevision = 'f9c46331224d589fc823ff69c2bc0fdc21525aec' const sourceURL = relative => `https://github.com/MiniMax-AI/parsar-core/blob/${sourceRevision}/${relative}` const digest = text => crypto.createHash('sha256').update(text).digest('hex') function mdx(text) { From 8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 16:38:32 +0000 Subject: [PATCH 24/78] Route all Runtime initialization through the common daemon protocol --- CONTRIBUTING.md | 71 +++-- README.md | 5 + .../internal/dispatch/capabilities_prepare.go | 186 ------------ .../internal/dispatch/executor.go | 2 +- .../internal/dispatch/preparation.go | 2 +- .../internal/dispatch/preparation_start.go | 2 +- .../parsar-daemon/internal/dispatch/prompt.go | 2 +- .../parsar-daemon/internal/dispatch/router.go | 6 +- .../internal/dispatch/runtime_preparation.go | 197 +++++++++++++ ... => runtime_preparation_execution_test.go} | 2 +- ...re_test.go => runtime_preparation_test.go} | 109 ++++--- .../internal/dispatch/shutdown.go | 10 +- .../internal/dispatch/suspend.go | 2 +- .../internal/dispatch/workspace_write.go | 2 +- .../internal/localworkspace/capabilities.go | 21 +- .../localworkspace/runtime_initialization.go | 11 + .../runtime_initialization_linux.go | 174 +++++++++++ .../runtime_initialization_linux_test.go | 194 ++++++++++++ .../runtime_initialization_other.go | 16 + .../localworkspace/snapshot_marker_test.go | 10 +- contracts/agents-api/environment-templates.md | 90 +++--- contracts/agents-api/environments.md | 16 +- docs/getting-started/self-hosted.md | 3 + internal/agentcapabilities/manifest.go | 6 +- .../agentcapabilities/source_directories.go | 50 ++++ .../source_directories_test.go | 25 ++ .../agentdaemon/gateway/cancellation_test.go | 2 +- .../{capabilities.go => runtime_prepare.go} | 48 +-- ...lities_test.go => runtime_prepare_test.go} | 149 ++++++++-- internal/agentdaemon/gateway/session.go | 2 +- internal/agentdaemon/proto/capabilities.go | 132 --------- .../agentdaemon/proto/capabilities_test.go | 173 ----------- internal/agentdaemon/proto/runtime_prepare.go | 214 ++++++++++++++ .../agentdaemon/proto/runtime_prepare_test.go | 275 ++++++++++++++++++ .../agents-api/deploy/runtime/initialize.py | 27 +- .../deploy/runtime/initialize_receipt_test.py | 18 ++ .../agents-api/deploy/runtime/tool-root.py | 4 +- .../internal/api/environment_request.go | 2 +- .../internal/api/environment_request_test.go | 10 + .../environment_capabilities_test.go | 14 + .../execution/environment_placement.go | 4 +- .../execution/runtime_capabilities_test.go | 41 +-- .../execution/runtime_initialization.go | 95 +----- .../internal/execution/runtime_setup.go | 141 ++++----- .../internal/execution/runtime_setup_test.go | 138 +++------ ...sted_initialization_failure_public_test.go | 90 +++--- .../runtime_capabilities_pending_test.go | 6 +- .../store/runtime_initialization_peer_test.go | 109 +++++++ .../store/runtime_initialization_test.go | 156 +++++++--- .../internal/store/runtime_lifecycle_test.go | 14 +- 50 files changed, 2003 insertions(+), 1075 deletions(-) delete mode 100644 apps/parsar-daemon/internal/dispatch/capabilities_prepare.go create mode 100644 apps/parsar-daemon/internal/dispatch/runtime_preparation.go rename apps/parsar-daemon/internal/dispatch/{capabilities_execution_test.go => runtime_preparation_execution_test.go} (96%) rename apps/parsar-daemon/internal/dispatch/{capabilities_prepare_test.go => runtime_preparation_test.go} (67%) create mode 100644 apps/parsar-daemon/internal/localworkspace/runtime_initialization.go create mode 100644 apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go create mode 100644 apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux_test.go create mode 100644 apps/parsar-daemon/internal/localworkspace/runtime_initialization_other.go create mode 100644 internal/agentcapabilities/source_directories.go create mode 100644 internal/agentcapabilities/source_directories_test.go rename internal/agentdaemon/gateway/{capabilities.go => runtime_prepare.go} (51%) rename internal/agentdaemon/gateway/{capabilities_test.go => runtime_prepare_test.go} (51%) delete mode 100644 internal/agentdaemon/proto/capabilities.go delete mode 100644 internal/agentdaemon/proto/capabilities_test.go create mode 100644 internal/agentdaemon/proto/runtime_prepare.go create mode 100644 internal/agentdaemon/proto/runtime_prepare_test.go create mode 100644 services/agents-api/internal/store/runtime_initialization_peer_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db8df5f35..2d748b377 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,8 @@ Core orchestrates protocol-defined operations. Sandbox providers, Runtime implementations, harnesses and model providers are replaceable components. User-owned machines, E2B, Docker and other environments must expose the same execution protocol. Resource management selects machines, allocates capacity and -creates, renews and reclaims Environments; Runtime prepares capabilities, executes +creates, bootstraps, renews and reclaims Environments; Runtime initializes files, +tool configuration, packages, setup and capabilities, then executes work and recovers within an Environment. Keep these contracts and lifetimes separate: closing a Session executor does not release its allocation, destroy its Environment or delete its workspace. Reclamation is an explicit resource-manager @@ -554,9 +555,11 @@ or load encrypted file bodies. Record original creation intent before resolution Template parsing, persistence and resolution must not select a harness or Provider, or depend on native tool names and private harness paths. The shared initializer uses the packaged Runtime contract for trusted commands, workspace/staging paths, -confidential input and completion receipts. Each Provider supplies that same -Runtime and carries initialization commands through RunCommand; each adapter owns -native tool configuration. A new harness or Provider must not require template +confidential input and completion receipts. Each Provider bootstraps the same +daemon; all initialization uses its authenticated `runtime_prepare` protocol. +The common runner depends on Environment and Session identity and a Runtime peer, +not a Provider, deployment type or operating system. Each adapter owns native tool +configuration. A new harness or Provider must not require template business-logic changes. Reuse qualified shared helpers even when their executable names have historical engine prefixes; renaming is not a boundary fix. Select real regressions by the changed shared, Provider and adapter boundaries, rather than @@ -596,8 +599,9 @@ never from command, package-manager or file output; unknown effects, timeouts an receipts without a status keep a generic reason. New input then gets the observed 409 `conflict_error`; expiry and pending-input settlement keep their behavior. Completed environments never reinstall initial files on reconnect or native recovery. -Provider RunCommand carries bounded stdin, not confidential argv. Only fixed trusted -initializers may run with Runtime authority. User setup and package install hooks +The typed Runtime preparation protocol carries bounded confidential input. The +daemon selects fixed trusted initializers; Core supplies no executable or host +platform field. User setup and package install hooks run in the common packaged sandbox, without daemon credentials or native history. Files, resolved Skills and inline Plugins precede system, npm/Python packages and ordered setup commands. Initialization has provisioning network access; requested network restrictions apply to native tools @@ -728,11 +732,13 @@ omission; expand inline and template initialization together in separately quali batches. Resource reads need only tenant authorization, not a live Runtime. See the [Template coverage and unresolved semantics](contracts/agents-api/environment-templates.md). -SandboxProvider has five operations: Create, GetInfo, Renew, Kill and RunCommand. -Use maintained provider SDKs and thin adapters. Hosted deployments select one deployment-wide Provider: E2B cloud, or Docker/microsandbox on administrator-owned nodes. -Provider initialization creates the sandbox and starts its daemon/harness; -RunCommand is for initialization only. Daily execution and Files use Runtime and -native or bounded local capabilities. Docker's lack of a native renewable lease +SandboxProvider owns Create, GetInfo, Renew and Kill, with placement and daemon +bootstrap at the resource-management boundary. Use maintained provider SDKs and +thin adapters. Hosted deployments select one deployment-wide Provider: E2B cloud, +or Docker/microsandbox on administrator-owned nodes. Providers do not execute Core +initialization commands. Initialization, daily execution and Files use the same +daemon through typed Runtime operations and native or bounded local capabilities. +Docker's lack of a native renewable lease does not remove service-owned hosted expiry and cleanup requirements. The official `openai_hosted` discriminator means hosting by this independent Core @@ -1095,11 +1101,12 @@ change guard. This boundary does not add cross-node Session migration, Core multi-active, autoscaling, Kubernetes or harness residency. The common -`services/agents-api/internal/sandbox` contract owns the five base operations -(Create, GetInfo, Renew, Kill, RunCommand) and the optional CheckpointProvider +`services/agents-api/internal/sandbox` contract owns the four base operations +(Create, GetInfo, Renew, Kill) and the optional CheckpointProvider capability. Core orchestration must not import an adapter or SDK. Exact compute identity, generation construction, inspection, full snapshot capture, restore, -thaw, command execution and owned artifact cleanup use that common capability. +thaw and owned artifact cleanup use that common capability. Initialization and +execution use the authenticated Runtime peer. Provider-specific names and snapshot identities are opaque to Core. Self-hosted compute and providers without checkpoint support keep their existing behavior. @@ -3909,28 +3916,42 @@ Sessions only. Keep the existing product navigation and direct empty-chat compos Resource management retains provider placement, capacity, allocation and Environment create/renew/reclaim operations. The authenticated Runtime connection carries -capability preparation and executor operations; it does not implicitly allocate or -destroy compute. Connection loss, executor idle close and Turn cancellation preserve +initialization, capability preparation and executor operations; it does not implicitly +allocate or destroy compute. Connection loss, executor idle close and Turn cancellation preserve the workspace and installed snapshot. Resource reclamation coordinates with active work through its existing owner. Core freezes resource versions, metadata and local source selections. Managed -initialization keeps its existing order: initial files, provider tool configuration, -Runtime bundle import, provider packages and setup commands, then Runtime directory -snapshot finalization. Provider commands no longer parse or install Skills/Plugins. +initialization keeps its existing order: initial files, tool configuration, +Skill/Plugin bundle import, packages and setup commands, then directory snapshot +finalization. Every step uses the same daemon and `runtime_prepare` exchange. +Providers only place, create, bootstrap, inspect, renew and reclaim resources; they +do not execute Core initialization commands. The common runner uses only neutral +Environment/Session identity and a Runtime peer, with no Provider, deployment or OS +branch. Harness differences belong to native adapters. Package installation and +setup retain enabled provisioning network access; the requested network policy +constrains native execution after setup. A missing authenticated Runtime connection waits before capability initialization is claimed; an operation whose effect is unknown is not replayed. Self-hosted capability directories remain in immutable Environment configuration and never create a managed initialization row or allocation. -The private `capabilities_prepare`/`capabilities_result` exchange transfers inert -Skill/Plugin archives or a finalization selection, with canonical Session and -Environment identities. It accepts no command or destination path. Ordered 64 KiB -chunks and SHA-256 receipts bound each archive to 50 MiB, each control frame to -1 MiB and each connection to one transfer. Begin, chunk and commit are never retried; +The private `runtime_prepare`/`runtime_prepare_result` exchange carries typed +initial files, configure/system/npm/python/setup operations, inert Skill/Plugin +archives and finalization selections, with canonical Session and Environment +identities. Files and setup working directories use logical `/workspace` addresses; +setup commands are explicit typed input, while executable selection and physical +destinations remain Runtime-owned. Ordered 64 KiB chunks and SHA-256 receipts +bound each file or archive to 50 MiB, each control frame to 1 MiB and each connection +to one transfer. Initialization and finalization headers carry no file data. Begin, +chunk and commit are never retried; rejected, failed and unknown effects remain distinct. Runtime owns transfer and filesystem work through settlement, including disconnect or cancellation. +The source-selection protocol accepts portable absolute Unix, Windows drive and +UNC paths without consulting Core's filesystem. Runtime interprets and authorizes +local paths. The current implementation is Linux-only, with its existing packaged +helpers and layout; the interface imposes no Linux executable or platform field. The common Linux binding authorizes local directories, excluding aliases, Runtime credentials, native history and initialization roots or their ancestors. One shared parser writes `installed.json` below the operator-owned capability directory @@ -3962,6 +3983,8 @@ installation arrays describe API-managed uploads and stay empty for local direct MCP environment variables must come from explicit immutable Runtime tool configuration; missing variables never fall back to daemon credentials or ambient process variables. No project-version upgrade or historical manifest compatibility is introduced. +These implementation rules do not establish new real-deployment acceptance; +historical evidence remains limited to its recorded binaries and inputs. ### API-key write provenance diff --git a/README.md b/README.md index fd4c51b5e..c8f255455 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,11 @@ Applications then set `OPENAI_BASE_URL` to `https://core.example/v1` and - **Your sandboxes.** Core-hosted Sessions run in sandboxes on your nodes (Docker or microsandbox microVMs) or on E2B. Applications can also connect their own machines as self-hosted executors. +- **One Runtime path.** The same daemon handles initial files, tool configuration, + packages, setup, Skills/Plugins and execution. Providers place and bootstrap + compute, then renew or reclaim it. The protocol is platform-neutral; the current + packaged implementation and isolation are Linux-only. Harness differences stay + in adapters. - **A console for administrators.** Web signs in with the Core key, issues Project API keys, adds nodes, sets default models and shows metrics and Session history. It never runs Agents on anyone's behalf. diff --git a/apps/parsar-daemon/internal/dispatch/capabilities_prepare.go b/apps/parsar-daemon/internal/dispatch/capabilities_prepare.go deleted file mode 100644 index 118bdb736..000000000 --- a/apps/parsar-daemon/internal/dispatch/capabilities_prepare.go +++ /dev/null @@ -1,186 +0,0 @@ -package dispatch - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/google/uuid" -) - -const capabilitiesPrepareTimeout = 120 * time.Second - -// Router.mu protects one connection-local transfer. Partial installation data -// belongs to the bound Environment and is never removed by transfer cleanup. -type capabilitiesUpload struct { - envelope proto.Envelope - request proto.CapabilitiesPreparePayload - data []byte - ready chan struct{} - cancel context.CancelFunc - finished bool - apply bool - uncertain bool -} - -func (r *Router) handleCapabilitiesPrepare(ctx context.Context, env proto.Envelope) error { - id, err := uuid.Parse(env.ID) - if err != nil || id == uuid.Nil || id.String() != env.ID { - return errors.New("dispatch: invalid capability preparation identity") - } - var request proto.CapabilitiesPreparePayload - if len(env.Payload) > proto.CapabilitiesMaxFrameBytes || env.DecodePayload(&request) != nil || !proto.ValidCapabilitiesPrepareRequest(request) { - r.mu.Lock() - pending := r.capabilitiesPrepare != nil && r.capabilitiesPrepare.envelope.ID == env.ID - if pending && !r.capabilitiesPrepare.finished { - r.finishCapabilitiesUploadLocked(r.capabilitiesPrepare, false) - } - r.mu.Unlock() - if pending { - // A late malformed frame cannot report rejection of an earlier commit. - return errors.New("dispatch: malformed pending capability frame") - } - return r.sendCapabilitiesResult(ctx, env.ID, rejectedCapabilities("invalid_request")) - } - r.mu.Lock() - if r.closed || r.suspension != nil { - r.mu.Unlock() - return ErrRouterClosed - } - if request.Step == "begin" { - if r.capabilitiesPrepare != nil { - duplicate := r.capabilitiesPrepare.envelope.ID == env.ID - r.mu.Unlock() - if duplicate { - return errors.New("dispatch: capability preparation already admitted") - } - return r.sendCapabilitiesResult(ctx, env.ID, rejectedCapabilities("capabilities_capacity")) - } - if r.localWorkspace == nil || !r.localWorkspace.Matches(request.EnvironmentID, request.SessionID) || r.capabilityResourcesBusyLocked() { - r.mu.Unlock() - return r.sendCapabilitiesResult(ctx, env.ID, rejectedCapabilities("resource_unavailable")) - } - owner, cancel := context.WithTimeout(context.WithoutCancel(ctx), capabilitiesPrepareTimeout) - u := &capabilitiesUpload{ - envelope: env, request: request, data: make([]byte, 0, request.SizeBytes), - ready: make(chan struct{}), cancel: cancel, - } - r.capabilitiesPrepare = u - r.shutdownWG.Add(1) - r.mu.Unlock() - go r.runCapabilitiesUpload(owner, u, r.localWorkspace.ApplyCapabilities) - if err := r.sendCapabilitiesResult(ctx, env.ID, proto.CapabilitiesResultPayload{Outcome: "ready"}); err != nil { - cancel() - return err - } - return nil - } - u := r.capabilitiesPrepare - if u == nil || u.envelope.ID != env.ID { - r.mu.Unlock() - return r.sendCapabilitiesResult(ctx, env.ID, rejectedCapabilities("resource_unavailable")) - } - if u.finished { - r.mu.Unlock() - return errors.New("dispatch: capability preparation body already closed") - } - if request.Step == "chunk" && request.Offset == len(u.data) && len(request.Data) <= u.request.SizeBytes-len(u.data) { - u.data = append(u.data, request.Data...) - offset := len(u.data) - r.mu.Unlock() - if err := r.sendCapabilitiesResult(ctx, env.ID, proto.CapabilitiesResultPayload{Outcome: "received", Offset: offset}); err != nil { - u.cancel() - return err - } - return nil - } - apply := false - if request.Step == "commit" && len(u.data) == u.request.SizeBytes { - if u.request.Action == "finalize" { - apply = true - } else { - digest := sha256.Sum256(u.data) - apply = hex.EncodeToString(digest[:]) == u.request.SHA256 - } - } - r.finishCapabilitiesUploadLocked(u, apply) - r.mu.Unlock() - return nil -} - -func (r *Router) capabilityResourcesBusyLocked() bool { - if r.workspaceWrite != nil || r.workspaceExport != nil || len(r.workspaceReads) != 0 || len(r.sessions) != 0 || len(r.idle) != 0 || len(r.executors) != 0 { - return true - } - for _, p := range r.preparations { - if p.owns || p.busy { - return true - } - } - return false -} - -func (r *Router) finishCapabilitiesUploadLocked(u *capabilitiesUpload, apply bool) { - u.apply, u.finished = apply, true - close(u.ready) -} - -// apply must return only after its local mutations stop. Cancellation requests -// shutdown, but cannot release ownership while that call is still running. -func (r *Router) runCapabilitiesUpload(ctx context.Context, u *capabilitiesUpload, apply func(context.Context, proto.CapabilitiesPreparePayload, []byte) error) { - defer r.shutdownWG.Done() - defer u.cancel() - select { - case <-u.ready: - case <-r.shutdownCh: - case <-ctx.Done(): - } - r.mu.Lock() - admitted := u.apply && !r.closed && ctx.Err() == nil - u.finished = true - data := u.data - u.data = nil - r.mu.Unlock() - result := rejectedCapabilities("invalid_request") - if admitted { - result = capabilitiesApplyResult(apply(ctx, u.request, data), u.request.SizeBytes) - } - // Release the potentially large body before waiting on transport delivery. - data = nil - r.mu.Lock() - u.uncertain = result.Outcome == "unknown" - if !u.uncertain && r.capabilitiesPrepare == u { - r.capabilitiesPrepare = nil - } - r.mu.Unlock() - // The result has a separate send budget, independent of an installation timeout. - _ = r.sendCapabilitiesResult(context.WithoutCancel(ctx), u.envelope.ID, result) -} - -func rejectedCapabilities(code string) proto.CapabilitiesResultPayload { - return proto.CapabilitiesResultPayload{Outcome: "rejected", ErrorCode: code} -} - -func capabilitiesApplyResult(err error, size int) proto.CapabilitiesResultPayload { - if err == nil { - return proto.CapabilitiesResultPayload{Outcome: "completed", SizeBytes: size} - } - if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) && errors.Is(err, agentcapabilities.ErrInvalid) { - return proto.CapabilitiesResultPayload{Outcome: "failed", ErrorCode: "capabilities_failed"} - } - return proto.CapabilitiesResultPayload{Outcome: "unknown", ErrorCode: "capabilities_unconfirmed"} -} - -func (r *Router) sendCapabilitiesResult(ctx context.Context, id string, result proto.CapabilitiesResultPayload) error { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - env, err := proto.NewEnvelope(proto.TypeCapabilitiesResult, id, result) - if err != nil { - return err - } - return r.sender.Send(ctx, env) -} diff --git a/apps/parsar-daemon/internal/dispatch/executor.go b/apps/parsar-daemon/internal/dispatch/executor.go index 7eec038e8..afe959a66 100644 --- a/apps/parsar-daemon/internal/dispatch/executor.go +++ b/apps/parsar-daemon/internal/dispatch/executor.go @@ -73,7 +73,7 @@ func (r *Router) handleExecutorPrepare(ctx context.Context, env proto.Envelope, r.mu.Unlock() return ErrRouterClosed } - if r.workspaceWrite != nil || r.workspaceExport != nil || r.capabilitiesPrepare != nil { + if r.workspaceWrite != nil || r.workspaceExport != nil || r.runtimePreparation != nil { r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") } diff --git a/apps/parsar-daemon/internal/dispatch/preparation.go b/apps/parsar-daemon/internal/dispatch/preparation.go index 0e0ae9e67..dd218cd88 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation.go +++ b/apps/parsar-daemon/internal/dispatch/preparation.go @@ -78,7 +78,7 @@ func (r *Router) handleExecutionPrepare(ctx context.Context, env proto.Envelope) r.mu.Unlock() return ErrRouterClosed } - if r.capabilitiesPrepare != nil || ((r.workspaceWrite != nil || r.workspaceExport != nil) && !req.WorkspaceReadOnly) { + if r.runtimePreparation != nil || ((r.workspaceWrite != nil || r.workspaceExport != nil) && !req.WorkspaceReadOnly) { r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") } diff --git a/apps/parsar-daemon/internal/dispatch/preparation_start.go b/apps/parsar-daemon/internal/dispatch/preparation_start.go index 24e5afbbd..74d267cac 100644 --- a/apps/parsar-daemon/internal/dispatch/preparation_start.go +++ b/apps/parsar-daemon/internal/dispatch/preparation_start.go @@ -24,7 +24,7 @@ func (r *Router) handleExecutionStart(_ context.Context, env proto.Envelope) err r.mu.Unlock() return ErrRouterClosed } - if r.workspaceWrite != nil || r.workspaceExport != nil || r.capabilitiesPrepare != nil { + if r.workspaceWrite != nil || r.workspaceExport != nil || r.runtimePreparation != nil { r.mu.Unlock() return r.rejectPreparation(env, "resource_unavailable") } diff --git a/apps/parsar-daemon/internal/dispatch/prompt.go b/apps/parsar-daemon/internal/dispatch/prompt.go index 76dd98bbb..e0b4c07d4 100644 --- a/apps/parsar-daemon/internal/dispatch/prompt.go +++ b/apps/parsar-daemon/internal/dispatch/prompt.go @@ -75,7 +75,7 @@ func (r *Router) handlePromptRequest(callerCtx context.Context, env proto.Envelo r.log.ErrorContext(callerCtx, "handlePromptRequest: router closed", "run_id", runID) return ErrRouterClosed } - if r.capabilitiesPrepare != nil || r.workspaceWrite != nil || r.workspaceExport != nil { + if r.runtimePreparation != nil || r.workspaceWrite != nil || r.workspaceExport != nil { r.mu.Unlock() err := errors.New("local workspace has an unsettled write") r.emitTerminalError(callerCtx, runID, err.Error()) diff --git a/apps/parsar-daemon/internal/dispatch/router.go b/apps/parsar-daemon/internal/dispatch/router.go index 7c6744d1c..0a8a4ca9d 100644 --- a/apps/parsar-daemon/internal/dispatch/router.go +++ b/apps/parsar-daemon/internal/dispatch/router.go @@ -51,7 +51,7 @@ type Router struct { preparations map[string]*preparationState preparationRequests map[string]*preparationState preparationTimeout time.Duration - capabilitiesPrepare *capabilitiesUpload + runtimePreparation *runtimePreparationTransfer workspaceWrite *workspaceUpload workspaceExport *workspaceExport workspaceReads map[string]struct{} @@ -168,8 +168,8 @@ func (r *Router) Handle(ctx context.Context, env proto.Envelope) error { ctx = adoptEnvelopeTrace(ctx, env) switch env.Type { - case proto.TypeCapabilitiesPrepare: - return r.handleCapabilitiesPrepare(ctx, env) + case proto.TypeRuntimePrepare: + return r.handleRuntimePrepare(ctx, env) case proto.TypeWorkspaceExport: return r.handleWorkspaceExport(ctx, env) case proto.TypeWorkspaceWrite: diff --git a/apps/parsar-daemon/internal/dispatch/runtime_preparation.go b/apps/parsar-daemon/internal/dispatch/runtime_preparation.go new file mode 100644 index 000000000..9975e9a41 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/runtime_preparation.go @@ -0,0 +1,197 @@ +package dispatch + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +const runtimePreparationTimeout = 120 * time.Second + +// Router.mu protects one connection-local transfer. Partial installation data +// belongs to the bound Environment and is never removed by transfer cleanup. +type runtimePreparationTransfer struct { + envelope proto.Envelope + request proto.RuntimePreparePayload + data []byte + ready chan struct{} + cancel context.CancelFunc + finished bool + apply bool + uncertain bool +} + +func (r *Router) handleRuntimePrepare(ctx context.Context, env proto.Envelope) error { + id, err := uuid.Parse(env.ID) + if err != nil || id == uuid.Nil || id.String() != env.ID { + return errors.New("dispatch: invalid Runtime preparation identity") + } + var request proto.RuntimePreparePayload + if len(env.Payload) > proto.RuntimePrepareMaxFrameBytes || env.DecodePayload(&request) != nil || !proto.ValidRuntimePrepareRequest(request) { + r.mu.Lock() + pending := r.runtimePreparation != nil && r.runtimePreparation.envelope.ID == env.ID + if pending && !r.runtimePreparation.finished { + r.finishRuntimePreparationTransferLocked(r.runtimePreparation, false) + } + r.mu.Unlock() + if pending { + // A late malformed frame cannot report rejection of an earlier commit. + return errors.New("dispatch: malformed pending Runtime preparation frame") + } + return r.sendRuntimePrepareResult(ctx, env.ID, rejectedRuntimePreparation("invalid_request")) + } + r.mu.Lock() + if r.closed || r.suspension != nil { + r.mu.Unlock() + return ErrRouterClosed + } + if request.Step == "begin" { + if r.runtimePreparation != nil { + duplicate := r.runtimePreparation.envelope.ID == env.ID + r.mu.Unlock() + if duplicate { + return errors.New("dispatch: Runtime preparation already admitted") + } + return r.sendRuntimePrepareResult(ctx, env.ID, rejectedRuntimePreparation("runtime_preparation_capacity")) + } + if r.localWorkspace == nil || !r.localWorkspace.Matches(request.EnvironmentID, request.SessionID) || r.runtimePreparationResourcesBusyLocked() { + r.mu.Unlock() + return r.sendRuntimePrepareResult(ctx, env.ID, rejectedRuntimePreparation("resource_unavailable")) + } + owner, cancel := context.WithTimeout(context.WithoutCancel(ctx), runtimePreparationTimeout) + u := &runtimePreparationTransfer{ + envelope: env, request: request, data: make([]byte, 0, request.SizeBytes), + ready: make(chan struct{}), cancel: cancel, + } + r.runtimePreparation = u + r.shutdownWG.Add(1) + r.mu.Unlock() + go r.runRuntimePreparationTransfer(owner, u, r.localWorkspace.ApplyRuntimePreparation) + if err := r.sendRuntimePrepareResult(ctx, env.ID, proto.RuntimePrepareResultPayload{Outcome: "ready"}); err != nil { + cancel() + return err + } + return nil + } + u := r.runtimePreparation + if u == nil || u.envelope.ID != env.ID { + r.mu.Unlock() + return r.sendRuntimePrepareResult(ctx, env.ID, rejectedRuntimePreparation("resource_unavailable")) + } + if u.finished { + r.mu.Unlock() + return errors.New("dispatch: Runtime preparation body already closed") + } + if request.Step == "chunk" && request.Offset == len(u.data) && len(request.Data) <= u.request.SizeBytes-len(u.data) { + u.data = append(u.data, request.Data...) + offset := len(u.data) + r.mu.Unlock() + if err := r.sendRuntimePrepareResult(ctx, env.ID, proto.RuntimePrepareResultPayload{Outcome: "received", Offset: offset}); err != nil { + u.cancel() + return err + } + return nil + } + apply := false + if request.Step == "commit" && len(u.data) == u.request.SizeBytes { + if u.request.Action == "finalize" || u.request.Action == "initialize" { + apply = true + } else { + digest := sha256.Sum256(u.data) + apply = hex.EncodeToString(digest[:]) == u.request.SHA256 + } + } + r.finishRuntimePreparationTransferLocked(u, apply) + r.mu.Unlock() + return nil +} + +func (r *Router) runtimePreparationResourcesBusyLocked() bool { + if r.workspaceWrite != nil || r.workspaceExport != nil || len(r.workspaceReads) != 0 || len(r.sessions) != 0 || len(r.idle) != 0 || len(r.executors) != 0 { + return true + } + for _, p := range r.preparations { + if p.owns || p.busy { + return true + } + } + return false +} + +func (r *Router) finishRuntimePreparationTransferLocked(u *runtimePreparationTransfer, apply bool) { + u.apply, u.finished = apply, true + close(u.ready) +} + +// apply must return only after its local mutations stop. Cancellation requests +// shutdown, but cannot release ownership while that call is still running. +func (r *Router) runRuntimePreparationTransfer(ctx context.Context, u *runtimePreparationTransfer, apply func(context.Context, proto.RuntimePreparePayload, []byte) error) { + defer r.shutdownWG.Done() + defer u.cancel() + select { + case <-u.ready: + case <-r.shutdownCh: + case <-ctx.Done(): + } + r.mu.Lock() + admitted := u.apply && !r.closed && ctx.Err() == nil + u.finished = true + data := u.data + u.data = nil + r.mu.Unlock() + result := rejectedRuntimePreparation("invalid_request") + if admitted { + result = runtimePreparationResult(apply(ctx, u.request, data), u.request.SizeBytes) + } + // Release the potentially large body before waiting on transport delivery. + data = nil + r.mu.Lock() + u.uncertain = result.Outcome == "unknown" + if !u.uncertain && r.runtimePreparation == u { + r.runtimePreparation = nil + } + r.mu.Unlock() + // The result has a separate send budget, independent of an installation timeout. + _ = r.sendRuntimePrepareResult(context.WithoutCancel(ctx), u.envelope.ID, result) +} + +func rejectedRuntimePreparation(code string) proto.RuntimePrepareResultPayload { + return proto.RuntimePrepareResultPayload{Outcome: "rejected", ErrorCode: code} +} + +func runtimePreparationResult(err error, size int) proto.RuntimePrepareResultPayload { + if err == nil { + return proto.RuntimePrepareResultPayload{Outcome: "completed", SizeBytes: size} + } + var initialization *localworkspace.InitializationFailure + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) && errors.As(err, &initialization) { + code := 0 + if initialization.ExitCode != nil { + code = *initialization.ExitCode + } + if (initialization.ExitCode == nil || code > 0) && code <= 255 { + return proto.RuntimePrepareResultPayload{Outcome: "failed", ErrorCode: "runtime_preparation_failed", ExitCode: code} + } + } + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) && errors.Is(err, agentcapabilities.ErrInvalid) { + return proto.RuntimePrepareResultPayload{Outcome: "failed", ErrorCode: "runtime_preparation_failed"} + } + return proto.RuntimePrepareResultPayload{Outcome: "unknown", ErrorCode: "runtime_preparation_unconfirmed"} +} + +func (r *Router) sendRuntimePrepareResult(ctx context.Context, id string, result proto.RuntimePrepareResultPayload) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + env, err := proto.NewEnvelope(proto.TypeRuntimePrepareResult, id, result) + if err != nil { + return err + } + return r.sender.Send(ctx, env) +} diff --git a/apps/parsar-daemon/internal/dispatch/capabilities_execution_test.go b/apps/parsar-daemon/internal/dispatch/runtime_preparation_execution_test.go similarity index 96% rename from apps/parsar-daemon/internal/dispatch/capabilities_execution_test.go rename to apps/parsar-daemon/internal/dispatch/runtime_preparation_execution_test.go index 4e376303f..eb7d59f7f 100644 --- a/apps/parsar-daemon/internal/dispatch/capabilities_execution_test.go +++ b/apps/parsar-daemon/internal/dispatch/runtime_preparation_execution_test.go @@ -16,7 +16,7 @@ import ( // Directory selection is syntactically valid and passes frozen configuration // admission. Runtime preparation must fail before creating a native Executor. -func TestCapabilitiesUnavailablePreventsNativeExecutor(t *testing.T) { +func TestRuntimePreparationUnavailablePreventsNativeExecutor(t *testing.T) { for _, mode := range []string{"missing-directory", "invalid-skill"} { t.Run(mode, func(t *testing.T) { source := filepath.Join(t.TempDir(), "capability") diff --git a/apps/parsar-daemon/internal/dispatch/capabilities_prepare_test.go b/apps/parsar-daemon/internal/dispatch/runtime_preparation_test.go similarity index 67% rename from apps/parsar-daemon/internal/dispatch/capabilities_prepare_test.go rename to apps/parsar-daemon/internal/dispatch/runtime_preparation_test.go index ea5293575..dd58170e6 100644 --- a/apps/parsar-daemon/internal/dispatch/capabilities_prepare_test.go +++ b/apps/parsar-daemon/internal/dispatch/runtime_preparation_test.go @@ -49,37 +49,37 @@ func capabilitiesTestRouter(t *testing.T) (*Router, *capabilitiesTestSender, str return router, sender, environment, session } -func capabilityEnvelope(t *testing.T, id string, request proto.CapabilitiesPreparePayload) proto.Envelope { +func capabilityEnvelope(t *testing.T, id string, request proto.RuntimePreparePayload) proto.Envelope { t.Helper() - env, err := proto.NewEnvelope(proto.TypeCapabilitiesPrepare, id, request) + env, err := proto.NewEnvelope(proto.TypeRuntimePrepare, id, request) if err != nil { t.Fatal(err) } return env } -func capabilityBegin(environment, session string, body []byte) proto.CapabilitiesPreparePayload { +func capabilityBegin(environment, session string, body []byte) proto.RuntimePreparePayload { digest := sha256.Sum256(body) - return proto.CapabilitiesPreparePayload{ + return proto.RuntimePreparePayload{ Step: "begin", Action: "skill", EnvironmentID: environment, SessionID: session, Skill: &agentskill.Metadata{Type: "inline", Name: "proof", Description: "A proof."}, SizeBytes: len(body), SHA256: hex.EncodeToString(digest[:]), } } -func capabilitiesReceipt(t *testing.T, sender *capabilitiesTestSender, id, outcome string) proto.CapabilitiesResultPayload { +func capabilitiesReceipt(t *testing.T, sender *capabilitiesTestSender, id, outcome string) proto.RuntimePrepareResultPayload { t.Helper() select { case env := <-sender.frames: - var result proto.CapabilitiesResultPayload - if env.Type != proto.TypeCapabilitiesResult || env.ID != id || env.DecodePayload(&result) != nil || result.Outcome != outcome { + var result proto.RuntimePrepareResultPayload + if env.Type != proto.TypeRuntimePrepareResult || env.ID != id || env.DecodePayload(&result) != nil || result.Outcome != outcome { t.Fatalf("unexpected capability receipt: id=%s type=%s result=%+v", env.ID, env.Type, result) } return result case <-time.After(3 * time.Second): t.Fatal("missing capability receipt") } - return proto.CapabilitiesResultPayload{} + return proto.RuntimePrepareResultPayload{} } func shutdownCapabilitiesRouter(t *testing.T, router *Router) { @@ -91,12 +91,12 @@ func shutdownCapabilitiesRouter(t *testing.T, router *Router) { } } -func TestCapabilitiesTransferValidatesCompleteBodyBeforeMutation(t *testing.T) { +func TestRuntimePreparationTransferValidatesCompleteBodyBeforeMutation(t *testing.T) { for _, mode := range []string{"offset", "digest", "short", "oversized-chunk"} { t.Run(mode, func(t *testing.T) { r, sender, environment, session := capabilitiesTestRouter(t) defer shutdownCapabilitiesRouter(t, r) - body := bytes.Repeat([]byte("a"), proto.CapabilitiesChunkBytes+1) + body := bytes.Repeat([]byte("a"), proto.RuntimePrepareChunkBytes+1) request := capabilityBegin(environment, session, body) if mode == "digest" { request.SHA256 = stringsOfZeroDigest() @@ -107,37 +107,37 @@ func TestCapabilitiesTransferValidatesCompleteBodyBeforeMutation(t *testing.T) { } capabilitiesReceipt(t, sender, id, "ready") r.mu.Lock() - owner := r.capabilitiesPrepare + owner := r.runtimePreparation r.mu.Unlock() duplicate := uuid.NewString() if err := r.Handle(t.Context(), capabilityEnvelope(t, duplicate, request)); err != nil { t.Fatal(err) } - if got := capabilitiesReceipt(t, sender, duplicate, "rejected"); got.ErrorCode != "capabilities_capacity" { + if got := capabilitiesReceipt(t, sender, duplicate, "rejected"); got.ErrorCode != "runtime_preparation_capacity" { t.Fatal(got) } switch mode { case "offset": - _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.CapabilitiesPreparePayload{Step: "chunk", Offset: 1, Data: []byte("a")})) + _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.RuntimePreparePayload{Step: "chunk", Offset: 1, Data: []byte("a")})) case "oversized-chunk": - _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.CapabilitiesPreparePayload{Step: "chunk", Data: body})) + _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.RuntimePreparePayload{Step: "chunk", Data: body})) default: - if err := r.Handle(t.Context(), capabilityEnvelope(t, id, proto.CapabilitiesPreparePayload{Step: "chunk", Data: body[:proto.CapabilitiesChunkBytes]})); err != nil { + if err := r.Handle(t.Context(), capabilityEnvelope(t, id, proto.RuntimePreparePayload{Step: "chunk", Data: body[:proto.RuntimePrepareChunkBytes]})); err != nil { t.Fatal(err) } - if got := capabilitiesReceipt(t, sender, id, "received"); got.Offset != proto.CapabilitiesChunkBytes { + if got := capabilitiesReceipt(t, sender, id, "received"); got.Offset != proto.RuntimePrepareChunkBytes { t.Fatal(got) } if mode == "digest" { - _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.CapabilitiesPreparePayload{Step: "chunk", Offset: proto.CapabilitiesChunkBytes, Data: body[proto.CapabilitiesChunkBytes:]})) + _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.RuntimePreparePayload{Step: "chunk", Offset: proto.RuntimePrepareChunkBytes, Data: body[proto.RuntimePrepareChunkBytes:]})) capabilitiesReceipt(t, sender, id, "received") } - _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.CapabilitiesPreparePayload{Step: "commit"})) + _ = r.Handle(t.Context(), capabilityEnvelope(t, id, proto.RuntimePreparePayload{Step: "commit"})) } capabilitiesReceipt(t, sender, id, "rejected") r.mu.Lock() defer r.mu.Unlock() - if owner.apply || owner.data != nil || r.capabilitiesPrepare != nil { + if owner.apply || owner.data != nil || r.runtimePreparation != nil { t.Fatal("invalid body retained or admitted a mutation") } }) @@ -146,7 +146,7 @@ func TestCapabilitiesTransferValidatesCompleteBodyBeforeMutation(t *testing.T) { func stringsOfZeroDigest() string { return hex.EncodeToString(make([]byte, sha256.Size)) } -func TestCapabilitiesBeginRequiresExactBindingAndBounds(t *testing.T) { +func TestRuntimePreparationBeginRequiresExactBindingAndBounds(t *testing.T) { r, sender, environment, session := capabilitiesTestRouter(t) defer shutdownCapabilitiesRouter(t, r) for _, mode := range []string{"environment", "session", "size", "nil-binding"} { @@ -157,7 +157,7 @@ func TestCapabilitiesBeginRequiresExactBindingAndBounds(t *testing.T) { case "session": request.SessionID = uuid.NewString() case "size": - request.SizeBytes = proto.CapabilitiesMaxBytes + 1 + request.SizeBytes = proto.RuntimePrepareMaxBytes + 1 case "nil-binding": r.localWorkspace = nil } @@ -166,13 +166,13 @@ func TestCapabilitiesBeginRequiresExactBindingAndBounds(t *testing.T) { t.Fatal(err) } capabilitiesReceipt(t, sender, id, "rejected") - if r.capabilitiesPrepare != nil { + if r.runtimePreparation != nil { t.Fatal("invalid scope allocated a transfer") } } } -func TestCapabilitiesPreparationExcludesOwnedResources(t *testing.T) { +func TestRuntimePreparationPreparationExcludesOwnedResources(t *testing.T) { for _, mode := range []string{"write", "export", "read", "run", "idle", "executor", "preparation"} { t.Run(mode, func(t *testing.T) { r, sender, environment, session := capabilitiesTestRouter(t) @@ -199,7 +199,7 @@ func TestCapabilitiesPreparationExcludesOwnedResources(t *testing.T) { if got := capabilitiesReceipt(t, sender, id, "rejected"); got.ErrorCode != "resource_unavailable" { t.Fatal(got) } - if r.capabilitiesPrepare != nil { + if r.runtimePreparation != nil { t.Fatal("busy Runtime admitted capability preparation") } r.workspaceWrite = nil @@ -214,7 +214,7 @@ func TestCapabilitiesPreparationExcludesOwnedResources(t *testing.T) { } } -func TestCapabilitiesUploadBlocksWorkspaceWriteAndSuspension(t *testing.T) { +func TestRuntimePreparationUploadBlocksWorkspaceWriteAndSuspension(t *testing.T) { r, sender, environment, session := capabilitiesTestRouter(t) id := uuid.NewString() if err := r.Handle(t.Context(), capabilityEnvelope(t, id, capabilityBegin(environment, session, []byte("abc")))); err != nil { @@ -242,26 +242,26 @@ func TestCapabilitiesUploadBlocksWorkspaceWriteAndSuspension(t *testing.T) { t.Fatal("missing write rejection") } r.mu.Lock() - owner := r.capabilitiesPrepare + owner := r.runtimePreparation r.mu.Unlock() shutdownCapabilitiesRouter(t, r) - if owner.data != nil || r.capabilitiesPrepare != nil { + if owner.data != nil || r.runtimePreparation != nil { t.Fatal("disconnect retained uncommitted body") } } -func TestCapabilitiesCancellationKeepsOwnershipUntilApplyStops(t *testing.T) { +func TestRuntimePreparationCancellationKeepsOwnershipUntilApplyStops(t *testing.T) { r, sender, environment, session := capabilitiesTestRouter(t) ctx, cancel := context.WithCancel(context.Background()) id := uuid.NewString() - request := proto.CapabilitiesPreparePayload{Step: "begin", Action: "finalize", EnvironmentID: environment, SessionID: session, Sources: &agentcapabilities.Input{}} - owner := &capabilitiesUpload{envelope: capabilityEnvelope(t, id, request), request: request, ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} + request := proto.RuntimePreparePayload{Step: "begin", Action: "finalize", EnvironmentID: environment, SessionID: session, Sources: &agentcapabilities.Input{}} + owner := &runtimePreparationTransfer{envelope: capabilityEnvelope(t, id, request), request: request, ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} close(owner.ready) - r.capabilitiesPrepare = owner + r.runtimePreparation = owner r.shutdownWG.Add(1) started, interrupted, release := make(chan struct{}), make(chan struct{}), make(chan struct{}) retained := filepath.Join(t.TempDir(), "installed.json") - go r.runCapabilitiesUpload(ctx, owner, func(ctx context.Context, got proto.CapabilitiesPreparePayload, data []byte) error { + go r.runRuntimePreparationTransfer(ctx, owner, func(ctx context.Context, got proto.RuntimePreparePayload, data []byte) error { if got.Action != "finalize" || len(data) != 0 { return agentcapabilities.ErrInvalid } @@ -280,7 +280,7 @@ func TestCapabilitiesCancellationKeepsOwnershipUntilApplyStops(t *testing.T) { } <-interrupted r.mu.Lock() - owned := r.capabilitiesPrepare == owner + owned := r.runtimePreparation == owner r.mu.Unlock() if !owned { t.Fatal("cancel released unsettled capability ownership") @@ -291,24 +291,43 @@ func TestCapabilitiesCancellationKeepsOwnershipUntilApplyStops(t *testing.T) { if _, err := os.Stat(retained); err != nil { t.Fatal("shutdown deleted installation result") } - if r.capabilitiesPrepare != nil { + if r.runtimePreparation != nil { t.Fatal("confirmed completion retained capacity") } } -func TestCapabilitiesResultCategoriesAndUnknownOwnership(t *testing.T) { +func TestRuntimePreparationInitializationReceipts(t *testing.T) { + for _, code := range []int{-1, 0, 1, 255, 256} { + got := runtimePreparationResult(&localworkspace.InitializationFailure{ExitCode: &code}, 0) + if code > 0 && code <= 255 { + if got.Outcome != "failed" || got.ExitCode != code { + t.Fatalf("lost confirmed exit code: %+v", got) + } + } else if got.Outcome != "unknown" { + t.Fatalf("accepted invalid failure receipt: %+v", got) + } + } + if got := runtimePreparationResult(&localworkspace.InitializationFailure{}, 0); got.Outcome != "failed" || got.ExitCode != 0 { + t.Fatalf("lost confirmed generic failure: %+v", got) + } + if got := runtimePreparationResult(errors.Join(&localworkspace.InitializationFailure{}, context.Canceled), 0); got.Outcome != "unknown" { + t.Fatalf("cancellation reported confirmed: %+v", got) + } +} + +func TestRuntimePreparationResultCategoriesAndUnknownOwnership(t *testing.T) { for _, tc := range []struct { err error outcome, code string }{ {nil, "completed", ""}, - {agentcapabilities.ErrInvalid, "failed", "capabilities_failed"}, - {context.DeadlineExceeded, "unknown", "capabilities_unconfirmed"}, - {errors.Join(agentcapabilities.ErrInvalid, context.Canceled), "unknown", "capabilities_unconfirmed"}, - {errors.New("private native diagnostic"), "unknown", "capabilities_unconfirmed"}, + {agentcapabilities.ErrInvalid, "failed", "runtime_preparation_failed"}, + {context.DeadlineExceeded, "unknown", "runtime_preparation_unconfirmed"}, + {errors.Join(agentcapabilities.ErrInvalid, context.Canceled), "unknown", "runtime_preparation_unconfirmed"}, + {errors.New("private native diagnostic"), "unknown", "runtime_preparation_unconfirmed"}, } { - got := capabilitiesApplyResult(tc.err, 3) - if got.Outcome != tc.outcome || got.ErrorCode != tc.code || !proto.ValidCapabilitiesResult(got, "completed", 0, 3) { + got := runtimePreparationResult(tc.err, 3) + if got.Outcome != tc.outcome || got.ErrorCode != tc.code || !proto.ValidRuntimePrepareResult(got, "completed", 0, 3) { t.Fatalf("unsafe result: %+v", got) } } @@ -316,14 +335,14 @@ func TestCapabilitiesResultCategoriesAndUnknownOwnership(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) id := uuid.NewString() request := capabilityBegin(environment, session, []byte("abc")) - owner := &capabilitiesUpload{envelope: capabilityEnvelope(t, id, request), request: request, data: []byte("abc"), ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} + owner := &runtimePreparationTransfer{envelope: capabilityEnvelope(t, id, request), request: request, data: []byte("abc"), ready: make(chan struct{}), cancel: cancel, finished: true, apply: true} close(owner.ready) - r.capabilitiesPrepare = owner + r.runtimePreparation = owner r.shutdownWG.Add(1) - go r.runCapabilitiesUpload(ctx, owner, func(context.Context, proto.CapabilitiesPreparePayload, []byte) error { return context.DeadlineExceeded }) + go r.runRuntimePreparationTransfer(ctx, owner, func(context.Context, proto.RuntimePreparePayload, []byte) error { return context.DeadlineExceeded }) capabilitiesReceipt(t, sender, id, "unknown") r.mu.Lock() - owned := r.capabilitiesPrepare == owner && owner.uncertain && owner.data == nil + owned := r.runtimePreparation == owner && owner.uncertain && owner.data == nil r.mu.Unlock() if !owned { t.Fatal("unknown mutation released its ownership") diff --git a/apps/parsar-daemon/internal/dispatch/shutdown.go b/apps/parsar-daemon/internal/dispatch/shutdown.go index 43aa0a684..15d7f7061 100644 --- a/apps/parsar-daemon/internal/dispatch/shutdown.go +++ b/apps/parsar-daemon/internal/dispatch/shutdown.go @@ -43,8 +43,8 @@ func (r *Router) Shutdown(ctx context.Context) error { } if first { r.closed = true - if r.capabilitiesPrepare != nil { - r.capabilitiesPrepare.cancel() + if r.runtimePreparation != nil { + r.runtimePreparation.cancel() } for _, states := range r.idle { for state := range states { @@ -103,7 +103,7 @@ func (r *Router) runShutdownAttempt(attempt *shutdownAttempt, victims []sessionC r.shutdownWG.Wait() r.mu.Lock() - if r.capabilitiesPrepare != nil && r.capabilitiesPrepare.uncertain { + if r.runtimePreparation != nil && r.runtimePreparation.uncertain { attempt.err = errors.Join(attempt.err, errors.New("dispatch: capability preparation remains uncertain")) } if r.workspaceWrite != nil && r.workspaceWrite.uncertain { @@ -150,8 +150,8 @@ func (r *Router) handleDeviceShutdown(ctx context.Context, env proto.Envelope) e r.mu.Unlock() return ErrRouterClosed } - if r.capabilitiesPrepare != nil { - r.capabilitiesPrepare.cancel() + if r.runtimePreparation != nil { + r.runtimePreparation.cancel() } victims := make([]sessionCancellation, 0, len(r.sessions)) for _, state := range r.sessions { diff --git a/apps/parsar-daemon/internal/dispatch/suspend.go b/apps/parsar-daemon/internal/dispatch/suspend.go index a39db52f0..e52df4858 100644 --- a/apps/parsar-daemon/internal/dispatch/suspend.go +++ b/apps/parsar-daemon/internal/dispatch/suspend.go @@ -31,7 +31,7 @@ func (r *Router) Quiesce(ctx context.Context, request proto.EnvironmentSuspendPa r.mu.Unlock() return ErrRouterQuiesced } - if r.capabilitiesPrepare != nil || len(r.sessions) != 0 || len(r.workspaceReads) != 0 || r.workspaceWrite != nil || r.workspaceExport != nil || len(r.permIndex) != 0 || len(r.askIndex) != 0 { + if r.runtimePreparation != nil || len(r.sessions) != 0 || len(r.workspaceReads) != 0 || r.workspaceWrite != nil || r.workspaceExport != nil || len(r.permIndex) != 0 || len(r.askIndex) != 0 { r.mu.Unlock() return ErrRouterBusy } diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write.go b/apps/parsar-daemon/internal/dispatch/workspace_write.go index 7f916d8cf..7226ef20d 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_write.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_write.go @@ -46,7 +46,7 @@ func (r *Router) handleWorkspaceWrite(ctx context.Context, env proto.Envelope) e return ErrRouterClosed } if request.Step == "begin" { - if r.workspaceExport != nil || r.capabilitiesPrepare != nil { + if r.workspaceExport != nil || r.runtimePreparation != nil { r.mu.Unlock() return r.sendWorkspaceWrite(ctx, env.ID, rejectedWorkspaceWrite("resource_unavailable")) } diff --git a/apps/parsar-daemon/internal/localworkspace/capabilities.go b/apps/parsar-daemon/internal/localworkspace/capabilities.go index 07ec639b3..e762b177f 100644 --- a/apps/parsar-daemon/internal/localworkspace/capabilities.go +++ b/apps/parsar-daemon/internal/localworkspace/capabilities.go @@ -75,10 +75,10 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr return r, nil } -// ApplyCapabilities settles every filesystem operation before returning. A +// ApplyRuntimePreparation settles every filesystem operation before returning. A // cancelled caller never leaves an unowned installation goroutine behind. -func (b *Binding) ApplyCapabilities(ctx context.Context, input proto.CapabilitiesPreparePayload, data []byte) error { - if b == nil || !b.Matches(input.EnvironmentID, input.SessionID) || !proto.ValidCapabilitiesPrepareRequest(input) || input.Step != "begin" { +func (b *Binding) ApplyRuntimePreparation(ctx context.Context, input proto.RuntimePreparePayload, data []byte) error { + if b == nil || !b.Matches(input.EnvironmentID, input.SessionID) || !proto.ValidRuntimePrepareRequest(input) || input.Step != "begin" { return agentcapabilities.ErrInvalid } b.capabilityMu.Lock() @@ -91,6 +91,21 @@ func (b *Binding) ApplyCapabilities(ctx context.Context, input proto.Capabilitie if marker.completed { return agentcapabilities.ErrInvalid } + if err := ctx.Err(); err != nil { + return err + } + switch input.Action { + case "file": + if len(data) != input.SizeBytes { + return agentcapabilities.ErrInvalid + } + return b.installInitialFile(ctx, *input.File, data) + case "initialize": + if len(data) != 0 { + return agentcapabilities.ErrInvalid + } + return b.initializeRuntime(ctx, *input.Initialization) + } root, unlock, err := b.openCapabilities(ctx, false) if err != nil { return err diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go new file mode 100644 index 000000000..c8c639eb1 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go @@ -0,0 +1,11 @@ +package localworkspace + +import "errors" + +// InitializationFailure contains only a confirmed step's safe exit status. +type InitializationFailure struct{ ExitCode *int } + +func (*InitializationFailure) Error() string { return "Runtime initialization failed" } + +// ErrInitializationUnconfirmed means execution or its receipt was not confirmed. +var ErrInitializationUnconfirmed = errors.New("Runtime initialization unconfirmed") diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go new file mode 100644 index 000000000..48f708066 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go @@ -0,0 +1,174 @@ +//go:build linux + +package localworkspace + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "io" + "strconv" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInitialization) error { + payload := struct { + Version int `json:"version"` + proto.RuntimeInitialization + }{Version: 1, RuntimeInitialization: input} + raw, err := json.Marshal(payload) + if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { + return agentcapabilities.ErrInvalid + } + result, err := runInitializationProcess(ctx, "/usr/bin/python3", []string{"-I", "-S", MCPInitializer, "initialize", b.workspace}, bytes.NewReader(raw)) + if err != nil { + return err + } + return decodeInitialization(result) +} + +func (b *Binding) installInitialFile(ctx context.Context, input proto.RuntimeInitialFile, data []byte) (err error) { + if b.writer == nil || !strings.HasPrefix(input.Path, "/workspace/") || len(data) > proto.RuntimePrepareMaxBytes { + return agentcapabilities.ErrInvalid + } + w := b.writer + if !w.mu.TryLock() { + return agentcapabilities.ErrInvalid + } + defer w.mu.Unlock() + if w.uncertain { + return ErrInitializationUnconfirmed + } + defer func() { w.uncertain = errors.Is(err, ErrInitializationUnconfirmed) }() + digest := sha256.Sum256(data) + result, err := runInitializationProcess(ctx, "/usr/bin/python3", []string{"-I", "-S", "-c", initialFileInstaller, + b.workspace, strings.TrimPrefix(input.Path, "/workspace/"), strconv.Itoa(len(data)), w.staging, w.helper}, + io.MultiReader(bytes.NewReader(data), bytes.NewReader(digest[:]))) + if err != nil { + return err + } + if result.exit != 0 || len(result.stderr) != 0 { + return ErrInitializationUnconfirmed + } + _, err = decodeWrite(result.stdout, len(data)) + if errors.Is(err, agent.ErrWorkspaceWriteRejected) { + return &InitializationFailure{} + } + if err != nil { + return ErrInitializationUnconfirmed + } + return nil +} + +// This only creates fd-anchored parents, then delegates replacement to the +// existing atomic writer. Deployment paths come exclusively from Binding. +const initialFileInstaller = `import os, sys +workspace, path, size, staging, helper = sys.argv[1:] +parts = path.split('/') +if any(not p or p in ('.', '..') or any(c in p for c in ('\\', '\x00', '\r', '\n')) for p in parts): + raise SystemExit(2) +flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC +fd = os.open('/', flags) +for component in workspace.lstrip('/').split('/'): + child = os.open(component, flags, dir_fd=fd) + os.close(fd) + fd = child +for component in parts[:-1]: + try: + child = os.open(component, flags, dir_fd=fd) + except FileNotFoundError: + os.mkdir(component, mode=0o700, dir_fd=fd) + child = os.open(component, flags, dir_fd=fd) + os.close(fd) + fd = child +os.close(fd) +os.execv(helper, [helper, workspace, path, size, staging]) +` + +type initializationResult struct { + stdout, stderr []byte + exit int +} + +// runInitializationProcess uses the existing process-group owner and joins all +// readers/writers before returning. No child output becomes a public error. +func runInitializationProcess(ctx context.Context, binary string, args []string, input io.Reader) (initializationResult, error) { + result := initializationResult{exit: -1} + operation, cancel := context.WithTimeout(ctx, 30*time.Minute) + defer cancel() + if operation.Err() != nil { + return result, ErrInitializationUnconfirmed + } + process, err := clirunner.Start(clirunner.StartOptions{Parent: operation, Binary: binary, Args: args, + Dir: "/", Env: []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"}, NeedStdin: true, + OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) + if err != nil { + return result, ErrInitializationUnconfirmed + } + defer process.Cancel() + inputDone := make(chan error, 1) + go func() { + _, err := io.Copy(process.Stdin, input) + closeErr := process.Stdin.Close() + if err == nil { + err = closeErr + } + inputDone <- err + }() + type output struct { + data []byte + err error + } + read := func(stream io.Reader, done chan<- output) { + raw, err := io.ReadAll(io.LimitReader(stream, 1025)) + if err != nil || len(raw) > 1024 { + process.Cancel() + } + _, _ = io.Copy(io.Discard, stream) + done <- output{raw, err} + } + stdout, stderr := make(chan output, 1), make(chan output, 1) + go read(process.Stdout, stdout) + go read(process.Stderr, stderr) + out, diagnostic := <-stdout, <-stderr + inputErr := <-inputDone + _ = process.Wait() + result.stdout, result.stderr = out.data, diagnostic.data + result.exit = process.Cmd.ProcessState.ExitCode() + if operation.Err() != nil || inputErr != nil || out.err != nil || diagnostic.err != nil || + len(out.data) > 1024 || len(diagnostic.data) > 1024 || result.exit < 0 { + return result, ErrInitializationUnconfirmed + } + return result, nil +} + +func decodeInitialization(result initializationResult) error { + var receipt struct { + Version int `json:"version"` + Outcome string `json:"outcome"` + ExitCode *int `json:"exit_code"` + } + decoder := json.NewDecoder(bytes.NewReader(result.stdout)) + decoder.DisallowUnknownFields() + if len(result.stderr) != 0 || decoder.Decode(&receipt) != nil || decoder.Decode(new(any)) != io.EOF || receipt.Version != 1 { + return ErrInitializationUnconfirmed + } + if result.exit == 0 && receipt.Outcome == "completed" && receipt.ExitCode == nil { + return nil + } + if result.exit == 1 && receipt.Outcome == "failed" { + if receipt.ExitCode != nil && (*receipt.ExitCode < 1 || *receipt.ExitCode > 255) { + return ErrInitializationUnconfirmed + } + return &InitializationFailure{ExitCode: receipt.ExitCode} + } + return ErrInitializationUnconfirmed +} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux_test.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux_test.go new file mode 100644 index 000000000..c318ae0f8 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux_test.go @@ -0,0 +1,194 @@ +//go:build linux + +package localworkspace + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestRuntimeInitializationSafeReceipts(t *testing.T) { + for _, tc := range []struct { + raw string + exit int + failed bool + code int + }{ + {`{"version":1,"outcome":"completed"}`, 0, false, 0}, + {`{"version":1,"outcome":"failed","exit_code":3}`, 1, true, 3}, + {`{"version":1,"outcome":"failed"}`, 1, true, 0}, + } { + err := decodeInitialization(initializationResult{stdout: []byte(tc.raw), exit: tc.exit}) + var failure *InitializationFailure + if tc.failed { + if !errors.As(err, &failure) || tc.code != 0 && (failure.ExitCode == nil || *failure.ExitCode != tc.code) { + t.Fatal("failed receipt", err) + } + } else if err != nil { + t.Fatal(err) + } + } + for _, raw := range []string{ + `{"version":1,"outcome":"failed","exit_code":0}`, + `{"version":1,"outcome":"failed","exit_code":256}`, + `{"version":1,"outcome":"failed","exit_code":3,"stderr":"secret"}`, + `{"version":1,"outcome":"completed","exit_code":3}`, + `{"version":1,"outcome":"failed"} {}`, + } { + if !errors.Is(decodeInitialization(initializationResult{stdout: []byte(raw), exit: 1}), ErrInitializationUnconfirmed) { + t.Fatal("unsafe receipt") + } + } +} + +func TestRuntimeInitializationProcessDrainsAndCancels(t *testing.T) { + t.Setenv("RUNTIME_TEST_PRIVATE", "do-not-inherit") + result, err := runInitializationProcess(t.Context(), "/bin/sh", []string{"-c", `test -z "$RUNTIME_TEST_PRIVATE" || exit 4; cat >/dev/null; printf '{"version":1,"outcome":"completed"}'`}, strings.NewReader("{}")) + if err != nil || decodeInitialization(result) != nil { + t.Fatal("clean process", err) + } + _, err = runInitializationProcess(t.Context(), "/bin/sh", []string{"-c", "head -c 2048 /dev/zero; sleep 5"}, strings.NewReader("")) + if !errors.Is(err, ErrInitializationUnconfirmed) { + t.Fatal("unbounded output accepted", err) + } + directory := t.TempDir() + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + _, err := runInitializationProcess(ctx, "/bin/sh", []string{"-c", `(trap '' TERM; sleep 1; touch "$1/late") & touch "$1/started"; wait`, "fixture", directory}, strings.NewReader("{}")) + done <- err + }() + deadline := time.Now().Add(3 * time.Second) + for { + if _, err := os.Stat(filepath.Join(directory, "started")); err == nil { + break + } + if time.Now().After(deadline) { + cancel() + <-done + t.Fatal("child did not start") + } + time.Sleep(5 * time.Millisecond) + } + cancel() + if err := <-done; !errors.Is(err, ErrInitializationUnconfirmed) { + t.Fatal("cancellation confirmed", err) + } + time.Sleep(1100 * time.Millisecond) + if _, err := os.Stat(filepath.Join(directory, "late")); !os.IsNotExist(err) { + t.Fatal("child survived return", err) + } +} + +func TestRuntimeInitializationPreservesSetupNetworkPhase(t *testing.T) { + for _, policy := range []string{"disabled", "restricted"} { + t.Run(policy, func(t *testing.T) { + b, _ := testBinding(t) + b.networkAccess = policy + t.Setenv("OAC_RUNTIME_NETWORK_ACCESS", policy) + // Initialization's typed network remains independent of native policy. + script := "import json, os, sys; request=json.load(sys.stdin); assert request['network']=='enabled'; assert 'OAC_RUNTIME_NETWORK_ACCESS' not in os.environ; print('{\"version\":1,\"outcome\":\"completed\"}')" + result, err := runInitializationProcess(t.Context(), "/usr/bin/python3", []string{"-I", "-S", "-c", script}, + strings.NewReader(`{"version":1,"action":"setup","network":"enabled","command":"true","cwd":"/workspace"}`)) + if err != nil || decodeInitialization(result) != nil || b.NetworkPolicy().Access != policy { + t.Fatal("initialization changed native network policy", err) + } + marker, err := b.openSnapshotMarker() + if err != nil { + t.Fatal(err) + } + if err = marker.complete(); err != nil { + marker.close() + t.Fatal(err) + } + marker.close() + input := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: b.environment, SessionID: b.capabilityIdentity().SessionID, + Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", Network: "enabled", Command: "true", CWD: "/workspace"}} + if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { + t.Fatal("initialization reopened after native admission", err) + } + }) + } +} + +func TestRuntimeInitialFileUsesBoundAtomicWriterAndAnchoredParents(t *testing.T) { + b := writableBinding(t, `[ "$#" = 4 ] || exit 7 +[ -z "$RUNTIME_TEST_PRIVATE" ] || exit 8 +cat >/dev/null +printf '{"version":1,"outcome":"completed","size_bytes":3}' +`) + t.Setenv("RUNTIME_TEST_PRIVATE", "secret") + if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: "/workspace/nested/child/file"}, []byte("abc")); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(b.workspace, "nested/child")) + if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { + t.Fatal("parents", err) + } + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(b.workspace, "escape")); err != nil { + t.Fatal(err) + } + if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: "/workspace/escape/new/file"}, []byte("abc")); err == nil { + t.Fatal("alias accepted") + } + if _, err := os.Stat(filepath.Join(outside, "new")); !os.IsNotExist(err) { + t.Fatal("escaped workspace") + } +} + +func TestRuntimePreparationRejectsFilesAfterFinalization(t *testing.T) { + b, req := testBinding(t) + configured, err := b.Configure(req) + if err != nil { + t.Fatal(err) + } + if _, err = b.Prepare(t.Context(), configured); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(nil) + input := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: b.environment, SessionID: b.capabilityIdentity().SessionID, + Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/file"}, SHA256: hex.EncodeToString(digest[:])} + if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { + t.Fatal("finalized Runtime accepted file", err) + } + input.Action = "initialize" + input.File = nil + input.SHA256 = "" + input.Initialization = &proto.RuntimeInitialization{Action: "configure", Env: map[string]string{}} + if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { + t.Fatal("finalized Runtime accepted initialize", err) + } +} + +func TestRuntimeInitialFileNativeAtomicReplacement(t *testing.T) { + helper := os.Getenv("OAC_TEST_LOCAL_WRITE_HELPER") + if helper == "" { + t.Skip("requires built native installer") + } + b := writableBinding(t, "exit 1\n") + if err := b.bindWriter(helper, b.writer.staging); err != nil { + t.Fatal(err) + } + path := "/workspace/n1/n2/file" + for _, body := range [][]byte{nil, []byte("first"), bytes.Repeat([]byte("x"), 1<<20), []byte("replacement")} { + if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: path}, body); err != nil { + t.Fatal(err) + } + actual, err := os.ReadFile(filepath.Join(b.workspace, "n1/n2/file")) + if err != nil || !bytes.Equal(actual, body) { + t.Fatal("atomic replacement", err) + } + } +} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_other.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_other.go new file mode 100644 index 000000000..16ba0a627 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_other.go @@ -0,0 +1,16 @@ +//go:build !linux + +package localworkspace + +import ( + "context" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func (b *Binding) initializeRuntime(context.Context, proto.RuntimeInitialization) error { + return agentcapabilities.ErrInvalid +} +func (b *Binding) installInitialFile(context.Context, proto.RuntimeInitialFile, []byte) error { + return agentcapabilities.ErrInvalid +} diff --git a/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go b/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go index f870fcaa9..ca0b6696c 100644 --- a/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go +++ b/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go @@ -214,8 +214,8 @@ func TestSnapshotMarkerRefusesDamagedOrAliasedPrivateState(t *testing.T) { func TestCapabilityFinalizeRecordsCompletionAndRejectsLaterImports(t *testing.T) { b, _ := markerBinding(t) identity := b.capabilityIdentity() - finalize := proto.CapabilitiesPreparePayload{Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "finalize", Sources: &agentcapabilities.Input{}} - if err := b.ApplyCapabilities(t.Context(), finalize, nil); err != nil { + finalize := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "finalize", Sources: &agentcapabilities.Input{}} + if err := b.ApplyRuntimePreparation(t.Context(), finalize, nil); err != nil { t.Fatal(err) } if _, err := os.Stat(markerPath(b)); err != nil { @@ -225,11 +225,11 @@ func TestCapabilityFinalizeRecordsCompletionAndRejectsLaterImports(t *testing.T) if err := os.RemoveAll(b.capabilityRoot); err != nil { t.Fatal(err) } - skill := proto.CapabilitiesPreparePayload{Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "skill", Skill: &agentskill.Metadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 1, SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} - if err := b.ApplyCapabilities(t.Context(), skill, []byte("x")); err == nil { + skill := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: identity.EnvironmentID, SessionID: identity.SessionID, Action: "skill", Skill: &agentskill.Metadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 1, SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + if err := b.ApplyRuntimePreparation(t.Context(), skill, []byte("x")); err == nil { t.Fatal("completed identity accepted import") } - if err := b.ApplyCapabilities(t.Context(), finalize, nil); err == nil { + if err := b.ApplyRuntimePreparation(t.Context(), finalize, nil); err == nil { t.Fatal("completed identity finalized again") } if _, err := os.Stat(b.capabilityRoot); !os.IsNotExist(err) { diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md index da8181917..f548fc5f5 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -73,8 +73,9 @@ Session, omission and null inherit, while a supplied list replaces the entire fi set, including `[]` clearing it. Paths are not merged between the two sources. The effective list retains the existing validation and tenant-owned source checks. -Core initializes both paths with the same trusted file installer through Provider -RunCommand. Daemon authentication remains available, but native preparation and live +Core initializes both paths through the authenticated daemon's typed `runtime_prepare` +file operation. The Runtime resolves the logical workspace path and owns the trusted +file installer. Daemon authentication remains available, but native preparation and live Files wait for all writes. Each file gets a two-minute transfer budget; the batch has a thirty-minute local budget and shares maintenance scans with other allocations. These are local operational limits, not verified upstream timing. Initial input @@ -126,8 +127,8 @@ in its creation transaction. Later source deletion, default changes or template updates cannot change that Session or its committed creation retry. A supplied Session Skill list replaces the template list; omission/null inherit. Template responses include `version: null` for an unresolved default selector; resolved -Session references retain a concrete version string. See [resource selector -qualification](resource-selector-semantics.md) and [null selection evidence](template-null-selection.md). +Session references retain a concrete version string. See +[resource selector qualification](resource-selector-semantics.md) and [null selection evidence](template-null-selection.md). References return type/skill_id/version/name/description in Session metadata, while template responses retain unresolved selectors. Confidential bundle content never appears in these metadata responses. The common Runtime installation path @@ -167,7 +168,8 @@ Core transfers frozen Skill archives over the authenticated Runtime connection. Runtime's shared parser installs them under its operator-bound capability root (default `/environment/initialization/capabilities/skills/` for each Skill) before setup and native execution; -Provider commands do not install Skills or Plugins. Setup and native tools can read +The same daemon handles initial files, configuration, packages and setup; Providers +only bootstrap it and manage compute resources. Setup and native tools can read that tree but cannot write it. Completed recovery loads the protected installation without reinstalling. The common Runtime resolves installed metadata and paths before invoking the native executor factory. @@ -397,30 +399,37 @@ Templates workflow, not all upstream semantics, transports or Provider combinati ## Packaged Runtime initialization contract Template handlers and stores resolve public configuration without choosing a -harness, native path or compute backend. The common initialization lifecycle uses -the following existing Linux Runtime packaging requirements through Provider -`RunCommand`; these are private deployment requirements, not public Template fields. - -- `/workspace` is the public workspace. `/environment/workspace` names the same - storage for trusted initialization; `/environment/staging` is private staging. -- `/usr/bin/python3 -I -S` runs the trusted, fd-anchored initial-file installer. - It invokes the existing `/usr/local/bin/oac-codex-write` atomic writer in - its four-argument replace mode; only public Files.create uses the create mode. - That executable is a shared filesystem helper packaged for every harness; its - historical name does not select Codex or invoke native Codex tools. -- Confidential content travels on bounded stdin. Successful initialization needs - the writer's versioned completion receipt and confirmed process exit. Unknown - effects use the existing allocation cleanup path rather than replay. -- Provider implementations preserve argv, stdin, exit status and allocation - ownership. They do not interpret public templates. Runtime adapters own native - configuration; initialization must not consume a harness's private history, - model credentials or native tool protocol. - -New hosted harnesses reuse these helpers and paths; new Providers deploy the same -Runtime contract. Neither addition should change template validation, storage or -resolution. Extend this contract only for an accepted initialization requirement. +harness, native path or compute backend. The common runner depends on neutral +Environment/Session identity and an authenticated Runtime peer. It sends initial +files, configure/system/npm/python/setup operations, Skills, Plugins and finalization +through `runtime_prepare`, then uses the same daemon for execution. Providers own +placement, creation, daemon bootstrap, inspection, renewal and reclamation; they do +not run Core initialization commands. Harness-specific behavior stays in adapters. + +The protocol accepts logical `/workspace` file and working-directory addresses and +portable absolute source selections. Physical paths, executable selection and local +access checks belong to Runtime. The current implementation uses these existing +Linux packaging requirements; they are not public Template fields or requirements +for future Runtime implementations on other platforms: + +- `/workspace` is the logical workspace. The packaged Linux default binds it to + `/environment/workspace`; `/environment/staging` is private staging. +- The daemon invokes the fixed trusted, fd-anchored initial-file installer and the + shared atomic writer. These helpers do not select a harness or invoke native + model tools. +- Confidential content travels in bounded protocol frames and private helper stdin. + The daemon verifies native completion before sending a typed Runtime receipt. + Unknown effects use the existing allocation cleanup path rather than replay. +- Runtime adapters own native configuration. Initialization cannot access a + harness's private history, model credentials or native tool protocol. + +New Providers bootstrap the same Runtime contract. New harnesses reuse common +preparation, with native differences confined to their adapters. Neither addition +changes template validation, storage or resolution. This interface separation does +not claim a non-Linux installer, layout or isolation implementation. The trusted `/usr/local/bin/oac-runtime-initialize` receives a bounded -version-1 JSON operation on stdin. It configures read-only tool env under +version-1 JSON operation on stdin from the Linux daemon. This private helper format +is not the Core-to-Runtime protocol. It configures read-only tool env under `/environment/initialization`, installs packages under `/environment/packages`, and runs ordered commands through distro bubblewrap. The fixed mount/process map excludes daemon credentials, native history and staging. User values are applied @@ -434,7 +443,9 @@ Runtime receives a `tool_environment` execution flag, without template identity provider information. Adapters validate the common files and apply them in their native tool sandbox: Claude uses its native Bash hook, Codex its managed Bash hook, and MiniMax its isolated native-tool worker. Native transports remain unchanged. -Files reads do not require initialized tool configuration. Docker setup requires +Files reads do not require initialized tool configuration. Packages and setup keep +enabled provisioning network access; requested Session network restrictions apply +to native execution after setup. Docker setup requires the existing nested-sandbox deployment profile for every harness; E2B supplies the same Runtime layout and kernel isolation. @@ -445,6 +456,10 @@ exist; this is not an atomic hook-failure prevention guarantee. ## Initialization failure — September 23 +The September 23 observations below are historical acceptance evidence. Current +initialization uses the shared daemon protocol described above; these observations +do not qualify that new transport against a real deployment. + When a hosted Environment fails to provision, Core now reports it the way the official service does (evidence and rows H1–H8 in [official semantics](official-semantics-alignment.md#hosted-initialization-failure--september-23)). @@ -467,15 +482,14 @@ The reason names only the failed step and its exit status: "Anything else" covers timeouts, the thirty-minute budget, unknown effects, missing or malformed receipts, Plugin installation and directory finalization, -bootstrap rejection and Core restart during initialization. Provider-executed setup -has a confirmed failure only when the process exits 1 with empty stderr and stdout -decodes as a version-1 receipt whose `outcome` is `failed`. Only its optional -`exit_code` is projected, when it is an integer from 1 to 255; unknown fields are -ignored without establishing old-version compatibility. Runtime capability -operations use typed `rejected`, `failed` or `unknown` outcomes instead of provider -process receipts. Only a confirmed Skill preparation failure gets the Skill label; -Plugin/finalization failures and uncertain effects retain the generic reason. The Store composes the -reason from a fixed label and integers, so commands, env values, package names, +bootstrap rejection and Core restart during initialization. All initialization +operations return typed Runtime `rejected`, `failed` or `unknown` outcomes. The +Linux daemon confirms the trusted helper's process exit and private receipt before +reporting completion or failure. Only `failed` can carry a bounded `exit_code`; +For setup and package steps, Core projects a confirmed nonzero status, never +process output; zero or missing status retains the generic reason. Only a confirmed +Skill preparation failure gets the Skill label. Plugin/finalization failures and +uncertain effects retain the generic reason. The Store composes the reason from a fixed label and integers, so commands, env values, package names, paths and any process output never reach the reason, events, logs or responses. The failed step is not retried and later steps do not run. diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index c17a75587..f0bf8ea7d 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -49,8 +49,11 @@ Omitted/null network defaults to enabled. Enabled, disabled and exact-host restr policies use the same qualified image with adapter-selected immutable native policy. Templates and inline configuration share initial files, env, packages, ordered setup and inline or tenant-owned referenced Skills through ordered managed initialization. -Provider operations retain file/tool/package/setup work; Runtime imports Skills and -Plugins and finalizes directory snapshots through its authenticated connection. +The same authenticated daemon handles initial files, tool configuration, packages, +setup, Skill/Plugin import and directory snapshot finalization through +`runtime_prepare`. Providers place, create, bootstrap, inspect, renew and reclaim +compute. Package installation and setup retain enabled provisioning network access; +the requested network policy constrains native execution after setup. Unsupported hostname forms and installation combinations reject explicitly; see the [Template coverage and limits](environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Service-origin @@ -152,7 +155,9 @@ revocation/deletion on each declared deployment. Core freezes resource versions, metadata and source selections. Managed initialization keeps initial files and tool configuration before Runtime Skill/Plugin import, then runs packages and ordered setup before Runtime snapshots local capability -directories. Providers do not parse or install capability bundles. Self-hosted input +directories. All steps use the common `runtime_prepare` protocol; Providers do +not execute Core initialization commands. The common runner depends on neutral +Environment/Session identity and a Runtime peer, not Provider, deployment or OS. Self-hosted input accepts only local `capability_directories` with `workspace_directory`, not managed Skill/Plugin archive fields or a hosted Template. Local directory discovery does not populate the public API-managed `skills` or `plugins` installation arrays. @@ -176,6 +181,11 @@ Executor close, Turn cancellation and transport loss preserve the installed snap workspace and allocation. Reclamation is an explicit operation coordinated with active work; a disconnected socket is not proof that native effects have stopped. +Source selections accept portable absolute Unix, Windows drive and UNC paths; +Core does not resolve these paths on its own host. The daemon applies its local +path and access checks. Initial files and setup working directories use logical +`/workspace` paths. Harness differences remain in adapters. + The protocol is platform-neutral, but this implementation retains the existing Linux helpers, protected initialization storage and isolation requirements. It does not provide a new native-host installer, another platform layout, or Windows/macOS isolation. diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md index 17d12975c..4425ca8bd 100644 --- a/docs/getting-started/self-hosted.md +++ b/docs/getting-started/self-hosted.md @@ -108,6 +108,9 @@ environment = { } ``` +The API accepts portable absolute Unix, Windows drive and UNC source paths without +checking Core's filesystem. The current Linux Runtime requires canonical local Unix +paths and applies its own access checks; this does not add Windows support. These paths refer to directories visible inside that Runtime. Populate them before first execution. `/workspace` maps to its bound workspace; an operator-prepared Runtime may use its exact canonical absolute workspace instead. Directory aliases, diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index 9ec9d9624..e499388ce 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -81,8 +81,8 @@ func ValidateDirectories(directories []string) error { } // ValidateLocalDirectories checks source spelling, not local access authority. -// Absolute paths use the protocol path syntax; the Runtime resolver owns host -// interpretation, directory access and protected-root exclusions. +// This Linux implementation requires canonical Unix paths. The Runtime resolver +// owns directory access and protected-root exclusions. func ValidateLocalDirectories(directories []string) error { if len(directories) > 50 { return ErrInvalid @@ -165,7 +165,7 @@ func validSelectionHash(value string) bool { // ValidateInput bounds source selections without reading archives or directories. // Portable manifest grammar is checked by the shared Skill and Plugin parsers. func ValidateInput(input Input) error { - if len(input.Skills) > MaxSkills || len(input.Plugins) > 50 || ValidateLocalDirectories(input.Directories) != nil { + if len(input.Skills) > MaxSkills || len(input.Plugins) > 50 || ValidateSourceDirectories(input.Directories) != nil { return ErrInvalid } validMetadata := func(kind, name, description string) bool { diff --git a/internal/agentcapabilities/source_directories.go b/internal/agentcapabilities/source_directories.go new file mode 100644 index 000000000..9c8adac61 --- /dev/null +++ b/internal/agentcapabilities/source_directories.go @@ -0,0 +1,50 @@ +package agentcapabilities + +import ( + "path" + "strings" + "unicode/utf8" +) + +// ValidateSourceDirectories checks portable absolute source addresses without +// interpreting them on the Core host. Runtime authorizes and opens local paths. +func ValidateSourceDirectories(directories []string) error { + if len(directories) > 50 { + return ErrInvalid + } + seen := make(map[string]bool, len(directories)) + for _, directory := range directories { + if len(directory) == 0 || len(directory) > 4096 || !utf8.ValidString(directory) || seen[directory] || !portableAbsoluteDirectory(directory) { + return ErrInvalid + } + for _, b := range []byte(directory) { + if b < 0x20 || b == 0x7f { + return ErrInvalid + } + } + seen[directory] = true + } + return nil +} + +func portableAbsoluteDirectory(value string) bool { + // Drive letters and UNC shares are wire syntax, not a Windows implementation. + if len(value) >= 3 && ((value[0] >= 'A' && value[0] <= 'Z') || (value[0] >= 'a' && value[0] <= 'z')) && value[1] == ':' && (value[2] == '/' || value[2] == '\\') { + rest := strings.ReplaceAll(value[2:], "\\", "/") + return path.Clean(rest) == rest && !strings.ContainsAny(rest, ":*?\"<>|") + } + if strings.HasPrefix(value, "\\\\") || strings.HasPrefix(value, "//") { + rest := strings.ReplaceAll(value[2:], "\\", "/") + parts := strings.Split(rest, "/") + if len(parts) < 2 || strings.ContainsAny(rest, ":*?\"<>|") { + return false + } + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return false + } + } + return true + } + return strings.HasPrefix(value, "/") && !strings.Contains(value, "\\") && path.Clean(value) == value +} diff --git a/internal/agentcapabilities/source_directories_test.go b/internal/agentcapabilities/source_directories_test.go new file mode 100644 index 000000000..2d1908177 --- /dev/null +++ b/internal/agentcapabilities/source_directories_test.go @@ -0,0 +1,25 @@ +package agentcapabilities + +import ( + "strings" + "testing" +) + +func TestPortableSourceDirectories(t *testing.T) { + for _, value := range []string{"/", "/home/operator/skills", `C:\`, `C:\Users\operator\skills`, "D:/skills", `\\host\share`, `\\host\share\skills`, "//host/share/skills"} { + if ValidateSourceDirectories([]string{value}) != nil { + t.Fatal("valid source refused", value) + } + } + for _, value := range []string{"", "relative", "C:relative", `\rooted`, `\\host`, `\\?\C:\skills`, `/a/../b`, `C:\a\..\b`, "/a/", "/a\tb", "/a\x7fb", string([]byte{'/', 0xff}), strings.Repeat("a", 4097)} { + if ValidateSourceDirectories([]string{value}) == nil { + t.Fatal("invalid source accepted", value) + } + } + if ValidateSourceDirectories([]string{"/a", "/a"}) == nil || ValidateSourceDirectories(make([]string, 51)) == nil { + t.Fatal("source bounds not enforced") + } + if ValidateDirectories([]string{`C:\skills`}) == nil || ValidateLocalDirectories([]string{`C:\skills`}) == nil { + t.Fatal("portable source weakened local/managed authorization") + } +} diff --git a/internal/agentdaemon/gateway/cancellation_test.go b/internal/agentdaemon/gateway/cancellation_test.go index 4955d8b78..9b8c5aa61 100644 --- a/internal/agentdaemon/gateway/cancellation_test.go +++ b/internal/agentdaemon/gateway/cancellation_test.go @@ -58,7 +58,7 @@ func TestArchivedReceiptTracksDeliveryBeyondDoneAndRejectsNewWork(t *testing.T) if _, err := peer.TrackExecutionDelivery("new-run"); err == nil { t.Fatal("new delivery admitted while draining") } - for _, kind := range []string{proto.TypePromptRequest, proto.TypePromptSteer, proto.TypeExecutionPrepare, proto.TypeCapabilitiesPrepare, proto.TypeWorkspaceWrite, proto.TypeWorkspaceRead} { + for _, kind := range []string{proto.TypePromptRequest, proto.TypePromptSteer, proto.TypeExecutionPrepare, proto.TypeRuntimePrepare, proto.TypeWorkspaceWrite, proto.TypeWorkspaceRead} { env, _ := proto.NewEnvelope(kind, "run", nil) if err := peer.Send(t.Context(), env); err == nil { t.Fatal("drain permitted new operation", kind) diff --git a/internal/agentdaemon/gateway/capabilities.go b/internal/agentdaemon/gateway/runtime_prepare.go similarity index 51% rename from internal/agentdaemon/gateway/capabilities.go rename to internal/agentdaemon/gateway/runtime_prepare.go index b0f641d19..0a5076387 100644 --- a/internal/agentdaemon/gateway/capabilities.go +++ b/internal/agentdaemon/gateway/runtime_prepare.go @@ -12,30 +12,30 @@ import ( "github.com/google/uuid" ) -// PrepareCapabilities transfers one archive or finalization selection once. An +// PrepareRuntime sends one typed preparation operation once. An // interrupted or unconfirmed transfer reports unknown; callers must not replay it. -func (s *Session) PrepareCapabilities(ctx context.Context, id string, request proto.CapabilitiesPreparePayload, data []byte) (proto.CapabilitiesResultPayload, error) { - unknown := proto.CapabilitiesResultPayload{Outcome: "unknown", ErrorCode: "capabilities_unconfirmed"} +func (s *Session) PrepareRuntime(ctx context.Context, id string, request proto.RuntimePreparePayload, data []byte) (proto.RuntimePrepareResultPayload, error) { + unknown := proto.RuntimePrepareResultPayload{Outcome: "unknown", ErrorCode: "runtime_preparation_unconfirmed"} parsed, err := uuid.Parse(id) - if err != nil || parsed == uuid.Nil || parsed.String() != id || len(data) > proto.CapabilitiesMaxBytes || + if err != nil || parsed == uuid.Nil || parsed.String() != id || len(data) > proto.RuntimePrepareMaxBytes || (request.Step != "" && request.Step != "begin") { - return unknown, errors.New("agentdaemon gateway: invalid capability preparation") + return unknown, errors.New("agentdaemon gateway: invalid Runtime preparation") } request.Step = "begin" - if request.Action == "finalize" { + if request.Action == "finalize" || request.Action == "initialize" { if len(data) != 0 { - return unknown, errors.New("agentdaemon gateway: invalid capability finalization") + return unknown, errors.New("agentdaemon gateway: invalid Runtime finalization") } } else { digest := sha256.Sum256(data) expected := hex.EncodeToString(digest[:]) if (request.SizeBytes != 0 && request.SizeBytes != len(data)) || (request.SHA256 != "" && request.SHA256 != expected) { - return unknown, errors.New("agentdaemon gateway: capability archive mismatch") + return unknown, errors.New("agentdaemon gateway: Runtime archive mismatch") } request.SizeBytes, request.SHA256 = len(data), expected } - if !proto.ValidCapabilitiesPrepareRequest(request) { - return unknown, errors.New("agentdaemon gateway: invalid capability preparation") + if !proto.ValidRuntimePrepareRequest(request) { + return unknown, errors.New("agentdaemon gateway: invalid Runtime preparation") } s.capabilitiesMu.Lock() if s.IsClosed() { @@ -44,7 +44,7 @@ func (s *Session) PrepareCapabilities(ctx context.Context, id string, request pr } if len(s.capabilities) != 0 { s.capabilitiesMu.Unlock() - return unknown, errors.New("agentdaemon gateway: capability preparation capacity") + return unknown, errors.New("agentdaemon gateway: Runtime preparation capacity") } replies := make(chan proto.Envelope, 1) s.capabilities = map[string]chan proto.Envelope{id: replies} @@ -52,26 +52,26 @@ func (s *Session) PrepareCapabilities(ctx context.Context, id string, request pr defer func() { s.capabilitiesMu.Lock(); delete(s.capabilities, id); s.capabilitiesMu.Unlock() }() ctx, cancel := context.WithTimeout(ctx, 195*time.Second) defer cancel() - exchange := func(payload proto.CapabilitiesPreparePayload, outcome string, offset int) (proto.CapabilitiesResultPayload, error) { - env, err := proto.NewEnvelope(proto.TypeCapabilitiesPrepare, id, payload) + exchange := func(payload proto.RuntimePreparePayload, outcome string, offset int) (proto.RuntimePrepareResultPayload, error) { + env, err := proto.NewEnvelope(proto.TypeRuntimePrepare, id, payload) if err != nil { - return unknown, errors.New("agentdaemon gateway: invalid capability frame") + return unknown, errors.New("agentdaemon gateway: invalid Runtime frame") } encoded, err := json.Marshal(env) - if err != nil || len(encoded) > proto.CapabilitiesMaxFrameBytes { - return unknown, errors.New("agentdaemon gateway: invalid capability frame") + if err != nil || len(encoded) > proto.RuntimePrepareMaxFrameBytes { + return unknown, errors.New("agentdaemon gateway: invalid Runtime frame") } reply, err := s.exchangeChunkFrame(ctx, env, replies) if err != nil { return unknown, err } - var result proto.CapabilitiesResultPayload - if len(reply.Payload) > proto.CapabilitiesMaxFrameBytes || reply.DecodePayload(&result) != nil || - !proto.ValidCapabilitiesResult(result, outcome, offset, len(data)) { - return unknown, errors.New("agentdaemon gateway: invalid capability receipt") + var result proto.RuntimePrepareResultPayload + if len(reply.Payload) > proto.RuntimePrepareMaxFrameBytes || reply.DecodePayload(&result) != nil || + !proto.ValidRuntimePrepareResult(result, outcome, offset, len(data)) { + return unknown, errors.New("agentdaemon gateway: invalid Runtime receipt") } if result.Outcome == "unknown" { - return result, errors.New("agentdaemon gateway: capability preparation unconfirmed") + return result, errors.New("agentdaemon gateway: Runtime preparation unconfirmed") } return result, nil } @@ -80,14 +80,14 @@ func (s *Session) PrepareCapabilities(ctx context.Context, id string, request pr return result, err } for offset := 0; offset < len(data); { - end := min(offset+proto.CapabilitiesChunkBytes, len(data)) - result, err = exchange(proto.CapabilitiesPreparePayload{Step: "chunk", Offset: offset, Data: data[offset:end]}, "received", end) + end := min(offset+proto.RuntimePrepareChunkBytes, len(data)) + result, err = exchange(proto.RuntimePreparePayload{Step: "chunk", Offset: offset, Data: data[offset:end]}, "received", end) if err != nil || result.Outcome != "received" { return result, err } offset = end } - return exchange(proto.CapabilitiesPreparePayload{Step: "commit"}, "completed", 0) + return exchange(proto.RuntimePreparePayload{Step: "commit"}, "completed", 0) } func (s *Session) dispatchCapabilities(env proto.Envelope) { diff --git a/internal/agentdaemon/gateway/capabilities_test.go b/internal/agentdaemon/gateway/runtime_prepare_test.go similarity index 51% rename from internal/agentdaemon/gateway/capabilities_test.go rename to internal/agentdaemon/gateway/runtime_prepare_test.go index cb15e17e3..223d226a3 100644 --- a/internal/agentdaemon/gateway/capabilities_test.go +++ b/internal/agentdaemon/gateway/runtime_prepare_test.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "testing" "time" @@ -16,19 +17,19 @@ import ( "github.com/google/uuid" ) -func skillPreparation() proto.CapabilitiesPreparePayload { - return proto.CapabilitiesPreparePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "skill", +func skillPreparation() proto.RuntimePreparePayload { + return proto.RuntimePreparePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "skill", Skill: &agentskill.Metadata{Type: "inline", Name: "example", Description: "Example"}} } type capabilityOutcome struct { - result proto.CapabilitiesResultPayload + result proto.RuntimePrepareResultPayload err error } -func beginCapabilities(s *Session, ctx context.Context, id string, request proto.CapabilitiesPreparePayload, data []byte) <-chan capabilityOutcome { +func beginCapabilities(s *Session, ctx context.Context, id string, request proto.RuntimePreparePayload, data []byte) <-chan capabilityOutcome { done := make(chan capabilityOutcome, 1) - go func() { r, err := s.PrepareCapabilities(ctx, id, request, data); done <- capabilityOutcome{r, err} }() + go func() { r, err := s.PrepareRuntime(ctx, id, request, data); done <- capabilityOutcome{r, err} }() return done } func nextCapabilityFrame(t *testing.T, s *Session) proto.Envelope { @@ -51,8 +52,8 @@ func finishCapabilities(t *testing.T, done <-chan capabilityOutcome) capabilityO return capabilityOutcome{} } } -func replyCapabilities(s *Session, id string, result proto.CapabilitiesResultPayload) { - reply, _ := proto.NewEnvelope(proto.TypeCapabilitiesResult, id, result) +func replyCapabilities(s *Session, id string, result proto.RuntimePrepareResultPayload) { + reply, _ := proto.NewEnvelope(proto.TypeRuntimePrepareResult, id, result) s.dispatch(reply) } func noCapabilityFrame(t *testing.T, s *Session) { @@ -75,11 +76,11 @@ func TestCapabilitiesTransfersMoreThanFrameLimitAndCorrelates(t *testing.T) { for { env := nextCapabilityFrame(t, s) encoded, err := json.Marshal(env) - var p proto.CapabilitiesPreparePayload - if err != nil || len(encoded) > proto.CapabilitiesMaxFrameBytes || env.ID != id || env.Type != proto.TypeCapabilitiesPrepare || env.DecodePayload(&p) != nil || !proto.ValidCapabilitiesPrepareRequest(p) { + var p proto.RuntimePreparePayload + if err != nil || len(encoded) > proto.RuntimePrepareMaxFrameBytes || env.ID != id || env.Type != proto.TypeRuntimePrepare || env.DecodePayload(&p) != nil || !proto.ValidRuntimePrepareRequest(p) { t.Fatal("invalid frame") } - result := proto.CapabilitiesResultPayload{} + result := proto.RuntimePrepareResultPayload{} switch p.Step { case "begin": digest := sha256.Sum256(data) @@ -115,20 +116,20 @@ func TestCapabilitiesTransfersMoreThanFrameLimitAndCorrelates(t *testing.T) { func TestCapabilitiesFinalizeTransfersNoArchive(t *testing.T) { s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) defer s.Close("test") - request := proto.CapabilitiesPreparePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "finalize", Sources: &agentcapabilities.Input{}} + request := proto.RuntimePreparePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "finalize", Sources: &agentcapabilities.Input{}} id := uuid.NewString() done := beginCapabilities(s, t.Context(), id, request, nil) env := nextCapabilityFrame(t, s) - var p proto.CapabilitiesPreparePayload + var p proto.RuntimePreparePayload if env.DecodePayload(&p) != nil || p.Step != "begin" || p.Sources == nil || p.SizeBytes != 0 || p.SHA256 != "" { t.Fatal("invalid finalization", p) } - replyCapabilities(s, id, proto.CapabilitiesResultPayload{Outcome: "ready"}) + replyCapabilities(s, id, proto.RuntimePrepareResultPayload{Outcome: "ready"}) env = nextCapabilityFrame(t, s) if env.DecodePayload(&p) != nil || p.Step != "commit" { t.Fatal("finalization sent archive") } - replyCapabilities(s, id, proto.CapabilitiesResultPayload{Outcome: "completed"}) + replyCapabilities(s, id, proto.RuntimePrepareResultPayload{Outcome: "completed"}) if result := finishCapabilities(t, done); result.err != nil || result.result.Outcome != "completed" { t.Fatal(result) } @@ -137,14 +138,14 @@ func TestCapabilitiesFinalizeTransfersNoArchive(t *testing.T) { func TestCapabilitiesRefusesConflictingArchiveBeforeSending(t *testing.T) { s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) defer s.Close("test") - for _, mutate := range []func(*proto.CapabilitiesPreparePayload){ - func(p *proto.CapabilitiesPreparePayload) { p.SHA256 = "incorrect" }, - func(p *proto.CapabilitiesPreparePayload) { p.SizeBytes = 999 }, - func(p *proto.CapabilitiesPreparePayload) { p.Sources = &agentcapabilities.Input{} }, + for _, mutate := range []func(*proto.RuntimePreparePayload){ + func(p *proto.RuntimePreparePayload) { p.SHA256 = "incorrect" }, + func(p *proto.RuntimePreparePayload) { p.SizeBytes = 999 }, + func(p *proto.RuntimePreparePayload) { p.Sources = &agentcapabilities.Input{} }, } { request := skillPreparation() mutate(&request) - if result, err := s.PrepareCapabilities(t.Context(), uuid.NewString(), request, []byte("data")); err == nil || result.Outcome != "unknown" { + if result, err := s.PrepareRuntime(t.Context(), uuid.NewString(), request, []byte("data")); err == nil || result.Outcome != "unknown" { t.Fatal("conflict admitted", result, err) } noCapabilityFrame(t, s) @@ -152,10 +153,10 @@ func TestCapabilitiesRefusesConflictingArchiveBeforeSending(t *testing.T) { } func TestCapabilitiesStopsAtTerminalOrMalformedReceipt(t *testing.T) { - for name, receipt := range map[string]proto.CapabilitiesResultPayload{ - "rejected": {Outcome: "rejected", ErrorCode: "capabilities_rejected"}, - "failed": {Outcome: "failed", ErrorCode: "capabilities_failed"}, - "unknown": {Outcome: "unknown", ErrorCode: "capabilities_unconfirmed"}, + for name, receipt := range map[string]proto.RuntimePrepareResultPayload{ + "rejected": {Outcome: "rejected", ErrorCode: "runtime_preparation_rejected"}, + "failed": {Outcome: "failed", ErrorCode: "runtime_preparation_failed"}, + "unknown": {Outcome: "unknown", ErrorCode: "runtime_preparation_unconfirmed"}, "premature completed": {Outcome: "completed", SizeBytes: 4}, "wrong offset": {Outcome: "ready", Offset: 1}, "unsafe code": {Outcome: "rejected", ErrorCode: "private detail"}, @@ -186,9 +187,9 @@ func TestCapabilitiesRejectsWrongChunkReceipt(t *testing.T) { id := uuid.NewString() done := beginCapabilities(s, t.Context(), id, skillPreparation(), []byte("data")) nextCapabilityFrame(t, s) - replyCapabilities(s, id, proto.CapabilitiesResultPayload{Outcome: "ready"}) + replyCapabilities(s, id, proto.RuntimePrepareResultPayload{Outcome: "ready"}) nextCapabilityFrame(t, s) - replyCapabilities(s, id, proto.CapabilitiesResultPayload{Outcome: "received", Offset: 3}) + replyCapabilities(s, id, proto.RuntimePrepareResultPayload{Outcome: "received", Offset: 3}) result := finishCapabilities(t, done) if result.err == nil || result.result.Outcome != "unknown" { t.Fatal(result) @@ -205,14 +206,14 @@ func TestCapabilitiesConnectionOwnershipAndUnknownInterruption(t *testing.T) { defer cancel() done := beginCapabilities(s, ctx, uuid.NewString(), skillPreparation(), []byte("data")) nextCapabilityFrame(t, s) - if _, err := s.PrepareCapabilities(t.Context(), uuid.NewString(), skillPreparation(), []byte("second")); err == nil { + if _, err := s.PrepareRuntime(t.Context(), uuid.NewString(), skillPreparation(), []byte("second")); err == nil { t.Fatal("concurrent transfer admitted") } if closeConnection { s.Close("lost connection") } result := finishCapabilities(t, done) - if result.result.Outcome != "unknown" || result.result.ErrorCode != "capabilities_unconfirmed" { + if result.result.Outcome != "unknown" || result.result.ErrorCode != "runtime_preparation_unconfirmed" { t.Fatal("interruption claimed rejection", result) } expected := error(context.DeadlineExceeded) @@ -232,3 +233,97 @@ func TestCapabilitiesConnectionOwnershipAndUnknownInterruption(t *testing.T) { }) } } + +func TestRuntimeInitialFileChunking(t *testing.T) { + for _, size := range []int{0, (4 << 20) + 1} { + t.Run(fmt.Sprint(size), func(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + request := proto.RuntimePreparePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/project/file"}} + data := bytes.Repeat([]byte("z"), size) + id := uuid.NewString() + done := beginCapabilities(s, t.Context(), id, request, data) + var received []byte + for { + env := nextCapabilityFrame(t, s) + var p proto.RuntimePreparePayload + if env.DecodePayload(&p) != nil || !proto.ValidRuntimePrepareRequest(p) { + t.Fatal("invalid file frame") + } + result := proto.RuntimePrepareResultPayload{} + switch p.Step { + case "begin": + sum := sha256.Sum256(data) + if p.File == nil || p.File.Path != request.File.Path || p.SizeBytes != size || p.SHA256 != hex.EncodeToString(sum[:]) { + t.Fatal("file header changed") + } + result.Outcome = "ready" + case "chunk": + if p.Offset != len(received) { + t.Fatal("wrong chunk offset") + } + received = append(received, p.Data...) + result.Outcome, result.Offset = "received", len(received) + case "commit": + if !bytes.Equal(data, received) { + t.Fatal("file bytes changed") + } + result.Outcome, result.SizeBytes = "completed", size + } + replyCapabilities(s, id, result) + if p.Step == "commit" { + break + } + } + got := finishCapabilities(t, done) + if got.err != nil || got.result.Outcome != "completed" { + t.Fatal(got) + } + noCapabilityFrame(t, s) + }) + } +} + +func TestRuntimeInitializationNoDataAndExitReceipt(t *testing.T) { + for _, exit := range []int{0, 42, 256} { + t.Run(fmt.Sprint(exit), func(t *testing.T) { + s := NewSession(newFakeConn(), "device", "tenant", "test", nil, nil) + defer s.Close("test") + request := proto.RuntimePreparePayload{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", Network: "enabled", Command: "echo test"}} + if _, err := s.PrepareRuntime(t.Context(), uuid.NewString(), request, []byte("forbidden")); err == nil { + t.Fatal("initialization body accepted") + } + noCapabilityFrame(t, s) + id := uuid.NewString() + done := beginCapabilities(s, t.Context(), id, request, nil) + env := nextCapabilityFrame(t, s) + var p proto.RuntimePreparePayload + if env.DecodePayload(&p) != nil { + t.Fatal("invalid initialization frame") + } + if p.Step != "begin" || p.Initialization == nil || p.Initialization.Command != request.Initialization.Command || p.SizeBytes != 0 || p.SHA256 != "" { + t.Fatal("initialization changed") + } + replyCapabilities(s, id, proto.RuntimePrepareResultPayload{Outcome: "ready"}) + env = nextCapabilityFrame(t, s) + p = proto.RuntimePreparePayload{} + if env.DecodePayload(&p) != nil || p.Step != "commit" { + t.Fatal("initialization sent data") + } + result := proto.RuntimePrepareResultPayload{Outcome: "completed"} + if exit != 0 { + result = proto.RuntimePrepareResultPayload{Outcome: "failed", ErrorCode: "runtime_preparation_failed", ExitCode: exit} + } + replyCapabilities(s, id, result) + got := finishCapabilities(t, done) + if exit == 256 { + if got.err == nil || got.result.Outcome != "unknown" { + t.Fatal("unsafe exit accepted", got) + } + } else if got.err != nil || got.result != result { + t.Fatal(got) + } + noCapabilityFrame(t, s) + }) + } +} diff --git a/internal/agentdaemon/gateway/session.go b/internal/agentdaemon/gateway/session.go index 1da27c584..edd779e1c 100644 --- a/internal/agentdaemon/gateway/session.go +++ b/internal/agentdaemon/gateway/session.go @@ -608,7 +608,7 @@ func (s *Session) dispatch(env proto.Envelope) { case proto.TypeWorkspaceExportResult: s.dispatchWorkspaceExport(env) return - case proto.TypeCapabilitiesResult: + case proto.TypeRuntimePrepareResult: s.dispatchCapabilities(env) return case proto.TypeWorkspaceWriteResult: diff --git a/internal/agentdaemon/proto/capabilities.go b/internal/agentdaemon/proto/capabilities.go deleted file mode 100644 index e12db9a12..000000000 --- a/internal/agentdaemon/proto/capabilities.go +++ /dev/null @@ -1,132 +0,0 @@ -package proto - -import ( - "encoding/hex" - "encoding/json" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" - "github.com/google/uuid" -) - -const ( - TypeCapabilitiesPrepare = "capabilities_prepare" - TypeCapabilitiesResult = "capabilities_result" - CapabilitiesMaxBytes = 50 << 20 - CapabilitiesChunkBytes = WorkspaceWriteChunkBytes - CapabilitiesMaxFrameBytes = 1 << 20 -) - -// CapabilitiesPreparePayload imports an inert archive or finalizes frozen source -// selections. Runtime owns all destinations; this protocol supplies no command or -// installation path. Envelope.ID identifies one connection-local transfer. -type CapabilitiesPreparePayload struct { - Step string `json:"step"` - EnvironmentID string `json:"environment_id,omitempty"` - SessionID string `json:"session_id,omitempty"` - Action string `json:"action,omitempty"` - Slot int `json:"slot,omitempty"` - Skill *agentskill.Metadata `json:"skill,omitempty"` - Plugin *agentplugin.Metadata `json:"plugin,omitempty"` - Sources *agentcapabilities.Input `json:"sources,omitempty"` - SizeBytes int `json:"size_bytes,omitempty"` - SHA256 string `json:"sha256,omitempty"` - Offset int `json:"offset,omitempty"` - Data []byte `json:"data,omitempty"` -} - -type CapabilitiesResultPayload struct { - Outcome string `json:"outcome"` - Offset int `json:"offset,omitempty"` - SizeBytes int `json:"size_bytes,omitempty"` - ErrorCode string `json:"error_code,omitempty"` -} - -func ValidCapabilitiesPrepareRequest(p CapabilitiesPreparePayload) bool { - if p.Step == "begin" { - for _, id := range []string{p.EnvironmentID, p.SessionID} { - value, err := uuid.Parse(id) - if err != nil || value == uuid.Nil || value.String() != id { - return false - } - } - if p.Offset != 0 || len(p.Data) != 0 { - return false - } - switch p.Action { - case "skill": - if p.Skill == nil || p.Plugin != nil || p.Sources != nil || p.Slot != 0 || - agentcapabilities.ValidateInput(agentcapabilities.Input{Skills: []agentskill.Metadata{*p.Skill}}) != nil { - return false - } - case "plugin": - if p.Plugin == nil || p.Skill != nil || p.Sources != nil || p.Slot < 0 || p.Slot >= 50 || - agentcapabilities.ValidateInput(agentcapabilities.Input{Plugins: []agentplugin.Metadata{*p.Plugin}}) != nil { - return false - } - case "finalize": - if p.Skill != nil || p.Plugin != nil || p.Sources == nil || p.Slot != 0 || - p.SizeBytes != 0 || p.SHA256 != "" || agentcapabilities.ValidateInput(*p.Sources) != nil { - return false - } - default: - return false - } - if p.Action != "finalize" { - digest, err := hex.DecodeString(p.SHA256) - if err != nil || len(digest) != 32 || strings.ToLower(p.SHA256) != p.SHA256 || - p.SizeBytes <= 0 || p.SizeBytes > CapabilitiesMaxBytes { - return false - } - } - encoded, err := json.Marshal(p) - return err == nil && len(encoded) <= CapabilitiesMaxFrameBytes - } - if p.EnvironmentID != "" || p.SessionID != "" || p.Action != "" || p.Slot != 0 || - p.Skill != nil || p.Plugin != nil || p.Sources != nil || p.SizeBytes != 0 || p.SHA256 != "" { - return false - } - switch p.Step { - case "chunk": - return p.Offset >= 0 && p.Offset <= CapabilitiesMaxBytes && len(p.Data) > 0 && - len(p.Data) <= CapabilitiesChunkBytes && len(p.Data) <= CapabilitiesMaxBytes-p.Offset - case "commit": - return p.Offset == 0 && len(p.Data) == 0 - default: - return false - } -} - -// ValidCapabilitiesResult accepts only the expected success receipt or a finite -// terminal category. An unknown effect can never be represented as rejection. -func ValidCapabilitiesResult(r CapabilitiesResultPayload, expected string, offset, size int) bool { - switch r.Outcome { - case "rejected": - if r.Offset != 0 || r.SizeBytes != 0 { - return false - } - switch r.ErrorCode { - case "invalid_request", "resource_unavailable", "capabilities_capacity", "capabilities_unsupported", "capabilities_rejected": - return true - default: - return false - } - case "failed": - return r.Offset == 0 && r.SizeBytes == 0 && r.ErrorCode == "capabilities_failed" - case "unknown": - return r.Offset == 0 && r.SizeBytes == 0 && r.ErrorCode == "capabilities_unconfirmed" - } - if r.Outcome != expected || r.Offset != offset || r.ErrorCode != "" { - return false - } - switch expected { - case "completed": - return r.SizeBytes == size - case "ready", "received": - return r.SizeBytes == 0 - default: - return false - } -} diff --git a/internal/agentdaemon/proto/capabilities_test.go b/internal/agentdaemon/proto/capabilities_test.go deleted file mode 100644 index e528e1767..000000000 --- a/internal/agentdaemon/proto/capabilities_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package proto - -import ( - "encoding/json" - "reflect" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" - "github.com/google/uuid" -) - -func capabilityBegin() CapabilitiesPreparePayload { - return CapabilitiesPreparePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), - Action: "skill", Skill: &agentskill.Metadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 10, SHA256: strings.Repeat("a", 64)} -} - -func TestCapabilitiesRequestValidation(t *testing.T) { - valid := capabilityBegin() - if !ValidCapabilitiesPrepareRequest(valid) { - t.Fatal("valid skill refused") - } - for name, mutate := range map[string]func(*CapabilitiesPreparePayload){ - "noncanonical identity": func(p *CapabilitiesPreparePayload) { p.EnvironmentID = "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA" }, - "zero identity": func(p *CapabilitiesPreparePayload) { p.SessionID = uuid.Nil.String() }, - "begin data": func(p *CapabilitiesPreparePayload) { p.Data = []byte("x") }, - "begin offset": func(p *CapabilitiesPreparePayload) { p.Offset = 1 }, - "mixed metadata": func(p *CapabilitiesPreparePayload) { - p.Plugin = &agentplugin.Metadata{Type: "inline", Name: "example", Description: "Example"} - }, - "mixed sources": func(p *CapabilitiesPreparePayload) { p.Sources = &agentcapabilities.Input{} }, - "missing metadata": func(p *CapabilitiesPreparePayload) { p.Skill = nil }, - "wrong metadata type": func(p *CapabilitiesPreparePayload) { - p.Skill = &agentskill.Metadata{Type: "reference", Name: "example", Description: "Example"} - }, - "missing description": func(p *CapabilitiesPreparePayload) { p.Skill = &agentskill.Metadata{Type: "inline", Name: "example"} }, - "oversized header": func(p *CapabilitiesPreparePayload) { - p.Skill = &agentskill.Metadata{Type: "inline", Name: "example", Description: strings.Repeat("x", CapabilitiesMaxFrameBytes)} - }, - "skill slot": func(p *CapabilitiesPreparePayload) { p.Slot = 1 }, - "no digest": func(p *CapabilitiesPreparePayload) { p.SHA256 = "" }, - "uppercase digest": func(p *CapabilitiesPreparePayload) { p.SHA256 = strings.ToUpper(p.SHA256) }, - "short digest": func(p *CapabilitiesPreparePayload) { p.SHA256 = "aa" }, - "no archive": func(p *CapabilitiesPreparePayload) { p.SizeBytes = 0 }, - "oversized archive": func(p *CapabilitiesPreparePayload) { p.SizeBytes = CapabilitiesMaxBytes + 1 }, - } { - t.Run(name, func(t *testing.T) { - p := valid - mutate(&p) - if ValidCapabilitiesPrepareRequest(p) { - t.Fatal("invalid begin accepted") - } - }) - } - plugin := valid - plugin.Action, plugin.Skill, plugin.Plugin, plugin.Slot = "plugin", nil, &agentplugin.Metadata{Type: "inline", Name: "example", Description: "Example"}, 49 - if !ValidCapabilitiesPrepareRequest(plugin) { - t.Fatal("valid plugin refused") - } - plugin.Slot = 50 - if ValidCapabilitiesPrepareRequest(plugin) { - t.Fatal("out of range slot accepted") - } - for _, p := range []CapabilitiesPreparePayload{ - {Step: "chunk", Offset: 0, Data: []byte("x")}, - {Step: "chunk", Offset: CapabilitiesMaxBytes - CapabilitiesChunkBytes, Data: make([]byte, CapabilitiesChunkBytes)}, - {Step: "commit"}, - } { - if !ValidCapabilitiesPrepareRequest(p) { - t.Fatal("valid continuation refused", p.Step) - } - } - for _, p := range []CapabilitiesPreparePayload{ - {Step: "chunk", Data: []byte("x"), Action: "skill"}, - {Step: "chunk", Data: []byte("x"), EnvironmentID: valid.EnvironmentID}, - {Step: "chunk", Data: []byte("x"), Skill: valid.Skill}, - {Step: "chunk", Data: []byte("x"), Offset: -1}, - {Step: "chunk", Data: []byte("x"), Offset: CapabilitiesMaxBytes}, - {Step: "chunk", Data: make([]byte, CapabilitiesChunkBytes+1)}, - {Step: "chunk"}, - {Step: "commit", Offset: 1}, - {Step: "commit", Data: []byte("x")}, - {Step: "commit", Sources: &agentcapabilities.Input{}}, - } { - if ValidCapabilitiesPrepareRequest(p) { - t.Fatal("invalid continuation accepted", p.Step) - } - } -} - -func TestCapabilitiesFinalizeRequiresExplicitBoundedSelection(t *testing.T) { - p := CapabilitiesPreparePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "finalize", Sources: &agentcapabilities.Input{}} - if !ValidCapabilitiesPrepareRequest(p) { - t.Fatal("explicit empty finalization refused") - } - for _, mutate := range []func(*CapabilitiesPreparePayload){ - func(p *CapabilitiesPreparePayload) { p.Sources = nil }, - func(p *CapabilitiesPreparePayload) { p.SHA256 = strings.Repeat("a", 64) }, - func(p *CapabilitiesPreparePayload) { p.SizeBytes = 1 }, - func(p *CapabilitiesPreparePayload) { - p.Sources = &agentcapabilities.Input{Directories: make([]string, 51)} - }, - func(p *CapabilitiesPreparePayload) { - p.Sources = &agentcapabilities.Input{Directories: []string{"/workspace/../secret"}} - }, - func(p *CapabilitiesPreparePayload) { - p.Sources = &agentcapabilities.Input{Directories: []string{"/workspace/a", "/workspace/a"}} - }, - } { - bad := p - mutate(&bad) - if ValidCapabilitiesPrepareRequest(bad) { - t.Fatal("invalid finalization accepted") - } - } -} - -func TestCapabilitiesResultCannotMisrepresentUncertainty(t *testing.T) { - for _, r := range []CapabilitiesResultPayload{ - {Outcome: "rejected", ErrorCode: "capabilities_unconfirmed"}, - {Outcome: "unknown", ErrorCode: "capabilities_rejected"}, - {Outcome: "failed", ErrorCode: "private detail"}, - {Outcome: "rejected", ErrorCode: "invalid_request", Offset: 1}, - {Outcome: "unknown", ErrorCode: "capabilities_unconfirmed", SizeBytes: 10}, - {Outcome: "completed", SizeBytes: 10}, - {Outcome: "ready", Offset: 1}, - {Outcome: "ready", SizeBytes: 10}, - {Outcome: "ready", ErrorCode: "invalid_request"}, - } { - if ValidCapabilitiesResult(r, "ready", 0, 10) { - t.Fatal("unsafe receipt accepted", r) - } - } - if ValidCapabilitiesResult(CapabilitiesResultPayload{Outcome: "received", Offset: 1}, "received", 2, 10) { - t.Fatal("wrong offset accepted") - } - if ValidCapabilitiesResult(CapabilitiesResultPayload{Outcome: "completed", SizeBytes: 9}, "completed", 0, 10) { - t.Fatal("wrong size accepted") - } - for _, r := range []CapabilitiesResultPayload{ - {Outcome: "rejected", ErrorCode: "invalid_request"}, - {Outcome: "failed", ErrorCode: "capabilities_failed"}, - {Outcome: "unknown", ErrorCode: "capabilities_unconfirmed"}, - {Outcome: "completed", SizeBytes: 10}, - } { - if !ValidCapabilitiesResult(r, "completed", 0, 10) { - t.Fatal("safe receipt refused", r) - } - } -} - -func TestLocalEnvironmentCarriesSelectionButNeverInstalledRoots(t *testing.T) { - input := &agentcapabilities.Input{Directories: []string{"/workspace/capabilities"}} - original := LocalEnvironment{ID: uuid.NewString(), WorkspaceDirectory: "/workspace", CapabilitySources: input, Capabilities: true, - Skills: []agentcapabilities.InstalledSkill{{RelativeRoot: "private-installed-root"}}, - MCP: []EnvironmentMCP{{PackageRoot: "private-mcp-root"}}} - raw, err := json.Marshal(original) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(raw), "private-") { - t.Fatal("installed roots leaked to wire") - } - var decoded LocalEnvironment - if err := json.Unmarshal(raw, &decoded); err != nil { - t.Fatal(err) - } - if decoded.ID != original.ID || decoded.WorkspaceDirectory != "/workspace" || !reflect.DeepEqual(decoded.CapabilitySources, input) || !decoded.Capabilities || len(decoded.Skills) != 0 || len(decoded.MCP) != 0 { - t.Fatal("selection round trip changed", decoded) - } -} diff --git a/internal/agentdaemon/proto/runtime_prepare.go b/internal/agentdaemon/proto/runtime_prepare.go new file mode 100644 index 000000000..921f75efc --- /dev/null +++ b/internal/agentdaemon/proto/runtime_prepare.go @@ -0,0 +1,214 @@ +package proto + +import ( + "encoding/hex" + "encoding/json" + "path" + "strings" + "unicode/utf8" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/google/uuid" +) + +const ( + TypeRuntimePrepare = "runtime_prepare" + TypeRuntimePrepareResult = "runtime_prepare_result" + RuntimePrepareMaxBytes = 50 << 20 + RuntimePrepareChunkBytes = WorkspaceWriteChunkBytes + RuntimePrepareMaxFrameBytes = 1 << 20 +) + +// RuntimeInitialFile addresses a file within the logical workspace. +type RuntimeInitialFile struct { + Path string `json:"path"` +} + +type RuntimeInitialization struct { + Action string `json:"action"` + Env map[string]string `json:"env,omitempty"` + Network string `json:"network,omitempty"` + Packages []string `json:"packages,omitempty"` + Command string `json:"command,omitempty"` + CWD string `json:"cwd,omitempty"` +} + +// RuntimePreparePayload carries typed initialization or capability preparation. +// Runtime resolves logical paths and owns installation destinations. Envelope.ID +// identifies one connection-local transfer. +type RuntimePreparePayload struct { + Step string `json:"step"` + EnvironmentID string `json:"environment_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + Action string `json:"action,omitempty"` + Slot int `json:"slot,omitempty"` + Skill *agentskill.Metadata `json:"skill,omitempty"` + Plugin *agentplugin.Metadata `json:"plugin,omitempty"` + File *RuntimeInitialFile `json:"file,omitempty"` + Initialization *RuntimeInitialization `json:"initialization,omitempty"` + Sources *agentcapabilities.Input `json:"sources,omitempty"` + SizeBytes int `json:"size_bytes,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Offset int `json:"offset,omitempty"` + Data []byte `json:"data,omitempty"` +} + +type RuntimePrepareResultPayload struct { + ExitCode int `json:"exit_code,omitempty"` + Outcome string `json:"outcome"` + Offset int `json:"offset,omitempty"` + SizeBytes int `json:"size_bytes,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +func ValidRuntimePrepareRequest(p RuntimePreparePayload) bool { + if p.Step == "begin" { + for _, id := range []string{p.EnvironmentID, p.SessionID} { + value, err := uuid.Parse(id) + if err != nil || value == uuid.Nil || value.String() != id { + return false + } + } + if p.Offset != 0 || len(p.Data) != 0 { + return false + } + metadata := 0 + for _, present := range []bool{p.Skill != nil, p.Plugin != nil, p.Sources != nil, p.File != nil, p.Initialization != nil} { + if present { + metadata++ + } + } + if metadata != 1 { + return false + } + switch p.Action { + case "skill": + if p.Skill == nil || p.Plugin != nil || p.Sources != nil || p.Slot != 0 || + agentcapabilities.ValidateInput(agentcapabilities.Input{Skills: []agentskill.Metadata{*p.Skill}}) != nil { + return false + } + case "plugin": + if p.Plugin == nil || p.Skill != nil || p.Sources != nil || p.Slot < 0 || p.Slot >= 50 || + agentcapabilities.ValidateInput(agentcapabilities.Input{Plugins: []agentplugin.Metadata{*p.Plugin}}) != nil { + return false + } + case "file": + if p.File == nil || p.Slot != 0 || !validWorkspacePath(p.File.Path, false) { + return false + } + case "initialize": + if p.Initialization == nil || p.Slot != 0 || p.SizeBytes != 0 || p.SHA256 != "" || !validRuntimeInitialization(*p.Initialization) { + return false + } + case "finalize": + if p.Skill != nil || p.Plugin != nil || p.Sources == nil || p.Slot != 0 || + p.SizeBytes != 0 || p.SHA256 != "" || agentcapabilities.ValidateInput(*p.Sources) != nil { + return false + } + default: + return false + } + if p.Action != "finalize" && p.Action != "initialize" { + digest, err := hex.DecodeString(p.SHA256) + if err != nil || len(digest) != 32 || strings.ToLower(p.SHA256) != p.SHA256 || + p.SizeBytes < 0 || (p.SizeBytes == 0 && p.Action != "file") || p.SizeBytes > RuntimePrepareMaxBytes { + return false + } + } + encoded, err := json.Marshal(p) + return err == nil && len(encoded) <= RuntimePrepareMaxFrameBytes + } + if p.EnvironmentID != "" || p.SessionID != "" || p.Action != "" || p.Slot != 0 || + p.Skill != nil || p.Plugin != nil || p.Sources != nil || p.File != nil || p.Initialization != nil || p.SizeBytes != 0 || p.SHA256 != "" { + return false + } + switch p.Step { + case "chunk": + return p.Offset >= 0 && p.Offset <= RuntimePrepareMaxBytes && len(p.Data) > 0 && + len(p.Data) <= RuntimePrepareChunkBytes && len(p.Data) <= RuntimePrepareMaxBytes-p.Offset + case "commit": + return p.Offset == 0 && len(p.Data) == 0 + default: + return false + } +} + +// ValidRuntimePrepareResult accepts only the expected success receipt or a finite +// terminal category. An unknown effect can never be represented as rejection. +func ValidRuntimePrepareResult(r RuntimePrepareResultPayload, expected string, offset, size int) bool { + if r.ExitCode < 0 || r.ExitCode > 255 || (r.Outcome != "failed" && r.ExitCode != 0) { + return false + } + switch r.Outcome { + case "rejected": + if r.Offset != 0 || r.SizeBytes != 0 { + return false + } + switch r.ErrorCode { + case "invalid_request", "resource_unavailable", "runtime_preparation_capacity", "runtime_preparation_unsupported", "runtime_preparation_rejected": + return true + default: + return false + } + case "failed": + return r.Offset == 0 && r.SizeBytes == 0 && r.ErrorCode == "runtime_preparation_failed" + case "unknown": + return r.Offset == 0 && r.SizeBytes == 0 && r.ErrorCode == "runtime_preparation_unconfirmed" + } + if r.Outcome != expected || r.Offset != offset || r.ErrorCode != "" { + return false + } + switch expected { + case "completed": + return r.SizeBytes == size + case "ready", "received": + return r.SizeBytes == 0 + default: + return false + } +} + +// Workspace paths are logical protocol addresses, resolved by the Runtime. +func validWorkspacePath(value string, allowRoot bool) bool { + if len(value) > 4096 || !utf8.ValidString(value) || path.Clean(value) != value || strings.ContainsAny(value, "\\\x00\r\n") { + return false + } + return (allowRoot && value == "/workspace") || strings.HasPrefix(value, "/workspace/") +} + +func validRuntimeInitialization(p RuntimeInitialization) bool { + if p.Network != "" && p.Network != "enabled" && p.Network != "disabled" { + return false + } + if p.CWD != "" && !validWorkspacePath(p.CWD, true) { + return false + } + switch p.Action { + case "configure": + if p.Packages != nil || p.Command != "" || p.CWD != "" || len(p.Env) > 1000 { + return false + } + for key, value := range p.Env { + if key == "" || !utf8.ValidString(key) || !utf8.ValidString(value) || strings.ContainsAny(key, "=\x00\r\n") || strings.ContainsRune(value, 0) { + return false + } + } + return true + case "system", "npm", "python": + if p.Env != nil || p.Command != "" || p.CWD != "" || len(p.Packages) == 0 || len(p.Packages) > 1000 || p.Network == "" { + return false + } + for _, value := range p.Packages { + if value == "" || !utf8.ValidString(value) || strings.HasPrefix(value, "-") || strings.ContainsAny(value, "\x00\r\n") { + return false + } + } + return true + case "setup": + return p.Env == nil && p.Packages == nil && p.Network != "" && p.Command != "" && utf8.ValidString(p.Command) && !strings.ContainsRune(p.Command, 0) + default: + return false + } +} diff --git a/internal/agentdaemon/proto/runtime_prepare_test.go b/internal/agentdaemon/proto/runtime_prepare_test.go new file mode 100644 index 000000000..588520ca8 --- /dev/null +++ b/internal/agentdaemon/proto/runtime_prepare_test.go @@ -0,0 +1,275 @@ +package proto + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "github.com/MiniMax-AI-Dev/parsar/internal/agentskill" + "github.com/google/uuid" +) + +func capabilityBegin() RuntimePreparePayload { + return RuntimePreparePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), + Action: "skill", Skill: &agentskill.Metadata{Type: "inline", Name: "example", Description: "Example"}, SizeBytes: 10, SHA256: strings.Repeat("a", 64)} +} + +func TestCapabilitiesRequestValidation(t *testing.T) { + valid := capabilityBegin() + if !ValidRuntimePrepareRequest(valid) { + t.Fatal("valid skill refused") + } + for name, mutate := range map[string]func(*RuntimePreparePayload){ + "noncanonical identity": func(p *RuntimePreparePayload) { p.EnvironmentID = "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA" }, + "zero identity": func(p *RuntimePreparePayload) { p.SessionID = uuid.Nil.String() }, + "begin data": func(p *RuntimePreparePayload) { p.Data = []byte("x") }, + "begin offset": func(p *RuntimePreparePayload) { p.Offset = 1 }, + "mixed metadata": func(p *RuntimePreparePayload) { + p.Plugin = &agentplugin.Metadata{Type: "inline", Name: "example", Description: "Example"} + }, + "mixed sources": func(p *RuntimePreparePayload) { p.Sources = &agentcapabilities.Input{} }, + "missing metadata": func(p *RuntimePreparePayload) { p.Skill = nil }, + "wrong metadata type": func(p *RuntimePreparePayload) { + p.Skill = &agentskill.Metadata{Type: "reference", Name: "example", Description: "Example"} + }, + "missing description": func(p *RuntimePreparePayload) { p.Skill = &agentskill.Metadata{Type: "inline", Name: "example"} }, + "oversized header": func(p *RuntimePreparePayload) { + p.Skill = &agentskill.Metadata{Type: "inline", Name: "example", Description: strings.Repeat("x", RuntimePrepareMaxFrameBytes)} + }, + "skill slot": func(p *RuntimePreparePayload) { p.Slot = 1 }, + "no digest": func(p *RuntimePreparePayload) { p.SHA256 = "" }, + "uppercase digest": func(p *RuntimePreparePayload) { p.SHA256 = strings.ToUpper(p.SHA256) }, + "short digest": func(p *RuntimePreparePayload) { p.SHA256 = "aa" }, + "no archive": func(p *RuntimePreparePayload) { p.SizeBytes = 0 }, + "oversized archive": func(p *RuntimePreparePayload) { p.SizeBytes = RuntimePrepareMaxBytes + 1 }, + } { + t.Run(name, func(t *testing.T) { + p := valid + mutate(&p) + if ValidRuntimePrepareRequest(p) { + t.Fatal("invalid begin accepted") + } + }) + } + plugin := valid + plugin.Action, plugin.Skill, plugin.Plugin, plugin.Slot = "plugin", nil, &agentplugin.Metadata{Type: "inline", Name: "example", Description: "Example"}, 49 + if !ValidRuntimePrepareRequest(plugin) { + t.Fatal("valid plugin refused") + } + plugin.Slot = 50 + if ValidRuntimePrepareRequest(plugin) { + t.Fatal("out of range slot accepted") + } + for _, p := range []RuntimePreparePayload{ + {Step: "chunk", Offset: 0, Data: []byte("x")}, + {Step: "chunk", Offset: RuntimePrepareMaxBytes - RuntimePrepareChunkBytes, Data: make([]byte, RuntimePrepareChunkBytes)}, + {Step: "commit"}, + } { + if !ValidRuntimePrepareRequest(p) { + t.Fatal("valid continuation refused", p.Step) + } + } + for _, p := range []RuntimePreparePayload{ + {Step: "chunk", Data: []byte("x"), Action: "skill"}, + {Step: "chunk", Data: []byte("x"), EnvironmentID: valid.EnvironmentID}, + {Step: "chunk", Data: []byte("x"), Skill: valid.Skill}, + {Step: "chunk", Data: []byte("x"), Offset: -1}, + {Step: "chunk", Data: []byte("x"), Offset: RuntimePrepareMaxBytes}, + {Step: "chunk", Data: make([]byte, RuntimePrepareChunkBytes+1)}, + {Step: "chunk"}, + {Step: "commit", Offset: 1}, + {Step: "commit", Data: []byte("x")}, + {Step: "commit", Sources: &agentcapabilities.Input{}}, + } { + if ValidRuntimePrepareRequest(p) { + t.Fatal("invalid continuation accepted", p.Step) + } + } +} + +func TestCapabilitiesFinalizeRequiresExplicitBoundedSelection(t *testing.T) { + p := RuntimePreparePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "finalize", Sources: &agentcapabilities.Input{}} + if !ValidRuntimePrepareRequest(p) { + t.Fatal("explicit empty finalization refused") + } + for _, mutate := range []func(*RuntimePreparePayload){ + func(p *RuntimePreparePayload) { p.Sources = nil }, + func(p *RuntimePreparePayload) { p.SHA256 = strings.Repeat("a", 64) }, + func(p *RuntimePreparePayload) { p.SizeBytes = 1 }, + func(p *RuntimePreparePayload) { + p.Sources = &agentcapabilities.Input{Directories: make([]string, 51)} + }, + func(p *RuntimePreparePayload) { + p.Sources = &agentcapabilities.Input{Directories: []string{"/workspace/../secret"}} + }, + func(p *RuntimePreparePayload) { + p.Sources = &agentcapabilities.Input{Directories: []string{"/workspace/a", "/workspace/a"}} + }, + } { + bad := p + mutate(&bad) + if ValidRuntimePrepareRequest(bad) { + t.Fatal("invalid finalization accepted") + } + } +} + +func TestCapabilitiesResultCannotMisrepresentUncertainty(t *testing.T) { + for _, r := range []RuntimePrepareResultPayload{ + {Outcome: "rejected", ErrorCode: "runtime_preparation_unconfirmed"}, + {Outcome: "unknown", ErrorCode: "runtime_preparation_rejected"}, + {Outcome: "failed", ErrorCode: "private detail"}, + {Outcome: "rejected", ErrorCode: "invalid_request", Offset: 1}, + {Outcome: "unknown", ErrorCode: "runtime_preparation_unconfirmed", SizeBytes: 10}, + {Outcome: "completed", SizeBytes: 10}, + {Outcome: "ready", Offset: 1}, + {Outcome: "ready", SizeBytes: 10}, + {Outcome: "ready", ErrorCode: "invalid_request"}, + } { + if ValidRuntimePrepareResult(r, "ready", 0, 10) { + t.Fatal("unsafe receipt accepted", r) + } + } + if ValidRuntimePrepareResult(RuntimePrepareResultPayload{Outcome: "received", Offset: 1}, "received", 2, 10) { + t.Fatal("wrong offset accepted") + } + if ValidRuntimePrepareResult(RuntimePrepareResultPayload{Outcome: "completed", SizeBytes: 9}, "completed", 0, 10) { + t.Fatal("wrong size accepted") + } + for _, r := range []RuntimePrepareResultPayload{ + {Outcome: "rejected", ErrorCode: "invalid_request"}, + {Outcome: "failed", ErrorCode: "runtime_preparation_failed"}, + {Outcome: "unknown", ErrorCode: "runtime_preparation_unconfirmed"}, + {Outcome: "completed", SizeBytes: 10}, + } { + if !ValidRuntimePrepareResult(r, "completed", 0, 10) { + t.Fatal("safe receipt refused", r) + } + } +} + +func TestLocalEnvironmentCarriesSelectionButNeverInstalledRoots(t *testing.T) { + input := &agentcapabilities.Input{Directories: []string{"/workspace/capabilities"}} + original := LocalEnvironment{ID: uuid.NewString(), WorkspaceDirectory: "/workspace", CapabilitySources: input, Capabilities: true, + Skills: []agentcapabilities.InstalledSkill{{RelativeRoot: "private-installed-root"}}, + MCP: []EnvironmentMCP{{PackageRoot: "private-mcp-root"}}} + raw, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "private-") { + t.Fatal("installed roots leaked to wire") + } + var decoded LocalEnvironment + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if decoded.ID != original.ID || decoded.WorkspaceDirectory != "/workspace" || !reflect.DeepEqual(decoded.CapabilitySources, input) || !decoded.Capabilities || len(decoded.Skills) != 0 || len(decoded.MCP) != 0 { + t.Fatal("selection round trip changed", decoded) + } +} + +func TestRuntimePreparationInitialActions(t *testing.T) { + base := RuntimePreparePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString()} + for _, initialization := range []RuntimeInitialization{ + {Action: "configure", Env: map[string]string{"EXAMPLE": "value"}}, + {Action: "system", Network: "enabled", Packages: []string{"git"}}, + {Action: "npm", Network: "disabled", Packages: []string{"typescript"}}, + {Action: "python", Network: "enabled", Packages: []string{"requests"}}, + {Action: "setup", Network: "enabled", Command: "echo done"}, + {Action: "setup", Network: "disabled", Command: "echo done", CWD: "/workspace/project"}, + } { + p := base + p.Action, p.Initialization = "initialize", &initialization + if !ValidRuntimePrepareRequest(p) { + t.Fatalf("valid initialization refused: %s", initialization.Action) + } + for _, mutate := range []func(*RuntimePreparePayload){ + func(p *RuntimePreparePayload) { p.File = &RuntimeInitialFile{Path: "/workspace/a"} }, + func(p *RuntimePreparePayload) { p.SizeBytes = 1 }, + func(p *RuntimePreparePayload) { p.SHA256 = strings.Repeat("a", 64) }, + func(p *RuntimePreparePayload) { p.Data = []byte("x") }, + } { + bad := p + mutate(&bad) + if ValidRuntimePrepareRequest(bad) { + t.Fatal("mixed initialization accepted") + } + } + } + for _, initialization := range []RuntimeInitialization{ + {Action: "exec", Command: "echo done"}, + {Action: "configure", Packages: []string{"git"}}, + {Action: "configure", Env: map[string]string{"A=B": "value"}}, + {Action: "system", Network: "enabled"}, + {Action: "npm", Network: "invalid", Packages: []string{"a"}}, + {Action: "python", Network: "enabled", Packages: []string{"--help"}}, + {Action: "setup", Network: "enabled", Command: "x", CWD: "/environment"}, + {Action: "setup", Network: "enabled", Command: "x", CWD: "/workspace/../private"}, + {Action: "setup", Network: "enabled", Command: "x", Env: map[string]string{}}, + {Action: "setup", Network: "enabled", Command: "x", Packages: []string{}}, + } { + p := base + p.Action, p.Initialization = "initialize", &initialization + if ValidRuntimePrepareRequest(p) { + t.Fatalf("invalid initialization accepted: %+v", initialization) + } + } + p := base + p.Action, p.File, p.SHA256 = "file", &RuntimeInitialFile{Path: "/workspace/empty"}, strings.Repeat("a", 64) + if !ValidRuntimePrepareRequest(p) { + t.Fatal("empty initial file refused") + } + for _, name := range []string{"/workspace", "/workspace/../secret", "/workspace/a/", "/environment/file", `C:\workspace\file`, "/workspace/a\\b"} { + bad := p + bad.File = &RuntimeInitialFile{Path: name} + if ValidRuntimePrepareRequest(bad) { + t.Fatal("invalid logical file accepted", name) + } + } + for _, p := range []RuntimePreparePayload{{Step: "commit", File: &RuntimeInitialFile{Path: "/workspace/a"}}, {Step: "chunk", Data: []byte("a"), Initialization: &RuntimeInitialization{Action: "configure"}}} { + if ValidRuntimePrepareRequest(p) { + t.Fatal("continuation metadata accepted") + } + } +} + +func TestRuntimePreparationExitCodes(t *testing.T) { + for _, code := range []int{0, 1, 255} { + if !ValidRuntimePrepareResult(RuntimePrepareResultPayload{Outcome: "failed", ErrorCode: "runtime_preparation_failed", ExitCode: code}, "ready", 0, 0) { + t.Fatal("valid exit refused", code) + } + } + for _, outcome := range []string{"ready", "received", "completed", "rejected", "unknown", "failed"} { + for _, code := range []int{-1, 256, 1} { + if outcome == "failed" && code == 1 { + continue + } + r := RuntimePrepareResultPayload{Outcome: outcome, ExitCode: code} + switch outcome { + case "rejected": + r.ErrorCode = "invalid_request" + case "unknown": + r.ErrorCode = "runtime_preparation_unconfirmed" + case "failed": + r.ErrorCode = "runtime_preparation_failed" + } + if ValidRuntimePrepareResult(r, outcome, 0, 0) { + t.Fatal("invalid exit combination", r) + } + } + } +} + +func TestRuntimePreparationPortableSources(t *testing.T) { + p := RuntimePreparePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Action: "finalize", Sources: &agentcapabilities.Input{Directories: []string{`C:\Users\operator\skills`, `\\host\share\skills`}}} + if !ValidRuntimePrepareRequest(p) { + t.Fatal("portable sources rejected by wire") + } + if agentcapabilities.ValidateLocalDirectories(p.Sources.Directories) == nil { + t.Fatal("Windows sources accepted by Linux resolver") + } +} diff --git a/services/agents-api/deploy/runtime/initialize.py b/services/agents-api/deploy/runtime/initialize.py index 686e21970..aa7ae851a 100644 --- a/services/agents-api/deploy/runtime/initialize.py +++ b/services/agents-api/deploy/runtime/initialize.py @@ -107,15 +107,15 @@ def sandbox(network, cwd, workspace=None): return args + ['--chdir', cwd, '--'] -def run(request): +def run(request, workspace=None): action = request['action'] if action == 'configure': - configure(request['env']) + configure(request.get('env', {})) return if action == 'system': - runpy.run_path('/usr/local/bin/oac-tool-root')['install'](request['packages']) + runpy.run_path('/usr/local/bin/oac-tool-root')['install'](request['packages'], workspace=workspace or str(ROOT / 'workspace')) return - args = sandbox(request['network'], request.get('cwd', '/workspace')) + args = sandbox(request['network'], request.get('cwd', '/workspace'), workspace=workspace) if action == 'setup': command = request['command'] if not isinstance(command, str) or not command or '\x00' in command: @@ -230,7 +230,10 @@ def stdio(installation_root, workspace, package, server): def main(): - if len(sys.argv) != 1: + workspace = None + if len(sys.argv) == 3 and sys.argv[1] == 'initialize': + workspace = sys.argv[2] + elif len(sys.argv) != 1: try: if len(sys.argv) != 6 or sys.argv[1] != 'stdio': raise ValueError('invalid stdio invocation') @@ -245,8 +248,18 @@ def main(): request = json.loads(raw) if not isinstance(request, dict) or request.get('version') != 1: raise ValueError('invalid version') - roots() - run(request) + if workspace is not None: + path = Path(workspace) + if (not workspace.startswith('/') or workspace == '/' or os.path.normpath(workspace) != workspace + or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in workspace) + or path.resolve(strict=True) != path or not path.is_dir()): + raise ValueError('invalid Runtime workspace') + if workspace is None: + roots() + run(request) + else: + roots(workspace) + run(request, workspace=workspace) except Exception as error: print(failed_receipt(error)) return 1 diff --git a/services/agents-api/deploy/runtime/initialize_receipt_test.py b/services/agents-api/deploy/runtime/initialize_receipt_test.py index c97641100..99e239fb2 100644 --- a/services/agents-api/deploy/runtime/initialize_receipt_test.py +++ b/services/agents-api/deploy/runtime/initialize_receipt_test.py @@ -114,6 +114,24 @@ def test_stdio_mounts_workspace_at_native_alias_and_physical_path(self): self.assertIn(['--ro-bind', '/environment/initialization', '/environment/initialization'], mounts) self.assertIn(['--chdir', '/workspace'], [args[i:i+2] for i in range(len(args)-1)]) + def test_private_initialization_uses_bound_workspace_and_empty_environment(self): + with tempfile.TemporaryDirectory() as directory: + workspace = str(Path(directory).resolve()) + stdin = mock.Mock() + stdin.buffer = io.BytesIO(b'{"version":1,"action":"configure"}') + with mock.patch.object(initialize.sys, 'argv', ['initialize', 'initialize', workspace]), \ + mock.patch.object(initialize.sys, 'stdin', stdin), \ + mock.patch.object(initialize, 'roots') as roots, \ + mock.patch.object(initialize, 'run') as run, \ + contextlib.redirect_stdout(io.StringIO()) as output: + self.assertEqual(initialize.main(), 0) + roots.assert_called_once_with(workspace) + run.assert_called_once_with({'version': 1, 'action': 'configure'}, workspace=workspace) + self.assertEqual(json.loads(output.getvalue()), {'version': 1, 'outcome': 'completed'}) + with mock.patch.object(initialize, 'configure') as configure: + initialize.run({'action': 'configure'}, workspace=workspace) + configure.assert_called_once_with({}) + def test_sandboxed_step_reports_only_its_exit_status(self): for status in (1, 3, 100, 255): failure = subprocess.CalledProcessError(status, SANDBOXED, output=CANARY.encode(), stderr=CANARY.encode()) diff --git a/services/agents-api/deploy/runtime/tool-root.py b/services/agents-api/deploy/runtime/tool-root.py index f5f5ea499..85cd60cb3 100644 --- a/services/agents-api/deploy/runtime/tool-root.py +++ b/services/agents-api/deploy/runtime/tool-root.py @@ -57,7 +57,7 @@ def initialization_sandbox(cwd, network='enabled', *, writable=False, workspace= return args -def install(packages): +def install(packages, *, workspace='/environment/workspace'): if not isinstance(packages, list) or not packages or any( not isinstance(p, str) or not p or p.startswith('-') or '\x00' in p for p in packages ): @@ -71,7 +71,7 @@ def install(packages): ROOT.mkdir(mode=0o700) subprocess.run(['/usr/bin/tar', '--no-same-owner', '--no-same-permissions', '-xzf', str(SEED), '-C', str(ROOT)], check=True, env=BASE_ENV, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - args = initialization_sandbox('/workspace', writable=True) + args = initialization_sandbox('/workspace', writable=True, workspace=workspace) # Namespace root maps only to the unprivileged Runtime UID; _apt is unmapped. apt = ['/usr/bin/apt-get', '-o', 'APT::Sandbox::User=root', '-o', 'Acquire::Retries=0'] for command in (apt + ['update'], apt + ['install', '-y', '--no-install-recommends', '--', *packages]): diff --git a/services/agents-api/internal/api/environment_request.go b/services/agents-api/internal/api/environment_request.go index 1970cb331..9a79b645f 100644 --- a/services/agents-api/internal/api/environment_request.go +++ b/services/agents-api/internal/api/environment_request.go @@ -20,7 +20,7 @@ func decodeSessionEnvironment(raw json.RawMessage) (*v1.Environment, error) { return decodeHostedEnvironment(raw) case "self_hosted": fields = append(fields, "workspace_directory", "capability_directories") - if agentcapabilities.ValidateLocalDirectories([]string{environment.WorkspaceDirectory}) != nil || agentcapabilities.ValidateLocalDirectories(environment.CapabilityDirectories) != nil { + if agentcapabilities.ValidateSourceDirectories([]string{environment.WorkspaceDirectory}) != nil || agentcapabilities.ValidateSourceDirectories(environment.CapabilityDirectories) != nil { return nil, store.ErrInvalidInput } default: diff --git a/services/agents-api/internal/api/environment_request_test.go b/services/agents-api/internal/api/environment_request_test.go index 38b70a541..330579d63 100644 --- a/services/agents-api/internal/api/environment_request_test.go +++ b/services/agents-api/internal/api/environment_request_test.go @@ -27,3 +27,13 @@ func TestSelfHostedPathsDoNotCreateManagedInitialization(t *testing.T) { } } } + +func TestSelfHostedSourcePathsArePlatformNeutral(t *testing.T) { + for _, directory := range []string{`C:\work`, `D:/skills`, `\\server\share\project`, `/Users/user/work`} { + raw, _ := json.Marshal(map[string]any{"type": "self_hosted", "workspace_directory": directory, "capability_directories": []string{directory}}) + request, err := (decodedSessionRequest{Environment: raw}).validated() + if err != nil || request.Environment.WorkspaceDirectory != directory || !request.initialization.Empty() { + t.Fatal("Core interpreted a Runtime source path", directory, err) + } + } +} diff --git a/services/agents-api/internal/execution/environment_capabilities_test.go b/services/agents-api/internal/execution/environment_capabilities_test.go index b39950ea3..722494c8e 100644 --- a/services/agents-api/internal/execution/environment_capabilities_test.go +++ b/services/agents-api/internal/execution/environment_capabilities_test.go @@ -1,6 +1,7 @@ package execution import ( + "encoding/json" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "slices" @@ -32,3 +33,16 @@ func TestSelfHostedCapabilitySourcesAreFrozenAndStrict(t *testing.T) { } } } + +func TestSelfHostedPreparedPathsArePlatformNeutral(t *testing.T) { + for _, directory := range []string{`C:\work`, `D:/skills`, `\\server\share\project`, `/Users/user/work`} { + raw, _ := json.Marshal(map[string]any{"type": "self_hosted", "workspace_directory": directory, "capability_directories": []string{directory}}) + session := store.Session{ID: "session", TenantID: "tenant"} + environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, Configuration: raw} + var request proto.PromptRequestPayload + err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &request) + if err != nil || request.LocalEnvironment.WorkspaceDirectory != directory || request.LocalEnvironment.CapabilitySources.Directories[0] != directory { + t.Fatal("Core interpreted a Runtime source path", directory, err) + } + } +} diff --git a/services/agents-api/internal/execution/environment_placement.go b/services/agents-api/internal/execution/environment_placement.go index aeb6fe3db..ddb9b619b 100644 --- a/services/agents-api/internal/execution/environment_placement.go +++ b/services/agents-api/internal/execution/environment_placement.go @@ -117,7 +117,7 @@ func (d *Dispatcher) configurePreparedEnvironment(session store.Session, environ } func validSelfHostedPlacement(placement environmentPlacement) bool { - return agentcapabilities.ValidateLocalDirectories([]string{placement.WorkspaceDirectory}) == nil && - agentcapabilities.ValidateLocalDirectories(placement.CapabilityDirectories) == nil && + return agentcapabilities.ValidateSourceDirectories([]string{placement.WorkspaceDirectory}) == nil && + agentcapabilities.ValidateSourceDirectories(placement.CapabilityDirectories) == nil && !placement.ToolEnvironment && len(placement.Skills)+len(placement.Plugins) == 0 } diff --git a/services/agents-api/internal/execution/runtime_capabilities_test.go b/services/agents-api/internal/execution/runtime_capabilities_test.go index 2959dbf06..4e6fb9030 100644 --- a/services/agents-api/internal/execution/runtime_capabilities_test.go +++ b/services/agents-api/internal/execution/runtime_capabilities_test.go @@ -4,36 +4,26 @@ import ( "bytes" "context" "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" "testing" ) -type capabilityProviderTrap struct { - sandbox.Provider - calls int -} - -func (p *capabilityProviderTrap) RunCommand(context.Context, sandbox.Reference, sandbox.Command) (sandbox.CommandResult, error) { - p.calls++ - return sandbox.CommandResult{}, nil -} - type capabilityFixture struct { - request proto.CapabilitiesPreparePayload + request proto.RuntimePreparePayload body []byte requestID string outcome string err error } -func (f *capabilityFixture) PrepareCapabilities(_ context.Context, id string, request proto.CapabilitiesPreparePayload, body []byte) (proto.CapabilitiesResultPayload, error) { +func (f *capabilityFixture) PrepareRuntime(_ context.Context, id string, request proto.RuntimePreparePayload, body []byte) (proto.RuntimePrepareResultPayload, error) { f.requestID, f.request, f.body = id, request, append([]byte(nil), body...) - return proto.CapabilitiesResultPayload{Outcome: f.outcome, ErrorCode: "safe_failure"}, f.err + return proto.RuntimePrepareResultPayload{Outcome: f.outcome, ErrorCode: "safe_failure"}, f.err } func TestRuntimeCapabilitiesPreserveRawBundlesAndSetupOrdering(t *testing.T) { archive := []byte("opaque archive bytes must be expanded only by Runtime") @@ -44,13 +34,13 @@ func TestRuntimeCapabilitiesPreserveRawBundlesAndSetupOrdering(t *testing.T) { CapabilityDirectories: []string{"/workspace/generated"}, } operations := setupOperations(setup) - if len(operations) != 5 || operations[0].Action != "configure" || operations[1].Capabilities.Action != "skill" || operations[2].Capabilities.Action != "plugin" || operations[3].Action != "setup" || operations[4].Capabilities.Action != "finalize" { + if len(operations) != 5 || operations[0].Request.Initialization.Action != "configure" || operations[1].Request.Action != "skill" || operations[2].Request.Action != "plugin" || operations[3].Request.Initialization.Action != "setup" || operations[4].Request.Action != "finalize" { t.Fatal("bundle-before-setup or directory-after-setup ordering changed", operations) } - owner := store.RuntimeAllocation{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString()} - for _, op := range []runtimeSetupOperation{operations[1], operations[2], operations[4]} { + owner := agentcapabilities.Identity{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString()} + for _, op := range operations { peer := &capabilityFixture{outcome: "completed"} - if err := runRuntimeCapabilities(t.Context(), peer, owner, op); err != nil { + if err := runRuntimeSetup(t.Context(), peer, owner, op); err != nil { t.Fatal(err) } if _, err := uuid.Parse(peer.requestID); err != nil { @@ -59,32 +49,29 @@ func TestRuntimeCapabilitiesPreserveRawBundlesAndSetupOrdering(t *testing.T) { if peer.request.EnvironmentID != owner.EnvironmentID || peer.request.SessionID != owner.SessionID { t.Fatal("allocation identity lost") } - if op.Capabilities.Action != "finalize" && !bytes.Equal(peer.body, archive) { + if (op.Request.Action == "skill" || op.Request.Action == "plugin") && !bytes.Equal(peer.body, archive) { t.Fatal("Core expanded or changed archive") } - if op.Capabilities.Action == "skill" && (peer.request.Skill.Type != "inline" || peer.request.Skill.Name != "example") { + if op.Request.Action == "skill" && (peer.request.Skill.Type != "inline" || peer.request.Skill.Name != "example") { t.Fatal("unsafe or unresolved metadata") } - if op.Capabilities.Action == "finalize" && (len(peer.body) != 0 || peer.request.Sources == nil || len(peer.request.Sources.Skills) != 1 || len(peer.request.Sources.Plugins) != 1 || peer.request.Sources.Directories[0] != "/workspace/generated") { + if op.Request.Action == "finalize" && (len(peer.body) != 0 || peer.request.Sources == nil || len(peer.request.Sources.Skills) != 1 || len(peer.request.Sources.Plugins) != 1 || peer.request.Sources.Directories[0] != "/workspace/generated") { t.Fatal("finalization selections changed") } - provider := &capabilityProviderTrap{} - if err := runRuntimeSetup(t.Context(), provider, sandbox.Reference{}, op); err == nil || provider.calls != 0 { - t.Fatal("capability operation reached provider command path") - } + } } func TestRuntimeCapabilitiesConfirmedAndUnknownFailures(t *testing.T) { for _, outcome := range []string{"failed", "rejected", "unknown", "unexpected"} { peer := &capabilityFixture{outcome: outcome} - err := runRuntimeCapabilities(t.Context(), peer, store.RuntimeAllocation{}, runtimeSetupOperation{Capabilities: &proto.CapabilitiesPreparePayload{Action: "finalize"}}) + err := runRuntimeSetup(t.Context(), peer, agentcapabilities.Identity{}, runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "finalize"}}) var confirmed *runtimeStepFailure if err == nil || errors.As(err, &confirmed) != (outcome == "failed" || outcome == "rejected") { t.Fatal(outcome, err) } } peer := &capabilityFixture{outcome: "completed", err: errors.New(setupCanary)} - err := runRuntimeCapabilities(t.Context(), peer, store.RuntimeAllocation{}, runtimeSetupOperation{Capabilities: &proto.CapabilitiesPreparePayload{}}) + err := runRuntimeSetup(t.Context(), peer, agentcapabilities.Identity{}, runtimeSetupOperation{Request: proto.RuntimePreparePayload{}}) if err == nil || bytes.Contains([]byte(err.Error()), []byte(setupCanary)) { t.Fatal("transport error leaked or succeeded", err) } diff --git a/services/agents-api/internal/execution/runtime_initialization.go b/services/agents-api/internal/execution/runtime_initialization.go index 920acd9ec..cea3198ef 100644 --- a/services/agents-api/internal/execution/runtime_initialization.go +++ b/services/agents-api/internal/execution/runtime_initialization.go @@ -2,13 +2,11 @@ package execution import ( "context" - "crypto/sha256" "encoding/json" "errors" - "strconv" - "strings" "time" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" @@ -58,15 +56,10 @@ func (r *runtimeLifecycle) observeInitialization(ctx context.Context, owner stor _, err = r.store.RequestRuntimeCleanup(ctx, owner) return err } - // A not-yet-connected Runtime is not a failed initialization attempt. - for _, operation := range operations { - if operation.Capabilities != nil { - peer, err := r.initializationPeer(ctx, owner) - if err != nil || peer == nil { - return err - } - break - } + // No initialization is claimed before the authenticated Runtime is available. + peer, err := r.initializationPeer(ctx, owner) + if err != nil || peer == nil { + return err } claimed, err := r.store.ClaimRuntimeInitialization(ctx, owner) if err != nil { @@ -108,29 +101,23 @@ func (r *runtimeLifecycle) advanceInitialization(ctx context.Context) error { r.initializing = nil return err } + peer, err := r.initializationPeer(operation, owner) + if err == nil && peer == nil { + return nil + } + identity := agentcapabilities.Identity{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID} step := store.ProvisioningFailure{Step: store.ProvisioningInitialFile} - if active.next < active.files { + if err == nil && active.next < active.files { var file store.InitialFileMetadata var body []byte file, body, err = r.store.ReadInitialEnvironmentFile(operation, owner.TenantID, owner.SessionID, active.next) if err == nil { - err = installInitialFile(operation, r.config.Provider, runtimeReference(owner), file, body) + err = installInitialFile(operation, peer, identity, file, body) } - } else { + } else if err == nil { setup := active.operations[active.next-active.files] step = setup.provisioningFailure(0) - if setup.Capabilities != nil { - var peer *gateway.Session - peer, err = r.initializationPeer(operation, owner) - if err == nil && peer == nil { - return nil - } - if err == nil { - err = runRuntimeCapabilities(operation, peer, owner, setup) - } - } else { - err = runRuntimeSetup(operation, r.config.Provider, runtimeReference(owner), setup) - } + err = runRuntimeSetup(operation, peer, identity, setup) } if err != nil { // Clearing the in-memory owner makes the next observation request cleanup, @@ -158,60 +145,8 @@ func (r *runtimeLifecycle) advanceInitialization(ctx context.Context) error { return nil } -func installInitialFile(ctx context.Context, provider sandbox.Provider, reference sandbox.Reference, file store.InitialFileMetadata, body []byte) error { - if provider == nil || file.SizeBytes == nil || *file.SizeBytes != int64(len(body)) || len(body) > store.MaxInitialFileBytes || !strings.HasPrefix(file.Path, "/workspace/") { - return sandbox.ErrInvalid - } - digest := sha256.Sum256(body) - input := make([]byte, 0, len(body)+len(digest)) - input = append(input, body...) - input = append(input, digest[:]...) - result, err := provider.RunCommand(ctx, reference, sandbox.Command{Directory: "/", Args: []string{"/usr/bin/python3", "-I", "-S", "-c", initialFileInstaller, strings.TrimPrefix(file.Path, "/workspace/"), strconv.Itoa(len(body))}, Stdin: input}) - if err != nil { - return err - } - var receipt struct { - Version int `json:"version"` - Outcome string `json:"outcome"` - SizeBytes *int64 `json:"size_bytes"` - } - valid := result.Stderr == "" && json.Unmarshal([]byte(result.Stdout), &receipt) == nil && receipt.Version == 1 - if valid && result.ExitCode == 0 && receipt.Outcome == "completed" && receipt.SizeBytes != nil && *receipt.SizeBytes == int64(len(body)) { - return nil - } - if valid && receipt.Outcome == "failed" { - // The writer exits 0 with a failed receipt when it committed nothing; - // "unknown" and every other result stay generic. - return &runtimeStepFailure{} - } - return errors.New("initial environment file installation unconfirmed") -} - -// Isolated Python creates only fd-anchored workspace parents, then replaces itself with the existing atomic writer. -const initialFileInstaller = `import os, sys -parts = sys.argv[1].split('/') -if any(not p or p in ('.', '..') or any(c in p for c in ('\\', '\x00', '\r', '\n')) for p in parts): - raise SystemExit(2) -flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC -fd = os.open('/', flags) -for component in ('environment', 'workspace'): - child = os.open(component, flags, dir_fd=fd) - os.close(fd) - fd = child -for component in parts[:-1]: - try: - os.mkdir(component, mode=0o700, dir_fd=fd) - except FileExistsError: - pass - child = os.open(component, flags, dir_fd=fd) - os.close(fd) - fd = child -os.close(fd) -os.execv('/usr/local/bin/oac-codex-write', ['oac-codex-write', '/environment/workspace', sys.argv[1], sys.argv[2], '/environment/staging']) -` - // Missing authority/socket before sending consumes no initialization operation. -// Once PrepareCapabilities is called, unknown effects use the existing cleanup. +// Once PrepareRuntime is called, unknown effects use the existing cleanup. func (r *runtimeLifecycle) initializationPeer(ctx context.Context, owner store.RuntimeAllocation) (*gateway.Session, error) { if r.registry == nil { return nil, nil diff --git a/services/agents-api/internal/execution/runtime_setup.go b/services/agents-api/internal/execution/runtime_setup.go index 34ddd06bc..4225f3280 100644 --- a/services/agents-api/internal/execution/runtime_setup.go +++ b/services/agents-api/internal/execution/runtime_setup.go @@ -2,44 +2,34 @@ package execution import ( "context" - "encoding/json" "errors" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/google/uuid" ) -// runtimeSetupOperation is the packaged initializer's confidential stdin contract. -// Public templates and native harness configuration never cross this boundary. +// runtimeSetupOperation carries one frozen, typed Runtime operation. Runtime owns +// host paths, executable selection and native installation receipts. type runtimeSetupOperation struct { - Capabilities *proto.CapabilitiesPreparePayload `json:"-"` - Archive []byte `json:"-"` - Version int `json:"version"` - Action string `json:"action"` - Network string `json:"network,omitempty"` - Env map[string]string `json:"env"` - Packages []string `json:"packages,omitempty"` - Command string `json:"command,omitempty"` - CWD string `json:"cwd,omitempty"` - // Index is the setup command position, used only for the public failure label. - Index int `json:"-"` + Request proto.RuntimePreparePayload + Data []byte + Index int } -// runtimeStepFailure is a confirmed failed initialization receipt. It holds only -// the Runtime-reported exit status (0 when absent), never command or output. type runtimeStepFailure struct{ exitCode int } func (*runtimeStepFailure) Error() string { return "environment initialization operation failed" } -// provisioningFailure labels a confirmed failed setup operation for the Store, -// which composes the public reason. Other actions keep the generic reason. func (operation runtimeSetupOperation) provisioningFailure(exitCode int) store.ProvisioningFailure { - switch operation.Action { + action := operation.Request.Action + if operation.Request.Initialization != nil { + action = operation.Request.Initialization.Action + } + switch action { case "setup", "python", "npm", "system", "skill": - return store.ProvisioningFailure{Step: operation.Action, Index: operation.Index, ExitCode: exitCode} + return store.ProvisioningFailure{Step: action, Index: operation.Index, ExitCode: exitCode} } return store.ProvisioningFailure{} } @@ -48,101 +38,68 @@ func setupOperations(setup store.EnvironmentSetup) []runtimeSetupOperation { if setup.Empty() { return nil } + initialize := func(input proto.RuntimeInitialization) runtimeSetupOperation { + return runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "initialize", Initialization: &input}} + } env := setup.Env if env == nil { env = map[string]string{} } - result := []runtimeSetupOperation{{Version: 1, Action: "configure", Env: env}} - for i := range setup.Skills { - metadata := setup.Skills[i].InstallationMetadata() - result = append(result, runtimeSetupOperation{Action: "skill", Capabilities: &proto.CapabilitiesPreparePayload{Action: "skill", Skill: &metadata}, Archive: setup.Skills[i].Archive}) + result := []runtimeSetupOperation{initialize(proto.RuntimeInitialization{Action: "configure", Env: env})} + for _, skill := range setup.Skills { + metadata := skill.InstallationMetadata() + result = append(result, runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "skill", Skill: &metadata}, Data: skill.Archive}) } for i, plugin := range setup.Plugins { - result = append(result, runtimeSetupOperation{Capabilities: &proto.CapabilitiesPreparePayload{Action: "plugin", Slot: i, Plugin: &plugin.Metadata}, Archive: plugin.Archive}) - } - // The public network policy applies after setup completes. Provisioning uses - // the isolated initializer's network; adapters enforce the runtime policy. - const network = "enabled" - if len(setup.Packages.System) > 0 { - result = append(result, runtimeSetupOperation{Version: 1, Action: "system", Network: network, Packages: setup.Packages.System}) - } - if len(setup.Packages.NPM) > 0 { - result = append(result, runtimeSetupOperation{Version: 1, Action: "npm", Network: network, Packages: setup.Packages.NPM}) - } - if len(setup.Packages.Python) > 0 { - result = append(result, runtimeSetupOperation{Version: 1, Action: "python", Network: network, Packages: setup.Packages.Python}) + result = append(result, runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "plugin", Slot: i, Plugin: &plugin.Metadata}, Data: plugin.Archive}) + } + // Provisioning network policy is distinct from the policy enforced for Turns. + for _, packages := range []struct { + action string + values []string + }{{"system", setup.Packages.System}, {"npm", setup.Packages.NPM}, {"python", setup.Packages.Python}} { + if len(packages.values) > 0 { + result = append(result, initialize(proto.RuntimeInitialization{Action: packages.action, Network: "enabled", Packages: packages.values})) + } } for i, command := range setup.Commands { - cwd := command.CWD - if cwd == "" { - cwd = "/workspace" - } - result = append(result, runtimeSetupOperation{Version: 1, Action: "setup", Network: network, Command: command.Command, CWD: cwd, Index: i}) + operation := initialize(proto.RuntimeInitialization{Action: "setup", Network: "enabled", Command: command.Command, CWD: command.CWD}) + operation.Index = i + result = append(result, operation) } if len(setup.Skills)+len(setup.Plugins)+len(setup.CapabilityDirectories) > 0 { sources := agentcapabilities.Input{Plugins: setup.PluginMetadata(), Directories: setup.CapabilityDirectories} for _, skill := range setup.Skills { sources.Skills = append(sources.Skills, skill.InstallationMetadata()) } - result = append(result, runtimeSetupOperation{Capabilities: &proto.CapabilitiesPreparePayload{Action: "finalize", Sources: &sources}}) + result = append(result, runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "finalize", Sources: &sources}}) } return result } -func runRuntimeSetup(ctx context.Context, provider sandbox.Provider, reference sandbox.Reference, operation runtimeSetupOperation) error { - if provider == nil || operation.Capabilities != nil { - return sandbox.ErrInvalid - } - var payload any = operation - args := []string{"/usr/bin/python3", "-I", "-S", "/usr/local/bin/oac-runtime-initialize"} - input, err := json.Marshal(payload) - if err != nil { - return err - } - result, err := provider.RunCommand(ctx, reference, sandbox.Command{Directory: "/", Args: args, Stdin: input}) - if err != nil { - return err - } - var receipt struct { - Version int `json:"version"` - Outcome string `json:"outcome"` - ExitCode *int `json:"exit_code"` - } - valid := result.Stderr == "" && json.Unmarshal([]byte(result.Stdout), &receipt) == nil && receipt.Version == 1 - if valid && result.ExitCode == 0 && receipt.Outcome == "completed" { - return nil - } - // The initializer confirms a failed step with status 1 and, for a sandboxed - // step, that step's exit status only. Images without exit_code stay generic. - if valid && result.ExitCode == 1 && receipt.Outcome == "failed" && operation.Capabilities == nil { - failure := &runtimeStepFailure{} - if receipt.ExitCode != nil && *receipt.ExitCode > 0 && *receipt.ExitCode < 256 { - failure.exitCode = *receipt.ExitCode - } - return failure - } - return errors.New("environment initialization operation unconfirmed") -} - -// capabilityPreparer is the authenticated Runtime operation; no provider command -// or expanded Skill files cross this boundary. -type capabilityPreparer interface { - PrepareCapabilities(context.Context, string, proto.CapabilitiesPreparePayload, []byte) (proto.CapabilitiesResultPayload, error) +type runtimePreparer interface { + PrepareRuntime(context.Context, string, proto.RuntimePreparePayload, []byte) (proto.RuntimePrepareResultPayload, error) } -func runRuntimeCapabilities(ctx context.Context, peer capabilityPreparer, owner store.RuntimeAllocation, operation runtimeSetupOperation) error { - if peer == nil || operation.Capabilities == nil { - return sandbox.ErrInvalid +func runRuntimeSetup(ctx context.Context, peer runtimePreparer, identity agentcapabilities.Identity, operation runtimeSetupOperation) error { + if peer == nil { + return errors.New("environment initialization request unavailable") } - request := *operation.Capabilities - request.EnvironmentID, request.SessionID = owner.EnvironmentID, owner.SessionID - result, err := peer.PrepareCapabilities(ctx, uuid.NewString(), request, operation.Archive) + request := operation.Request + request.EnvironmentID, request.SessionID = identity.EnvironmentID, identity.SessionID + result, err := peer.PrepareRuntime(ctx, uuid.NewString(), request, operation.Data) if err == nil && result.Outcome == "completed" { return nil } if err == nil && (result.Outcome == "rejected" || result.Outcome == "failed") { - return &runtimeStepFailure{} + return &runtimeStepFailure{exitCode: result.ExitCode} + } + return errors.New("environment initialization operation unconfirmed") +} + +func installInitialFile(ctx context.Context, peer runtimePreparer, identity agentcapabilities.Identity, file store.InitialFileMetadata, body []byte) error { + if file.SizeBytes == nil || *file.SizeBytes != int64(len(body)) || len(body) > store.MaxInitialFileBytes { + return errors.New("environment initialization request unavailable") } - // Keep transport details and Runtime-native text out of public errors. - return errors.New("environment capability preparation unconfirmed") + return runRuntimeSetup(ctx, peer, identity, runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "file", File: &proto.RuntimeInitialFile{Path: file.Path}}, Data: body}) } diff --git a/services/agents-api/internal/execution/runtime_setup_test.go b/services/agents-api/internal/execution/runtime_setup_test.go index 4fa5de2dd..9a8a44bb2 100644 --- a/services/agents-api/internal/execution/runtime_setup_test.go +++ b/services/agents-api/internal/execution/runtime_setup_test.go @@ -6,130 +6,80 @@ import ( "strings" "testing" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) const setupCanary = "CANARY-runtime-setup-9b3e" -// receiptProvider answers every initialization command with one fixed result. -type receiptProvider struct { - sandbox.Provider - result sandbox.CommandResult - err error +type receiptRuntime struct { + result proto.RuntimePrepareResultPayload + err error + request proto.RuntimePreparePayload + data []byte } -func (p receiptProvider) RunCommand(context.Context, sandbox.Reference, sandbox.Command) (sandbox.CommandResult, error) { +func (p *receiptRuntime) PrepareRuntime(_ context.Context, _ string, request proto.RuntimePreparePayload, data []byte) (proto.RuntimePrepareResultPayload, error) { + p.request, p.data = request, data return p.result, p.err } - -// Only a process status 1, empty stderr and a version-1 failed receipt confirm a -// failed step, and only an integer exit_code from 1 to 255 is taken from it. -// Other receipt fields are ignored, never read; everything else stays generic. func TestRuntimeSetupReceiptOutcomes(t *testing.T) { - setup := runtimeSetupOperation{Version: 1, Action: "setup", Network: "enabled", Command: "echo " + setupCanary, CWD: "/workspace", Index: 2} - plugin := runtimeSetupOperation{Capabilities: &proto.CapabilitiesPreparePayload{Action: "plugin"}} - unconfirmed := -1 for _, test := range []struct { - name string - operation runtimeSetupOperation - result sandbox.CommandResult - err error - exitCode int // -1 unconfirmed, 0 confirmed without a status + name, outcome string + code int + err error + confirmed bool }{ - {"completed", setup, sandbox.CommandResult{Stdout: `{"version":1,"outcome":"completed"}`}, nil, -2}, - {"exit status", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":3}`}, nil, 3}, - {"highest exit status", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":255}` + "\n"}, nil, 255}, - {"old receipt", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed"}`}, nil, 0}, - {"zero exit status", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":0}`}, nil, 0}, - {"exit status above 255", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":256}`}, nil, 0}, - {"negative exit status", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":-9}`}, nil, 0}, - {"null exit status", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":null}`}, nil, 0}, - // Lenient for older and newer images: other fields are never read. - {"ignored output field", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":3,"output":"` + setupCanary + `"}`}, nil, 3}, - {"duplicate key keeps the last", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":3,"exit_code":4}`}, nil, 4}, - {"string exit status", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":"3"}`}, nil, unconfirmed}, - {"fractional exit status", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":3.5}`}, nil, unconfirmed}, - {"stderr", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":3}`, Stderr: setupCanary}, nil, unconfirmed}, - {"process status 2", setup, sandbox.CommandResult{ExitCode: 2, Stdout: `{"version":1,"outcome":"failed","exit_code":3}`}, nil, unconfirmed}, - {"process status 0", setup, sandbox.CommandResult{Stdout: `{"version":1,"outcome":"failed","exit_code":3}`}, nil, unconfirmed}, - {"completed with status 1", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"completed"}`}, nil, unconfirmed}, - {"version 2", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":2,"outcome":"failed","exit_code":3}`}, nil, unconfirmed}, - {"raw output", setup, sandbox.CommandResult{ExitCode: 3, Stdout: setupCanary}, nil, unconfirmed}, - {"trailing output", setup, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":3}` + setupCanary}, nil, unconfirmed}, - {"provider error", setup, sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed, unconfirmed}, - {"plugin step", plugin, sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":3}`}, nil, unconfirmed}, + {"completed", "completed", 0, nil, false}, {"failed", "failed", 3, nil, true}, {"failed without status", "failed", 0, nil, true}, {"maximum status", "failed", 255, nil, true}, {"rejected", "rejected", 0, nil, true}, {"unknown", "unknown", 0, nil, false}, {"transport", "completed", 0, errors.New(setupCanary), false}, } { t.Run(test.name, func(t *testing.T) { - err := runRuntimeSetup(t.Context(), receiptProvider{result: test.result, err: test.err}, sandbox.Reference{}, test.operation) - var failed *runtimeStepFailure - switch { - case test.exitCode == -2: + peer := &receiptRuntime{result: proto.RuntimePrepareResultPayload{Outcome: test.outcome, ExitCode: test.code}, err: test.err} + err := runRuntimeSetup(t.Context(), peer, agentcapabilities.Identity{}, runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", Command: setupCanary}}}) + if test.outcome == "completed" && test.err == nil { if err != nil { t.Fatal(err) } - case test.exitCode == unconfirmed: - if err == nil || errors.As(err, &failed) { - t.Fatal("unconfirmed result became a confirmed failure", err) - } - default: - if !errors.As(err, &failed) || failed.exitCode != test.exitCode { - t.Fatal("confirmed failure", err, failed) - } + return } - if err != nil && strings.Contains(err.Error(), setupCanary) { - t.Fatal("receipt or output reached the error", err) + var failed *runtimeStepFailure + if err == nil || errors.As(err, &failed) != test.confirmed || strings.Contains(err.Error(), setupCanary) { + t.Fatal("unsafe outcome", err) + } + if failed != nil && failed.exitCode != test.code { + t.Fatal("exit status lost") } }) } } - func TestRuntimeSetupFailureLabels(t *testing.T) { - for operation, want := range map[string]store.ProvisioningFailure{ - "setup": {Step: store.ProvisioningSetupCommand, Index: 2, ExitCode: 3}, - "python": {Step: store.ProvisioningPythonPackages, Index: 2, ExitCode: 3}, - "npm": {Step: store.ProvisioningNPMPackages, Index: 2, ExitCode: 3}, - "system": {Step: store.ProvisioningSystemPackages, Index: 2, ExitCode: 3}, - "skill": {Step: store.ProvisioningSkill, Index: 2, ExitCode: 3}, - "configure": {}, - "": {}, - } { - if got := (runtimeSetupOperation{Action: operation, Index: 2}).provisioningFailure(3); got != want { - t.Errorf("%q: %+v", operation, got) + for action, want := range map[string]store.ProvisioningFailure{"setup": {Step: store.ProvisioningSetupCommand, Index: 2, ExitCode: 3}, "python": {Step: store.ProvisioningPythonPackages, Index: 2, ExitCode: 3}, "npm": {Step: store.ProvisioningNPMPackages, Index: 2, ExitCode: 3}, "system": {Step: store.ProvisioningSystemPackages, Index: 2, ExitCode: 3}, "skill": {Step: store.ProvisioningSkill, Index: 2, ExitCode: 3}, "configure": {}, "": {}} { + op := runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: action}, Index: 2} + if action != "skill" { + op.Request = proto.RuntimePreparePayload{Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: action}} + } + if got := op.provisioningFailure(3); got != want { + t.Fatal(action, got) } } - // setupOperations numbers setup commands from zero. operations := setupOperations(store.EnvironmentSetup{Commands: []store.SetupCommand{{Command: "a"}, {Command: "b"}}}) - if len(operations) != 3 || operations[1].Index != 0 || operations[2].Index != 1 || operations[2].Action != "setup" { - t.Fatal("setup command positions", operations) + if len(operations) != 3 || operations[1].Index != 0 || operations[2].Index != 1 || operations[2].Request.Initialization.CWD != "" { + t.Fatal("command index or Runtime default changed") } } - -// The file writer exits 0 with a failed receipt when it committed nothing; an -// unknown outcome, stderr or a wrong size stays unconfirmed. -func TestInitialFileReceiptOutcomes(t *testing.T) { +func TestInitialFileUsesTypedRuntimeBytes(t *testing.T) { body := []byte(setupCanary) - file := store.InitialFileMetadata{Path: "/workspace/a"} size := int64(len(body)) - file.SizeBytes = &size - for _, test := range []struct { - name string - result sandbox.CommandResult - confirmed bool - }{ - {"failed", sandbox.CommandResult{Stdout: `{"version":1,"outcome":"failed","error":"write_failed"}`}, true}, - {"unknown", sandbox.CommandResult{Stdout: `{"version":1,"outcome":"unknown","error":"write_failed"}`}, false}, - {"stderr", sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed"}`, Stderr: setupCanary}, false}, - {"wrong size", sandbox.CommandResult{Stdout: `{"version":1,"outcome":"completed","size_bytes":1}`}, false}, - {"raw output", sandbox.CommandResult{ExitCode: 2, Stdout: setupCanary}, false}, - } { - t.Run(test.name, func(t *testing.T) { - err := installInitialFile(t.Context(), receiptProvider{result: test.result}, sandbox.Reference{}, file, body) - var failed *runtimeStepFailure - if err == nil || errors.As(err, &failed) != test.confirmed || strings.Contains(err.Error(), setupCanary) { - t.Fatal(err) - } - }) + owner := agentcapabilities.Identity{EnvironmentID: "environment", SessionID: "session"} + peer := &receiptRuntime{result: proto.RuntimePrepareResultPayload{Outcome: "completed"}} + if err := installInitialFile(t.Context(), peer, owner, store.InitialFileMetadata{Path: "/workspace/a", SizeBytes: &size}, body); err != nil { + t.Fatal(err) + } + if peer.request.Action != "file" || peer.request.File.Path != "/workspace/a" || peer.request.EnvironmentID != owner.EnvironmentID || peer.request.SessionID != owner.SessionID || string(peer.data) != setupCanary { + t.Fatal("file transport changed") + } + size++ + if err := installInitialFile(t.Context(), peer, owner, store.InitialFileMetadata{SizeBytes: &size}, body); err == nil { + t.Fatal("mismatched source size accepted") } } diff --git a/services/agents-api/internal/store/hosted_initialization_failure_public_test.go b/services/agents-api/internal/store/hosted_initialization_failure_public_test.go index 7f422425e..d2566bacd 100644 --- a/services/agents-api/internal/store/hosted_initialization_failure_public_test.go +++ b/services/agents-api/internal/store/hosted_initialization_failure_public_test.go @@ -6,13 +6,11 @@ import ( "context" "encoding/json" "errors" - "fmt" "io" "log/slog" "net/http" "net/http/httptest" "reflect" - "strconv" "strings" "sync" "testing" @@ -20,6 +18,8 @@ import ( v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" @@ -29,10 +29,10 @@ import ( const hostedFailureCanary = "CANARY-hosted-init-7c21" -// leakyReceipt is a failed receipt that also carries canary output in fields -// Core must never read, as a leaking or newer Runtime could send. -func leakyReceipt(fields string) sandbox.CommandResult { - return sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed",` + fields + `"output":"` + hostedFailureCanary + `","stderr":"` + hostedFailureCanary + `"}`} +// Failed receipts carry only a finite code and a bounded status. Public +// projections must never include the confidential operation's canary text. +func failedInitialization(exitCode int) proto.RuntimePrepareResultPayload { + return proto.RuntimePrepareResultPayload{Outcome: "failed", ErrorCode: "runtime_preparation_failed", ExitCode: exitCode} } func hostedFailureSkill(t *testing.T) store.EnvironmentSkill { @@ -53,45 +53,49 @@ func hostedFailureSkill(t *testing.T) store.EnvironmentSkill { } // hostedFailureProvider fails one initialization step with a controlled result. -// Every failure it reports is produced next to canary output, which the -// initializer discards and Core must never publish. +// The confidential inputs contain canaries that Core must never publish. type hostedFailureProvider struct { lifecycleProvider + initializationPeer fail string // runtime-initialize action, or "file" for the initial file writer skip int // matching steps that succeed before the failure - result sandbox.CommandResult + result proto.RuntimePrepareResultPayload err error steps []string } -func (p *hostedFailureProvider) RunCommand(_ context.Context, _ sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { - action := "file" - if c.Args[len(c.Args)-1] == "/usr/local/bin/oac-runtime-initialize" { - var operation struct { - Action string `json:"action"` - } - if json.Unmarshal(c.Stdin, &operation) != nil || operation.Action == "" { - return sandbox.CommandResult{}, sandbox.ErrInvalid - } - action = operation.Action +func (p *hostedFailureProvider) setRuntimeGateway(t *testing.T, endpoint string, registry *gateway.Registry) { + p.initializationPeer.setRuntimeGateway(t, endpoint, registry) + p.apply = p.prepare +} +func (p *hostedFailureProvider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { + info, err := p.lifecycleProvider.Create(ctx, b) + if err == nil { + err = p.connect(b) + } + return info, err +} +func (p *hostedFailureProvider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { + return p.initializationPeer.RunCommand(ctx, r, c) +} +func (p *hostedFailureProvider) prepare(request proto.RuntimePreparePayload, _ []byte) proto.RuntimePrepareResultPayload { + action := request.Action + if request.Initialization != nil { + action = request.Initialization.Action } p.mu.Lock() + defer p.mu.Unlock() p.steps = append(p.steps, action) - p.mu.Unlock() if action == p.fail { if p.skip == 0 { - return p.result, p.err + if p.err != nil { + return proto.RuntimePrepareResultPayload{Outcome: "unknown", ErrorCode: "runtime_preparation_unconfirmed"} + } + return p.result } p.skip-- } - if action == "file" { - size, err := strconv.Atoi(c.Args[len(c.Args)-1]) - if err != nil { - return sandbox.CommandResult{}, sandbox.ErrInvalid - } - return sandbox.CommandResult{Stdout: fmt.Sprintf(`{"version":1,"outcome":"completed","size_bytes":%d}`, size)}, nil - } - return sandbox.CommandResult{Stdout: `{"version":1,"outcome":"completed"}`}, nil + return completedInitialization(request, nil) } func hostedFailureStore(t *testing.T) *store.Store { @@ -140,7 +144,7 @@ func TestHostedInitializationFailureRecordsSafeSessionFailure(t *testing.T) { type failure struct { fail string skip int - result sandbox.CommandResult + result proto.RuntimePrepareResultPayload err error } for _, test := range []struct { @@ -151,28 +155,28 @@ func TestHostedInitializationFailureRecordsSafeSessionFailure(t *testing.T) { steps []string }{ {"setup exit status", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Commands: commands[1:]}}, - failure{fail: "setup", result: leakyReceipt(`"exit_code":3,`)}, + failure{fail: "setup", result: failedInitialization(3)}, `Failed to provision environment: script "setup_commands[0]" failed with exit code 3`, []string{"configure", "setup"}}, {"later setup command", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Commands: commands}}, - failure{fail: "setup", skip: 1, result: sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":3}` + "\n"}}, + failure{fail: "setup", skip: 1, result: failedInitialization(3)}, `Failed to provision environment: script "setup_commands[1]" failed with exit code 3`, []string{"configure", "setup", "setup"}}, {"python package", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Packages: v1.EnvironmentPackages{Python: []string{"oac-nonexistent-zz"}}, Commands: commands[2:]}}, - failure{fail: "python", result: leakyReceipt(`"exit_code":1,`)}, + failure{fail: "python", result: failedInitialization(1)}, `Failed to provision environment: script "Python package installation" failed with exit code 1`, []string{"configure", "python"}}, - {"old image without exit status", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Commands: commands[1:]}}, - failure{fail: "setup", result: leakyReceipt("")}, + {"failure without exit status", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Commands: commands[1:]}}, + failure{fail: "setup", result: failedInitialization(0)}, "Failed to provision environment: initialization did not complete", []string{"configure", "setup"}}, {"unknown effect", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Commands: commands[1:]}}, failure{fail: "setup", err: sandbox.ErrCommandUnconfirmed}, "Failed to provision environment: initialization did not complete", []string{"configure", "setup"}}, - {"output instead of a receipt", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Commands: commands[1:]}}, - failure{fail: "setup", result: sandbox.CommandResult{ExitCode: 3, Stdout: hostedFailureCanary, Stderr: hostedFailureCanary}}, + {"invalid failure code", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Commands: commands[1:]}}, + failure{fail: "setup", result: proto.RuntimePrepareResultPayload{Outcome: "failed", ErrorCode: hostedFailureCanary}}, "Failed to provision environment: initialization did not complete", []string{"configure", "setup"}}, {"initial file", store.CreateSessionInput{InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/a", Data: []byte(hostedFailureCanary)}}}, - failure{fail: "file", result: sandbox.CommandResult{Stdout: `{"version":1,"outcome":"failed","error":"write_failed","detail":"` + hostedFailureCanary + `"}`}}, + failure{fail: "file", result: failedInitialization(0)}, "Failed to provision environment: initial file installation failed", []string{"file"}}, {"Skill", store.CreateSessionInput{Initialization: store.EnvironmentSetup{Skills: []store.EnvironmentSkill{hostedFailureSkill(t)}, Commands: commands[2:]}}, - failure{fail: "skill", result: leakyReceipt("")}, + failure{fail: "skill", result: failedInitialization(0)}, "Failed to provision environment: Skill installation failed", []string{"configure", "skill"}}, } { t.Run(test.name, func(t *testing.T) { @@ -182,7 +186,7 @@ func TestHostedInitializationFailureRecordsSafeSessionFailure(t *testing.T) { p := &hostedFailureProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, fail: test.p.fail, skip: test.p.skip, result: test.p.result, err: test.p.err} failHostedInitialization(t, s, tenant, environment, p) - if !reflect.DeepEqual(p.steps, test.steps) || p.kills != 1 { + if !reflect.DeepEqual(p.steps, test.steps) || p.kills != 1 || p.commandCalls.Load() != 0 { t.Fatal("failed initialization continued or was not reclaimed", p.steps, p.kills) } @@ -243,7 +247,7 @@ func TestHostedInitializationFailureSettlesPendingInitialInput(t *testing.T) { InitialInputs: []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"initial"}`)}}, }) p := &hostedFailureProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, fail: "setup", - result: sandbox.CommandResult{ExitCode: 1, Stdout: `{"version":1,"outcome":"failed","exit_code":3}`}} + result: failedInitialization(3)} failHostedInitialization(t, s, tenant, environment, p) read, err := s.GetSession(t.Context(), tenant, session.ID) if err != nil || read.PendingInput || read.EnvironmentInputActivity == nil || read.EnvironmentInputActivity.Status != "failed" || @@ -277,8 +281,8 @@ func TestHostedInitializationFailurePublicHTTP(t *testing.T) { Metadata: map[string]string{"case": "setup-exit3"}, }) key := uuid.NewString() - // The failed receipt carries canary output in fields Core must never read. - p := &hostedFailureProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, fail: "setup", result: leakyReceipt(`"exit_code":3,`)} + // A failed typed Runtime receipt exposes only a safe status. + p := &hostedFailureProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, fail: "setup", result: failedInitialization(3)} logs := &lockedBuffer{} previous := slog.Default() slog.SetDefault(slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))) diff --git a/services/agents-api/internal/store/runtime_capabilities_pending_test.go b/services/agents-api/internal/store/runtime_capabilities_pending_test.go index 6571c94e0..6c7265fbd 100644 --- a/services/agents-api/internal/store/runtime_capabilities_pending_test.go +++ b/services/agents-api/internal/store/runtime_capabilities_pending_test.go @@ -32,7 +32,7 @@ func TestManagedCapabilitiesWaitBeforeInitializationClaim(t *testing.T) { if err != nil { t.Fatal(err) } - provider := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}} + provider := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, initializationPeer: initializationPeer{deferred: true}} key := uuid.NewString() worker, _ := managedWorker(t, s, key, provider) owner, err := worker.ProvisionEnvironment(t.Context(), tenant, env.ID, key) @@ -45,8 +45,8 @@ func TestManagedCapabilitiesWaitBeforeInitializationClaim(t *testing.T) { } } owner, err = s.GetRuntimeAllocation(t.Context(), tenant, env.ID) - if err != nil || owner.Initialization != "pending" || owner.State != "running" || provider.writes != 0 || provider.kills != 0 { - t.Fatal("missing socket consumed initialization or requested cleanup", owner, err, provider.writes, provider.kills) + if err != nil || owner.Initialization != "pending" || owner.State != "running" || provider.writes.Load() != 0 || provider.kills != 0 { + t.Fatal("missing socket consumed initialization or requested cleanup", owner, err, provider.writes.Load(), provider.kills) } if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); !errors.Is(err, store.ErrNotFound) { t.Fatal("ordinary readiness gate bypassed", err) diff --git a/services/agents-api/internal/store/runtime_initialization_peer_test.go b/services/agents-api/internal/store/runtime_initialization_peer_test.go new file mode 100644 index 000000000..786f6f0c8 --- /dev/null +++ b/services/agents-api/internal/store/runtime_initialization_peer_test.go @@ -0,0 +1,109 @@ +package store_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/gorilla/websocket" +) + +// initializationPeer exercises the real authenticated gateway and chunk receipts. +// The provider fixture bootstraps its socket; all initialization runs on that peer. +type initializationPeer struct { + t *testing.T + endpoint string + registry *gateway.Registry + apply func(proto.RuntimePreparePayload, []byte) proto.RuntimePrepareResultPayload + writes atomic.Int32 + commandCalls atomic.Int32 + deferred bool + bootstrap sandbox.Bootstrap +} + +func (p *initializationPeer) setRuntimeGateway(t *testing.T, endpoint string, registry *gateway.Registry) { + p.t, p.endpoint, p.registry = t, endpoint, registry +} +func (p *initializationPeer) connect(b sandbox.Bootstrap) error { + p.bootstrap = b + if p.deferred { + return nil + } + c, _, err := websocket.DefaultDialer.Dial(p.endpoint+"?device_id="+b.DeviceID+"&version="+proto.Version, http.Header{"Authorization": {"Bearer " + b.Credential}}) + if err != nil { + return err + } + p.t.Cleanup(func() { _ = c.Close() }) + go func() { + var request proto.RuntimePreparePayload + var data []byte + for { + var env proto.Envelope + if c.ReadJSON(&env) != nil { + return + } + if env.Type != proto.TypeRuntimePrepare { + continue + } + var frame proto.RuntimePreparePayload + if env.DecodePayload(&frame) != nil || !proto.ValidRuntimePrepareRequest(frame) { + p.t.Error("invalid Runtime frame") + return + } + result := proto.RuntimePrepareResultPayload{} + switch frame.Step { + case "begin": + request = frame + data = nil + result.Outcome = "ready" + case "chunk": + if frame.Offset != len(data) { + p.t.Error("unordered initialization bytes") + return + } + data = append(data, frame.Data...) + result.Outcome = "received" + result.Offset = len(data) + case "commit": + if request.Action == "file" || request.Action == "skill" || request.Action == "plugin" { + sum := sha256.Sum256(data) + if request.SizeBytes != len(data) || request.SHA256 != hex.EncodeToString(sum[:]) { + p.t.Error("initialization digest changed") + return + } + } + result = p.apply(request, data) + p.writes.Add(1) + if result.Outcome == "completed" { + result.SizeBytes = len(data) + } + } + reply, err := proto.NewEnvelope(proto.TypeRuntimePrepareResult, env.ID, result) + if err != nil || c.WriteJSON(reply) != nil { + return + } + } + }() + end := time.Now().Add(time.Second) + for time.Now().Before(end) { + if _, err := p.registry.LookupDevice(b.DeviceID); err == nil { + return nil + } + time.Sleep(time.Millisecond) + } + return context.DeadlineExceeded +} +func (p *initializationPeer) RunCommand(context.Context, sandbox.Reference, sandbox.Command) (sandbox.CommandResult, error) { + p.commandCalls.Add(1) + return sandbox.CommandResult{}, sandbox.ErrInvalid +} +func completedInitialization(proto.RuntimePreparePayload, []byte) proto.RuntimePrepareResultPayload { + return proto.RuntimePrepareResultPayload{Outcome: "completed"} +} diff --git a/services/agents-api/internal/store/runtime_initialization_test.go b/services/agents-api/internal/store/runtime_initialization_test.go index 52a41f8a1..d58d36d33 100644 --- a/services/agents-api/internal/store/runtime_initialization_test.go +++ b/services/agents-api/internal/store/runtime_initialization_test.go @@ -1,17 +1,18 @@ package store_test import ( + "archive/zip" "bytes" "context" - "crypto/sha256" "encoding/json" "errors" - "fmt" - "strconv" + "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" + "reflect" "strings" "testing" v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" @@ -20,38 +21,18 @@ import ( type initializingProvider struct { lifecycleProvider - writes int - fail bool - check func() + initializationPeer } -func (p *initializingProvider) RunCommand(_ context.Context, _ sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { - p.writes++ - if p.check != nil { - p.check() +func (p *initializingProvider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { + info, err := p.lifecycleProvider.Create(ctx, b) + if err == nil { + err = p.connect(b) } - if p.fail { - return sandbox.CommandResult{}, sandbox.ErrCommandUnconfirmed - } - if c.Args[len(c.Args)-1] == "/usr/local/bin/oac-runtime-initialize" { - var operation struct { - Version int `json:"version"` - Action string `json:"action"` - } - if json.Unmarshal(c.Stdin, &operation) != nil || operation.Version != 1 || operation.Action == "" { - return sandbox.CommandResult{}, sandbox.ErrInvalid - } - return sandbox.CommandResult{Stdout: `{"version":1,"outcome":"completed"}`}, nil - } - size, err := strconv.Atoi(c.Args[len(c.Args)-1]) - if err != nil || len(c.Stdin) != size+32 { - return sandbox.CommandResult{}, sandbox.ErrInvalid - } - digest := sha256.Sum256(c.Stdin[:size]) - if !bytes.Equal(digest[:], c.Stdin[size:]) { - return sandbox.CommandResult{}, sandbox.ErrInvalid - } - return sandbox.CommandResult{Stdout: fmt.Sprintf(`{"version":1,"outcome":"completed","size_bytes":%d}`, size)}, nil + return info, err +} +func (p *initializingProvider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { + return p.initializationPeer.RunCommand(ctx, r, c) } func TestManagedInitialFilesGateFairnessCompletionAndRestart(t *testing.T) { @@ -81,7 +62,7 @@ func TestManagedInitialFilesGateFairnessCompletionAndRestart(t *testing.T) { if err != nil { t.Fatal(err) } - p := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, fail: mode == "uncertain"} + p := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, initializationPeer: initializationPeer{deferred: true}} key := uuid.NewString() w, stop := managedWorker(t, s, key, p) owner, err := w.ProvisionEnvironment(t.Context(), tenant, env.ID, key) @@ -92,8 +73,11 @@ func TestManagedInitialFilesGateFairnessCompletionAndRestart(t *testing.T) { if err != nil || !ok || credential.ID != owner.DeviceID { t.Fatal("pending initialization blocks daemon authentication") } + targetCredential := p.credential lastStepGets := 0 - p.check = func() { + p.apply = func(_ proto.RuntimePreparePayload, _ []byte) proto.RuntimePrepareResultPayload { + p.mu.Lock() + defer p.mu.Unlock() if p.gets-lastStepGets < 33 { t.Fatal("initialization advanced before a full allocation scan", p.gets-lastStepGets) } @@ -104,6 +88,10 @@ func TestManagedInitialFilesGateFairnessCompletionAndRestart(t *testing.T) { if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); !errors.Is(err, store.ErrNotFound) { t.Fatal("premature native preparation", err) } + if mode == "uncertain" { + return proto.RuntimePrepareResultPayload{Outcome: "unknown", ErrorCode: "runtime_preparation_unconfirmed"} + } + return completedInitialization(proto.RuntimePreparePayload{}, nil) } // A full page of other allocations is serviced between initialization steps. for range 32 { @@ -112,13 +100,27 @@ func TestManagedInitialFilesGateFairnessCompletionAndRestart(t *testing.T) { t.Fatal(err) } } - for n := 0; p.writes == 0 && n < 100; n++ { + // A missing socket must leave every kind of initialization unclaimed. + for range 3 { if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { t.Fatal(err) } } - if p.writes != 1 { - t.Fatal("initialization did not perform one bounded file step", p.writes) + pending, err := s.GetRuntimeAllocation(t.Context(), tenant, env.ID) + if err != nil || pending.Initialization != "pending" || p.writes.Load() != 0 { + t.Fatal("missing peer claimed initialization", pending, err) + } + p.deferred = false + if err := p.connect(sandbox.Bootstrap{DeviceID: owner.DeviceID, Credential: targetCredential}); err != nil { + t.Fatal(err) + } + for n := 0; int(p.writes.Load()) == 0 && n < 100; n++ { + if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { + t.Fatal(err) + } + } + if int(p.writes.Load()) != 1 { + t.Fatal("initialization did not perform one bounded file step", int(p.writes.Load())) } afterFirst := p.gets if mode == "restart" { @@ -126,14 +128,14 @@ func TestManagedInitialFilesGateFairnessCompletionAndRestart(t *testing.T) { w, _ = managedWorker(t, s, key, p) } if mode == "complete" { - for n := 0; p.writes < expectedSteps && n < 100; n++ { + for n := 0; int(p.writes.Load()) < expectedSteps && n < 100; n++ { if err := w.ReconcileManagedRuntimes(t.Context()); err != nil { t.Fatal(err) } } got, err := s.GetRuntimeAllocation(t.Context(), tenant, env.ID) - if err != nil || got.Initialization != "complete" || p.writes != expectedSteps || p.gets <= afterFirst { - t.Fatal("completion or maintenance", got, err, p.writes) + if err != nil || got.Initialization != "complete" || int(p.writes.Load()) != expectedSteps || p.gets <= afterFirst { + t.Fatal("completion or maintenance", got, err, int(p.writes.Load())) } if _, err := s.GetSessionExecutionBinding(t.Context(), tenant, session.ID); err != nil { t.Fatal("ready execution still blocked", err) @@ -145,19 +147,87 @@ func TestManagedInitialFilesGateFairnessCompletionAndRestart(t *testing.T) { t.Fatal(err) } } - if p.writes != expectedSteps { + if int(p.writes.Load()) != expectedSteps { t.Fatal("completed initialization replayed") } } else { reconcileManagedState(t, w, s, tenant, env.ID, "released") - if p.writes != 1 || p.kills != 1 { - t.Fatal("uncertain initialization replayed or released twice", p.writes, p.kills) + if int(p.writes.Load()) != 1 || p.kills != 1 { + t.Fatal("uncertain initialization replayed or released twice", int(p.writes.Load()), p.kills) } failed, err := s.GetEnvironment(t.Context(), tenant, env.ID) if err != nil || failed.Status != "failed" { t.Fatal("failed initialization exposed", failed, err) } } + if p.commandCalls.Load() != 0 { + t.Fatal("initialization invoked Provider.RunCommand") + } }) } } + +func TestManagedRuntimePreparationAllOperationsUsePeer(t *testing.T) { + s := hostedFailureStore(t) + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + for path, body := range map[string]string{"proof/.codex-plugin/plugin.json": `{"name":"plugin","description":"A plugin.","skills":"./skills"}`, "proof/skills/example/SKILL.md": "---\nname: plugin-proof\ndescription: A plugin Skill.\n---\nProof."} { + file, err := writer.Create(path) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + tenant := uuid.NewString() + fileBody := bytes.Repeat([]byte("bounded bytes"), 12000) + session, environment := hostedFailureSession(t, s, tenant, store.CreateSessionInput{ + InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/first", Data: fileBody}}, + Initialization: store.EnvironmentSetup{Skills: []store.EnvironmentSkill{hostedFailureSkill(t)}, Plugins: []store.EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin", Description: "A plugin."}, Archive: archive.Bytes()}}, Packages: v1.EnvironmentPackages{System: []string{"jq"}, NPM: []string{"is-number@7.0.0"}, Python: []string{"packaging==24.2"}}, Commands: []store.SetupCommand{{Command: "read installed bundles and create directory"}}, CapabilityDirectories: []string{"/workspace/generated"}}, + }) + provider := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}} + var actions []string + provider.apply = func(request proto.RuntimePreparePayload, data []byte) proto.RuntimePrepareResultPayload { + if request.SessionID != session.ID || request.EnvironmentID != environment.ID { + t.Error("Runtime identity changed") + } + action := request.Action + if request.Initialization != nil { + action = request.Initialization.Action + } + if action == "file" && !bytes.Equal(data, fileBody) { + t.Error("initial bytes changed") + } + actions = append(actions, action) + return completedInitialization(request, data) + } + key := uuid.NewString() + worker, _ := managedWorker(t, s, key, provider) + if _, err := worker.ProvisionEnvironment(t.Context(), tenant, environment.ID, key); err != nil { + t.Fatal(err) + } + for range 30 { + if err := worker.ReconcileManagedRuntimes(t.Context()); err != nil { + t.Fatal(err) + } + allocation, err := s.GetRuntimeAllocation(t.Context(), tenant, environment.ID) + if err != nil { + t.Fatal(err) + } + if allocation.Initialization == "complete" { + break + } + } + expected := []string{"file", "configure", "skill", "plugin", "system", "npm", "python", "setup", "finalize"} + if !reflect.DeepEqual(actions, expected) || provider.commandCalls.Load() != 0 { + t.Fatal("typed ordering or provider isolation", actions, provider.commandCalls.Load()) + } + allocation, err := s.GetRuntimeAllocation(t.Context(), tenant, environment.ID) + if err != nil || allocation.Initialization != "complete" { + t.Fatal("initialization incomplete", allocation, err) + } +} diff --git a/services/agents-api/internal/store/runtime_lifecycle_test.go b/services/agents-api/internal/store/runtime_lifecycle_test.go index 58da0521d..37d00da64 100644 --- a/services/agents-api/internal/store/runtime_lifecycle_test.go +++ b/services/agents-api/internal/store/runtime_lifecycle_test.go @@ -4,6 +4,9 @@ import ( "context" "encoding/json" "errors" + "net/http" + "net/http/httptest" + "strings" "sync" "testing" @@ -75,7 +78,16 @@ func managedWorker(t *testing.T, s *store.Store, key string, p sandbox.Provider) func managedWorkerMode(t *testing.T, s *store.Store, key string, p sandbox.Provider, maintenance bool) (*execution.Worker, func()) { t.Helper() - w, err := execution.StartWorker(t.Context(), &execution.Dispatcher{Store: s, Registry: gateway.NewRegistry(), ManagedRuntimes: &execution.RuntimeProvider{CoreURL: "http://core.invalid/api/v1", InstallationID: key, BackendFingerprint: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Provider: p, AdmissionPaused: maintenance}}) + registry := gateway.NewRegistry() + if peer, ok := p.(interface { + setRuntimeGateway(*testing.T, string, *gateway.Registry) + }); ok { + handler := gateway.NewHandler(gateway.HandlerConfig{Authenticator: gateway.NewAuthenticator(s), Registry: registry}) + server := httptest.NewServer(http.HandlerFunc(handler.WS)) + t.Cleanup(server.Close) + peer.setRuntimeGateway(t, "ws"+strings.TrimPrefix(server.URL, "http"), registry) + } + w, err := execution.StartWorker(t.Context(), &execution.Dispatcher{Store: s, Registry: registry, ManagedRuntimes: &execution.RuntimeProvider{CoreURL: "http://core.invalid/api/v1", InstallationID: key, BackendFingerprint: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Provider: p, AdmissionPaused: maintenance}}) if err != nil { t.Fatal(err) } From a7d39346ececc7614902d2657732599c7b8be716 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 16:39:34 +0000 Subject: [PATCH 25/78] Regenerate guides for the common Runtime preparation contract --- apps/docs/content/docs/admin-api.mdx | 24 +++---- apps/docs/content/docs/agents-and-tools.mdx | 40 ++++++------ .../content/docs/bootstrap-projects-keys.mdx | 4 +- apps/docs/content/docs/concepts.mdx | 6 +- apps/docs/content/docs/configure.mdx | 16 ++--- apps/docs/content/docs/console.mdx | 18 +++--- .../content/docs/environments-and-files.mdx | 50 +++++++++------ apps/docs/content/docs/execution-model.mdx | 6 +- apps/docs/content/docs/hosted-providers.mdx | 8 +-- apps/docs/content/docs/index.mdx | 8 +-- apps/docs/content/docs/install.mdx | 6 +- apps/docs/content/docs/observability.mdx | 10 +-- apps/docs/content/docs/public-api.mdx | 64 +++++++++---------- apps/docs/content/docs/quickstart.mdx | 8 +-- .../content/docs/self-hosted-execution.mdx | 9 ++- apps/docs/content/docs/sessions.mdx | 26 ++++---- apps/docs/content/docs/troubleshooting.mdx | 8 +-- apps/docs/content/guide-sources.json | 40 ++++++------ apps/docs/scripts/generate-guides.mjs | 2 +- 19 files changed, 183 insertions(+), 170 deletions(-) diff --git a/apps/docs/content/docs/admin-api.mdx b/apps/docs/content/docs/admin-api.mdx index efd8aeff3..5fa1b29f5 100644 --- a/apps/docs/content/docs/admin-api.mdx +++ b/apps/docs/content/docs/admin-api.mdx @@ -8,7 +8,7 @@ resource inspection, Project and key management, credential issuance, audit, usa and sandbox operations. It never calls `/v1` or `/api/v1`, and has no Agent execution, copy or arbitrary asset editing operation. -Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/core-errors.md), +Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/core-errors.md), including optional typed safe details. Console sign-in endpoints retain their separate error shape described below. @@ -49,9 +49,9 @@ label, but could set any value. Never use it for authorization or as proof of origin. Web calls Core only through the typed Core clients in `packages/agents-client`: -[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/packages/agents-client/src/admin-client.ts) (`/core/v1`), +[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/packages/agents-client/src/admin-client.ts) (`/core/v1`), `SandboxAdminClient` (`/core/v1/sandbox`) and `CoreMetricsClient` -(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md) +(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md) for methods, fields, filters, pagination and response shapes. Routes below are relative to `/core/v1`: @@ -62,8 +62,8 @@ Routes below are relative to `/core/v1`: | Resource lists/details/deletion | `/projects/{id}/agents`, `/sessions`, `/environment-templates`, `/skills`, `/files`, `/vaults`, including the documented nested reads | | Asset ownership | `GET /projects/{id}/resource-owners` with batched resource IDs | | Key operation history | `GET /projects/{id}/write-operations` with key/resource/time filters | -| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-executor-credentials.md)) | -| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/model-execution.md#deployment-defaults)) | +| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-executor-credentials.md)) | +| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/model-execution.md#deployment-defaults)) | | Usage and health | `GET /summary`, `/sandbox/runtime-observations`, `/metrics` | | Administrator audit | `GET /audit-log`; deployment-wide entries have `project_id: null` | @@ -76,9 +76,9 @@ it is operational attribution, not per-key billing. ## Sandbox administration -The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/sandbox-deployment.md), -[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md) -and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/core.openapi.yaml) +The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/sandbox-deployment.md), +[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/core.openapi.yaml) define deployment and node operations: - `GET/POST/PUT /core/v1/sandbox/deployment` and @@ -115,7 +115,7 @@ is a durable serving pin, not proof of current connectivity. Target unknown, fai or update-required state does not by itself invalidate confirmed old-generation service; consume Core's connection/provider facts separately. -Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md#administrative-session-archive) +Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md#administrative-session-archive) at the current generation without reset. History and persisted Files/Artifacts survive; unpersisted workspace is lost and the original Session cannot resume. Cancel reset stops further archives, not cleanup already requested. After zero @@ -156,7 +156,7 @@ generated schema; do not interpret every empty or failed read as an absent resou `GET /core/v1/metrics?range=1h|6h|24h|7d` returns Core process, execution queue/slots, PostgreSQL and background-job measurements. It requires the Core key, rejects arbitrary query filters and never grants -Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/core-metrics.md) +Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/core-metrics.md) for complete buckets, null values, units and process-local retention. Frontend implementation is maintained separately; this backend change does not modify Agent metrics or the public Agent API. @@ -165,7 +165,7 @@ Agent metrics or the public Agent API. The Core key reads a node and its host history through `GET /core/v1/sandbox/nodes/{node_id}?range=1h|6h|24h`. See the -[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/node-host-history.md) +[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/node-host-history.md) for nullable observations, freshness and aggregation. The node list is unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/api/web-management.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/api/web-management.md) diff --git a/apps/docs/content/docs/agents-and-tools.mdx b/apps/docs/content/docs/agents-and-tools.mdx index fc82c996d..be015715a 100644 --- a/apps/docs/content/docs/agents-and-tools.mdx +++ b/apps/docs/content/docs/agents-and-tools.mdx @@ -4,7 +4,7 @@ description: "Native harness tools, shared declarations and the limits of execut --- Assessed against Core `206474a4c10901442b1faa094281bfb5559082b5` on 2026-09-22. -The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/upstream.json), Beta +The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/upstream.json), Beta `agents=v1`. This matrix records the bounded execution/tools milestone. It does not establish complete protocol compatibility; the remaining limits below stay open. @@ -22,20 +22,20 @@ and Environment Plugin MCP have separate inventories and qualification. | Operation | Implemented behavior and real qualification | Unsupported or unverified boundary | | --- | --- | --- | -| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | -| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | -| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | -| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | -| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | -| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | +| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | +| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | +| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | +| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | +| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | +| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | | Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. MiniMax functions and self-hosted image workflows are unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | -| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | -| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | -| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | -| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | -| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | -| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | -| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | +| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | +| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | +| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | +| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | +| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | +| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | +| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | ## Required-action recovery contract @@ -52,7 +52,7 @@ application already performed the function, keep and submit that saved result; do not run an external effect again merely because an action remains visible. Core's acknowledgement boundary is native application. For an environment action, connect its exact Environment using the existing enrollment authorization. -[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/store/environment_connections.go) +[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/store/environment_connections.go) fence stale generations; transport connection can clear pre-Turn activity before native preparation. Neither registration nor an action disappearing proves that a model ran successfully. Query the matching Turn and Items for its outcome. @@ -61,8 +61,8 @@ Two timing questions remain open. Repeated `requires_action` notification order and acknowledgement timing are local implementation choices, not proven upstream semantics. Also, an admitted result that is cancelled before native observation can remain internally saved without a public output Item or `item.added`. -[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/internal/store/item_projection.go) and -[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md#public-function-configuration) preserve +[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/store/item_projection.go) and +[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md#public-function-configuration) preserve this gap. Successful retry cannot manufacture the missing observation or establish that the model consumed the result. @@ -82,12 +82,12 @@ not a claim that every historical workflow was rerun at the current baseline. | M2 | `20260922/workspace-images/validation-summary.json`; Codex `public-run-_lr5uiy_`, Claude `public-run-sb65p9e9`; candidate `2c295116d66ed9aafc808ec49a8cb6dcb5c674c2`, merge `1adb2489bc3415039440224e9a7905c53e68a557` ([#17](https://github.com/MiniMax-AI/parsar-core/pull/17)). Kimi K3, Codex 0.153.4, Claude SDK 0.3.269/native 2.1.269; Docker seven-Turn image/workspace workflow. Codex's receipt conclusion is superseded by F2. | | F1 | `20260922/function-result-images/validation-summary.json`, `validated/function-image-public-2567456666/public.json`; candidate `d3cdb225bc7628b968b76c7e1b400101894ce8cd`, merge `fd23f169e1ede1b2f1c39a1d9dcce71886e3c006` ([#16](https://github.com/MiniMax-AI/parsar-core/pull/16)). Claude SDK 0.3.269/native 2.1.269 with Kimi K3 on `none`; success PNG/JPEG, failed text, retries, cancellation, history continuation. Earlier Codex transport-only evidence does not qualify current receipts. | | F2 | `20260922/function-result-receipts/validation-summary.json`, `candidate-public-owner-sdk.log`; hosted `public-run-4fn70foy` (104.13s), `none` `function-image-public-945075091` (83.918s). Candidate `6dceb98e1c56469fcc70cd82025ce77fc870ed3d`, merge `206474a4c10901442b1faa094281bfb5559082b5` ([#20](https://github.com/MiniMax-AI/parsar-core/pull/20)); real Kimi, Codex 0.153.4. Qualifies native receipts, not completed provider consumption. | -| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | +| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | | S1 | `20260922/structured-output/acceptance-summary.json`, `structured-public-1905281777/public.json`; real candidate `7f533d46c472f3cdf7c16ce9471c225a2ba7eded`, merge `8716e699dbc34904499c94b6b0b03857bbebfaad` ([#11](https://github.com/MiniMax-AI/parsar-core/pull/11)). Claude/Kimi `none` schema/function execution and cold continuation. Schema-specific active steering was not separately qualified here. | | S2 | `20260922/hosted-structured-output/validation-summary.json`, `validated/public-inline.log`, `public-run-qvq3csf1` (186.09s); candidate `f6d2c3f2a1dc2ff5f177213bd2b20d78496011d0`, merge `aa85d8ecc60e0a8b7f2494b73caa81216ee197e2` ([#18](https://github.com/MiniMax-AI/parsar-core/pull/18)). Claude SDK 0.3.269/native 2.1.269, real Kimi; Docker saved/inline schema, prepared/active input, native files and four completed structured answers. | | D1 | `20260922/deferred-tools/tool-search-public-2039155387/public.json`, `public-image-isolated-tests.log` (86.71s); merge `178507ae7a63d4068e1e82bef9cd256ba398ae00` ([#13](https://github.com/MiniMax-AI/parsar-core/pull/13)). Claude SDK 0.3.269/native 2.1.269/Kimi K3 `none`; text results with initial/active PNGs, cancellation and cold continuation. `claude-native-1790052750` separately records provider schema inventories and native feasibility. | | P1 | `20260922/tool-policy/acceptance.json`, `server-evidence/tool-policy-{codex-2495445746,claude_sdk-2539346823,mcode-3747575401}/public.json`; candidate `eeb432c7495265ae3a9309e32f5b4323611c3245`, merge `4b8754a6eda6696d9b18eb8d583953ac16c6800f` ([#14](https://github.com/MiniMax-AI/parsar-core/pull/14)). Codex/Claude Kimi K3, MiniMax M2.7; four `none` Sessions per harness, native disable controls and cold continuation. | -| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | +| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | | E2 | `20260922/execution-tools-closure/environment-actions/result.json` contains Codex/Claude Kimi passes (13 checks each) and the retained MiniMax direct-network failure; `mcode-relay-retry/result.json` separately passes MiniMax M2.7 (13 checks) after correcting provider routing. Source `206474a`; same shared SDK/raw workflow, isolated Core/PostgreSQL and user-managed Docker. Covers initial creation SSE, client disconnect, exact pending Environment/no Turn or Items, scoped enrollment, one actual completed model Turn and creation retries preserving history. This is connection-action qualification, not another full lifecycle or E2B regression. | Controlled tests establish parser, projection, atomicity and race behavior; native @@ -121,4 +121,4 @@ remain separate limitations. They do not authorize silently relabeling an unprov principal workflow as deferred. OAuth, full Usage, release publication, new tool kinds and new execution loops are outside this closure batch. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/execution-tools.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/execution-tools.md) diff --git a/apps/docs/content/docs/bootstrap-projects-keys.mdx b/apps/docs/content/docs/bootstrap-projects-keys.mdx index 5060f4d64..2882ed2d3 100644 --- a/apps/docs/content/docs/bootstrap-projects-keys.mdx +++ b/apps/docs/content/docs/bootstrap-projects-keys.mdx @@ -69,7 +69,7 @@ and display only. Keep deployment, application, node and provider credentials ou Installation creates no Project or application key. An administrator creates a Project and issues named keys on the console's **Projects and keys** page, which -uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md), or with that +uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md), or with that API directly. The Project owns one tenant and one principal; its keys share assets and permissions. Writes retain each key's provenance. All Projects and application keys live in @@ -98,4 +98,4 @@ management request points to the console's Core key or Core connection. A resour deletion conflict must remain visible; it does not authorize an execution call. Node and daemon `/api/v1` routes and native Runtime interfaces retain their own authentication. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/web/core-connection.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/web/core-connection.md) diff --git a/apps/docs/content/docs/concepts.mdx b/apps/docs/content/docs/concepts.mdx index 209c77792..f453d3514 100644 --- a/apps/docs/content/docs/concepts.mdx +++ b/apps/docs/content/docs/concepts.mdx @@ -83,12 +83,12 @@ ownership. No request bodies, secrets or file contents enter audit records. Do not add product users, RBAC, cross-Project shared assets, administrator execution, or old private-protocol compatibility to this management model. Existing public -execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/CONTRIBUTING.md). +execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/CONTRIBUTING.md). Native failure classification is adapter-owned and uses finite structured native values. Optional Runtime error metadata is normalized once and retained through normal delivery and journal draining; it never replaces Core terminal authority, cancellation receipts, Usage or native identity. See -[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/native-error-classification.md). +[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/native-error-classification.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/design-principles.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/design-principles.md) diff --git a/apps/docs/content/docs/configure.mdx b/apps/docs/content/docs/configure.mdx index e5a1fa0ad..143bd6f62 100644 --- a/apps/docs/content/docs/configure.mdx +++ b/apps/docs/content/docs/configure.mdx @@ -116,7 +116,7 @@ output or in Core's settings snapshot. Model providers are not process settings; The schema is -[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/deploy/install/config.schema.json); each +[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/deploy/install/config.schema.json); each installation keeps a copy in `generated/config.schema.json` for editors. Core serves the non-secret settings snapshot, with the path of `config.json` and the apply command, at `GET /core/v1/installation`. @@ -145,7 +145,7 @@ Which harnesses are enabled, and the default one, are process settings Exactly one provider serves the deployment: Docker, microsandbox or E2B. The deployment specification holds the per-sandbox resources and, for Docker and microsandbox, the Runtime release. Web's setup proposes the Standard size from -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/apps/web/src/features/sandbox/standard-sizes.json) and +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/apps/web/src/features/sandbox/standard-sizes.json) and derives Small and Large from it; the installer applies the bundle's copy of the same file. The database owns the saved values. @@ -183,8 +183,8 @@ Current Runtime generation coexistence and ownership-scoped garbage collection remain supported. They do not upgrade the installed node program or convert an old installation. History stays, and existing Sessions never move between providers. See the [nodes guide](/hosted-providers#change-the-sandbox-backend-or-size), -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) -and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/sandbox-deployment.md). +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/sandbox-deployment.md). ### Node capacity @@ -211,7 +211,7 @@ Session freezes the provider it starts with, so a later change affects only new Sessions. Self-hosted Sessions never use it. A hosted or self-hosted Session with no provider is refused with 400 `model_provider_required`. See [model providers](/quickstart#model-providers) and the -[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/model-execution.md#deployment-defaults). +[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/model-execution.md#deployment-defaults). The operator file `AGENTS_API_EXECUTION_OPTIONS_FILE` is retired; see [installation version policy](/troubleshooting#installation-version-policy). @@ -231,7 +231,7 @@ A Web-only installation has only `secrets/core.key`, a copy of its Core's key. Core reads only its environment. The installer renders `generated/core.env` from `config.json`; if you run Core yourself (see -[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/maintainers.md)), set these variables. Compose +[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/maintainers.md)), set these variables. Compose loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose must be 2.26.0 or newer. @@ -246,7 +246,7 @@ loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose mu | `OAC_INSTALLATION_ID` | `state.json`; pinned to the database | | `OAC_SETTINGS_FILE` | `generated/settings.json`, the snapshot Core serves at `GET /core/v1/installation`; Core does not act on it | | `OAC_EXECUTION_CONCURRENCY`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS` | The matching `core.*` settings | -| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime-history-api.md) | +| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime-history-api.md) | | `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | `log.*`; Web reads the same three | | `OAC_E2B_STATE_DIR`, `OAC_E2B_PROVIDER_BIN` | The E2B receipt directory and, for native Core, the bundled helper. The E2B key and template live in the database | @@ -268,4 +268,4 @@ paths must be canonical absolute paths without control characters, quotes, backs or wildcards. Keep the installation ID and the database together; Core refuses a missing installation ID when its database already has a deployment. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/configuration.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/configuration.md) diff --git a/apps/docs/content/docs/console.mdx b/apps/docs/content/docs/console.mdx index 62a345834..7f227af8a 100644 --- a/apps/docs/content/docs/console.mdx +++ b/apps/docs/content/docs/console.mdx @@ -27,7 +27,7 @@ management client, and it sends nothing to `/v1`. | Platform | Projects and keys, Nodes, System | Project and key lifecycle; sandbox deployment and nodes; System: the installation's public address, API base URL, ID and source commit (read-only), each harness's default model provider (write-only key) beside its read-only startup state, the sandbox configuration every Project shares: provider, sandbox size, Runtime or E2B template build, idle suspension and reset, and Core's config.json startup settings with where to change them | Missing data is shown as missing (—), never as zero. How each figure is read and -bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/web/protocol-coverage.md). +bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/web/protocol-coverage.md). ## Management scope @@ -60,15 +60,15 @@ only the console server sends it to Core. - [Connection and authentication](/bootstrap-projects-keys) - [Architecture and ownership](/execution-model) -- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/web/protocol-coverage.md) -- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/web/roadmap.md) -- [React application](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/apps/web/README.md) +- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/web/protocol-coverage.md) +- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/web/roadmap.md) +- [React application](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/apps/web/README.md) -The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md) defines -management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md) +The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md) defines +management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md) define the separate application interface. See the [design principles](/concepts) -for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/CONTRIBUTING.md) for required checks. +for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/CONTRIBUTING.md) for required checks. -OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/LICENSE). +OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/LICENSE). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/web/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/web/README.md) diff --git a/apps/docs/content/docs/environments-and-files.mdx b/apps/docs/content/docs/environments-and-files.mdx index bfb5e6b36..7e955068d 100644 --- a/apps/docs/content/docs/environments-and-files.mdx +++ b/apps/docs/content/docs/environments-and-files.mdx @@ -3,19 +3,19 @@ title: "Environments and files" description: "Execution placement, Environment resources and workspace ownership." --- -This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/upstream.json), with partial +This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/upstream.json), with partial coverage. Core-managed Docker runs the three qualified native harnesses. V1 user-managed `self_hosted` enrollment uses the same colocated Runtime for Codex, Claude SDK and MiniMax. `/workspace` names the packaged workspace; a self-hosted Session may instead select its exact canonical physical directory. The -[recorded deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/user-managed-runtime-v1.md) retains its historical +[recorded deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/user-managed-runtime-v1.md) retains its historical source, paths and tested capability scope. For hosted compute, the deployment selects E2B, Docker or microsandbox through -[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md). +[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md). For the separate caller-managed E2B path, the user owns allocation, renewal and -cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/deploy/e2b/README.md). -[Templates](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; -[Files](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. +cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/deploy/e2b/README.md). +[Templates](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; +[Files](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. The internal Store now owns a durable Environment association for newly created `self_hosted` and `openai_hosted` snapshots, atomically with Session creation. @@ -33,15 +33,15 @@ readiness. Rotation/revocation, Session deletion and ownership loss deny further access without promising immediate cessation of native effects. Native history remains local to the bound Runtime and cannot be replaced on retry. There is no registry/Noise relay or transient service-side harness credential. -See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/README.md#user-managed-runtime-enrollment). +See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/README.md#user-managed-runtime-enrollment). ## Basic public Docker-hosted profile An explicitly configured default managed Docker provider enables `type=openai_hosted` for the qualified Codex, Claude Code and MiniMax Code profiles. Each uses the same Runtime lifecycle and workspace interfaces with its own native adapter/isolation. -See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. -The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) +See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. +The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) selects the qualified immutable Runtime image; advertised capabilities alone do not enable admission. An idle or initial-text creation commits Session, Environment and retry identity before the existing leased Worker provisions its allocation. @@ -52,10 +52,13 @@ Omitted/null network defaults to enabled. Enabled, disabled and exact-host restr policies use the same qualified image with adapter-selected immutable native policy. Templates and inline configuration share initial files, env, packages, ordered setup and inline or tenant-owned referenced Skills through ordered managed initialization. -Provider operations retain file/tool/package/setup work; Runtime imports Skills and -Plugins and finalizes directory snapshots through its authenticated connection. +The same authenticated daemon handles initial files, tool configuration, packages, +setup, Skill/Plugin import and directory snapshot finalization through +`runtime_prepare`. Providers place, create, bootstrap, inspect, renew and reclaim +compute. Package installation and setup retain enabled provisioning network access; +the requested network policy constrains native execution after setup. Unsupported hostname forms and installation combinations reject explicitly; see -the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-templates.md). Empty/null installation +the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Service-origin hosted MCP remains unsupported; Environment Plugin MCP has its own qualified transport matrix. @@ -75,11 +78,11 @@ semantics; this profile does not establish complete Environment compatibility. The application creates, renews and destroys its E2B sandbox through the official SDK. It deploys the shared Runtime, then enrolls that Runtime into a `self_hosted` Session. Core neither keeps an E2B allocation nor issues Provider renew/kill calls. -The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/deploy/e2b/README.md) owns packaging and +The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/deploy/e2b/README.md) owns packaging and user-side lifecycle instructions. Expiry or lost workspace/history must not trigger -transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/user-managed-runtime-v1.md) +transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/user-managed-runtime-v1.md) records its own real deployment evidence. -The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md#e2b-v1-qualification) records a historical +The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md#e2b-v1-qualification) records a historical Core-managed route; it does not establish this caller-managed enrollment result. Current deployment-managed E2B is a separate hosted configuration in the manager guide. @@ -117,7 +120,7 @@ connection alone is not readiness. Retries preserve identity and the five-minute database deadline. HTTP disconnect retains the reservation. Local expired/cancelled outcomes return 409, lost ownership 503, and deletion 404; exact hosted error status/body and pending-input crash recovery are unverified. See the -[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/CONTRIBUTING.md#environment-ownership-and-placement). +[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/CONTRIBUTING.md#environment-ownership-and-placement). Cancellation-only batches use the existing locked admission and native delivery. An idle cancellation creates no Turn, and retry identity preserves the original @@ -155,7 +158,9 @@ revocation/deletion on each declared deployment. Core freezes resource versions, metadata and source selections. Managed initialization keeps initial files and tool configuration before Runtime Skill/Plugin import, then runs packages and ordered setup before Runtime snapshots local capability -directories. Providers do not parse or install capability bundles. Self-hosted input +directories. All steps use the common `runtime_prepare` protocol; Providers do +not execute Core initialization commands. The common runner depends on neutral +Environment/Session identity and a Runtime peer, not Provider, deployment or OS. Self-hosted input accepts only local `capability_directories` with `workspace_directory`, not managed Skill/Plugin archive fields or a hosted Template. Local directory discovery does not populate the public API-managed `skills` or `plugins` installation arrays. @@ -179,12 +184,17 @@ Executor close, Turn cancellation and transport loss preserve the installed snap workspace and allocation. Reclamation is an explicit operation coordinated with active work; a disconnected socket is not proof that native effects have stopped. +Source selections accept portable absolute Unix, Windows drive and UNC paths; +Core does not resolve these paths on its own host. The daemon applies its local +path and access checks. Initial files and setup working directories use logical +`/workspace` paths. Harness differences remain in adapters. + The protocol is platform-neutral, but this implementation retains the existing Linux helpers, protected initialization storage and isolation requirements. It does not provide a new native-host installer, another platform layout, or Windows/macOS isolation. This implementation description does not extend historical real-deployment acceptance; new managed and self-hosted capability coverage requires its own evidence. See the -[canonical preparation rules](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/CONTRIBUTING.md#shared-runtime-capability-preparation). +[canonical preparation rules](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/CONTRIBUTING.md#shared-runtime-capability-preparation). ## Contract inventory @@ -239,7 +249,7 @@ from Session and Turn status. ## Ownership and placement decision -The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/CONTRIBUTING.md#environment-ownership-and-placement) +The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/CONTRIBUTING.md#environment-ownership-and-placement) keep Environment lifecycle common while leaving process placement and native transport to adapters. Logical ownership does not require a machine per object. @@ -263,4 +273,4 @@ native history independently of disposable compute, or prove native restoration; never treat an Environment ID as a filesystem or history backup. Do not silently move an existing Session away from its bound device. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environments.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environments.md) diff --git a/apps/docs/content/docs/execution-model.mdx b/apps/docs/content/docs/execution-model.mdx index 45e65ef01..449056642 100644 --- a/apps/docs/content/docs/execution-model.mdx +++ b/apps/docs/content/docs/execution-model.mdx @@ -10,7 +10,7 @@ Agents API independently with their own Project keys. The management backend, `AdminClient` and the React console built on them are implemented. The [design principles](/concepts) define identity and authority. -The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md) defines exact +The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md) defines exact routes, payloads, pagination and audit records. ## Request boundaries @@ -106,6 +106,6 @@ comes from the deployment's allocations; the hosted Runtime total counts hosted observation records across projects and reported lifecycle states. These sources have different coverage and refresh independently, so the console does not infer resource retention or cleanup from their difference. -Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/CONTRIBUTING.md). +Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/CONTRIBUTING.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/web/architecture.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/web/architecture.md) diff --git a/apps/docs/content/docs/hosted-providers.mdx b/apps/docs/content/docs/hosted-providers.mdx index 088e01fcf..d340c7904 100644 --- a/apps/docs/content/docs/hosted-providers.mdx +++ b/apps/docs/content/docs/hosted-providers.mdx @@ -10,7 +10,7 @@ With E2B you need no nodes. Self-hosted executors, which applications run for th own Sessions, are a different thing; see [Self-hosted executors](/self-hosted-execution). You add a node by generating a command in Web and pasting it on the host. The -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the node protocol, manual registration, placement and failure handling. ## Before you add a node @@ -187,7 +187,7 @@ The installation ID is in the command (`--installation-id`) and on the **System* 1. In Web, open **Nodes** and choose **Remove node** on the node's page, or **Remove** in its list row, then **Confirm removal**. Core refuses while the node still holds sandboxes, snapshots or pending cleanup; let them finish, or - [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) + [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) through the Core API. Removal is permanent: the host can come back only as a new node. 2. Web then shows **Clean up the host** with the uninstall command. Run it on the host: @@ -290,7 +290,7 @@ files. Shared daemon images remain; a host administrator may remove them only af confirming no installation still needs them. Microsandbox image cleanup remains scoped to the installation's private native store and verified ownership receipts. -The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) describes reset and API behavior. Same-team E2B edits have a separate online Core API path and do not involve node installation. @@ -303,4 +303,4 @@ convert or delete it. Preserve the old installation, Runtime resources and histo provision a fresh node separately through **Add node**. The current installer does not adopt historical resources or remove another installation's state. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/getting-started/nodes.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/getting-started/nodes.md) diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 385f9f6ee..733d82293 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -19,10 +19,10 @@ applications call Core with. | [API reference](/public-api) | Developers | The `/v1`, `/core/v1` and `/api/v1` namespaces and their contracts | Deeper references: the -[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md) reference for operators, the -[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md), and -[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/maintainers.md) for building distributions +[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md), and +[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/maintainers.md) for building distributions and running Core without the installer. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/getting-started/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/getting-started/README.md) diff --git a/apps/docs/content/docs/install.mdx b/apps/docs/content/docs/install.mdx index d92d4e684..0de90041e 100644 --- a/apps/docs/content/docs/install.mdx +++ b/apps/docs/content/docs/install.mdx @@ -140,7 +140,7 @@ A deployment runs its sandboxes on exactly one backend: | `none` | Nothing yet | Choose the backend on the **Nodes** page in Web | The Standard sizes come from the bundle's copy of Web's -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/apps/web/src/features/sandbox/standard-sizes.json). The +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/apps/web/src/features/sandbox/standard-sizes.json). The installer saves the choice through the same administrator API that Web uses once the services are healthy. If Core refuses it, for example because E2B rejects the key, the installer prints Core's message and exits with an error; the services keep @@ -164,7 +164,7 @@ it installs anything: ``` Prepare the template build with the -[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/deploy/e2b/README.md). To change the backend or +[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/deploy/e2b/README.md). To change the backend or the sandbox size later, use the Nodes page in Web; see [Nodes](/hosted-providers#change-the-sandbox-backend-or-size). @@ -531,4 +531,4 @@ missing images, restores a missing `oac` command, copies node files newly placed `--install-dir`. It refuses a bundle from another release; see [installation version policy](/troubleshooting#installation-version-policy). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/getting-started/install.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/getting-started/install.md) diff --git a/apps/docs/content/docs/observability.mdx b/apps/docs/content/docs/observability.mdx index 6f4da2538..82936a2c3 100644 --- a/apps/docs/content/docs/observability.mdx +++ b/apps/docs/content/docs/observability.mdx @@ -7,7 +7,7 @@ Status: Phase 2 and initial Core Web consumption implemented. The current-snapsh `packages/agents-client` projection, and generated `core.openapi.yaml` contract are implemented and consumed by the Dashboard through complete Session/observation identity joins. Durable history uses the separate optional -[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside +[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside this phase. These are administrator reads under `/core/v1`, authenticated by the Core key, @@ -92,7 +92,7 @@ Authorization: Bearer ... ``` This returns the same object shape as a list item's `observation`, without the -administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md)). It never starts a Turn, creates +administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md)). It never starts a Turn, creates an Environment, provisions compute, renews a lease, or changes lifecycle state. A valid `environment:none` Session returns `200` with status `unsupported`; the @@ -193,7 +193,7 @@ Use the existing Agents API error envelope. | 400 | `invalid_request_error` / `invalid_request_error` | List: a repeated supported query key, or an empty or invalid limit or order, with the shared Beta list messages. Unknown list query keys are ignored. | | 400 | `invalid_request_error` / `unsupported_parameter` | Single-Session retrieval with any query parameter. | | 401 | `invalid_request_error` / `invalid_admin_key` | Missing or invalid Core key. | -| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | +| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | | 500 | `server_error` / `internal_error` | Integrity, ownership, or invalid provider evidence. | | 503 | `server_error` / `execution_unavailable` | Required Runtime observation service is not configured, or list collection exceeded its request budget. | @@ -273,10 +273,10 @@ deletion makes the candidate incomplete and prevents publication. - Token usage: use existing Session/Turn Usage. - Billing and cost: product/backend concern. - Historical series in these routes: the optional capability uses the separate - [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime-history-api.md). + [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime-history-api.md). - Container logs and command output. - Provider credentials or native configuration. - Start, stop, pause, resume, restart, renew, or delete operations. - Idle classification and automatic shutdown. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime-observability-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime-observability-api.md) diff --git a/apps/docs/content/docs/public-api.mdx b/apps/docs/content/docs/public-api.mdx index 7424c8053..ccaa3e302 100644 --- a/apps/docs/content/docs/public-api.mdx +++ b/apps/docs/content/docs/public-api.mdx @@ -9,8 +9,8 @@ no credential works in another namespace. | Namespace | Caller | Credential | Contents | Reference | | --- | --- | --- | --- | --- | | `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`) | [Public API](/sessions) | -| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/core.openapi.yaml) | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime.openapi.yaml) | +| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/core.openapi.yaml) | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime.openapi.yaml) | A Project API key gets 401 on `/core/v1` and `/api/v1`; the Core key gets 401 on `/v1` and `/api/v1`. Projects own assets. Multiple equally privileged keys share @@ -34,7 +34,7 @@ with the Core key on Core's loopback port, not through the public entry. Applications call every `/v1` route with a Project API key. The routes are exactly the 58 method and path pairs in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/upstream-routes.json); the +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/upstream-routes.json); the [Public API guide](/sessions) groups them by resource. `/v1` has no Core-only route. @@ -45,18 +45,18 @@ with the Core key. | Routes | Contents | Contract | | --- | --- | --- | -| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/execution-configuration.md) | -| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/session-diagnostics.md) | +| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/execution-configuration.md) | +| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/session-diagnostics.md) | | `projects/{project_id}/sessions/{session_id}/runtime-observation`, `sandbox/runtime-observations` | Current Runtime observations | [Runtime observations](/observability) | -| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime-history-api.md) | -| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-executor-credentials.md) | -| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/installation.md) | -| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/core-metrics.md) | -| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/node-host-history.md) | -| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/model-execution.md#deployment-defaults) | +| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime-history-api.md) | +| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-executor-credentials.md) | +| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/installation.md) | +| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/core-metrics.md) | +| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/node-host-history.md) | +| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/model-execution.md#deployment-defaults) | ## Machine connection API @@ -65,42 +65,42 @@ the Core key or a Project API key. | Routes | Caller | Credential | Contract | | --- | --- | --- | --- | -| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | -| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-executor-credentials.md) | -| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/services/agents-api/README.md#user-managed-runtime-enrollment) | +| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | +| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-executor-credentials.md) | +| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/README.md#user-managed-runtime-enrollment) | ## Contract sources -- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/upstream.json): OpenAI +- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/upstream.json): OpenAI Python SDK 3.13.0, exact upstream commit and `agents=v1`. -- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/upstream-routes.json) and - [fields](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/upstream-fields.json): extracted from the +- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/upstream-routes.json) and + [fields](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/upstream-fields.json): extracted from the pinned SDK by `scripts/extract-agents-api-upstream.py`. Contract tests require the public OpenAPI to have exactly these routes and to keep every other field inside `x_agents_core`. -- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/openapi.yaml): public schema snapshot; - combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/operation-evidence.md). -- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/core.openapi.yaml): generated `/core/v1` +- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/openapi.yaml): public schema snapshot; + combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/operation-evidence.md). +- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/core.openapi.yaml): generated `/core/v1` routes, all authenticated by the Core key. -- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/runtime.openapi.yaml): - generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/node-generation-protocol.md); the private +- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime.openapi.yaml): + generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/node-generation-protocol.md); the private daemon transport is described in the Runtime credential guide. -- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md): Project/key +- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md): Project/key lifecycle, resources, explicit hosted Session archive, summary, errors/deletion preconditions, audit and historical copy provenance. - [Web integration](/admin-api): browser, console and Core boundaries and frontend handoff. -- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/CONTRIBUTING.md): +- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/CONTRIBUTING.md): ownership, security and change requirements. Generated schemas do not establish complete compatibility or real execution -support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md) identifies +support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md) identifies qualified workflows, native differences and unresolved behavior. Update the relevant contract and this index when adding or moving an API surface. -Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/core-errors.md), +Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/core-errors.md), including typed optional safe details and distinct console proxy rejection codes. The public and machine error contracts remain unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/api/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/api/README.md) diff --git a/apps/docs/content/docs/quickstart.mdx b/apps/docs/content/docs/quickstart.mdx index 785889f14..d95f2b79f 100644 --- a/apps/docs/content/docs/quickstart.mdx +++ b/apps/docs/content/docs/quickstart.mdx @@ -101,8 +101,8 @@ The provider's protocol must match the harness: | Claude Code (`claude_sdk`) | `anthropic` | The provider's exact model ID | | MiniMax Code (`mcode`) | `anthropic` | `context_window` and `max_output_tokens` | -See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/harness-selection.md) and -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/model-execution.md) for the full contract. +See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/harness-selection.md) and +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/model-execution.md) for the full contract. Selecting a harness doesn't make an unsupported model or operation work. ## Model providers @@ -142,8 +142,8 @@ not replay history. Don't submit the same work as a new Session when a response lost. Files, Artifacts, cancellation and their current limits are in the -[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md). MCP credentials use the +[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md). MCP credentials use the separate Vault API; they are neither Project API keys nor model provider keys. The [API index](/public-api) lists every route. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/getting-started/quickstart.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/getting-started/quickstart.md) diff --git a/apps/docs/content/docs/self-hosted-execution.mdx b/apps/docs/content/docs/self-hosted-execution.mdx index faa45627a..016fe6077 100644 --- a/apps/docs/content/docs/self-hosted-execution.mdx +++ b/apps/docs/content/docs/self-hosted-execution.mdx @@ -111,6 +111,9 @@ environment = { } ``` +The API accepts portable absolute Unix, Windows drive and UNC source paths without +checking Core's filesystem. The current Linux Runtime requires canonical local Unix +paths and applies its own access checks; this does not add Windows support. These paths refer to directories visible inside that Runtime. Populate them before first execution. `/workspace` maps to its bound workspace; an operator-prepared Runtime may use its exact canonical absolute workspace instead. Directory aliases, @@ -133,7 +136,7 @@ create entries in the public API-managed installation arrays. Closing an executor, cancelling a Turn or losing its connection preserves the snapshot and workspace. The compute owner remains responsible for explicit resource cleanup. This source-level capability behavior requires its own real acceptance; -[earlier qualification](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/user-managed-runtime-v1.md) covered +[earlier qualification](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/user-managed-runtime-v1.md) covered only its recorded inputs and binaries, not local directory preparation or additional platforms. @@ -182,7 +185,7 @@ key_id=$(python3 -c 'import uuid; print(uuid.uuid4())'); echo "credential ID: $k Rotate with `{"key_id":"…","rotate":true}` on the same route; revoke with `DELETE …/executor-credentials/`. After an uncertain response, list the credentials with `GET` before trying again. The -[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-executor-credentials.md) +[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-executor-credentials.md) has every rule and error. @@ -198,4 +201,4 @@ continuation or automatic data deletion. Credential replacement for executors created by the current installer updates and restarts the same owned container. It does not authorize adopting an older executor. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/getting-started/self-hosted.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/getting-started/self-hosted.md) diff --git a/apps/docs/content/docs/sessions.mdx b/apps/docs/content/docs/sessions.mdx index d92f0f337..b3fc8a131 100644 --- a/apps/docs/content/docs/sessions.mdx +++ b/apps/docs/content/docs/sessions.mdx @@ -34,8 +34,8 @@ leaves the others valid; archiving the Project revokes all its keys. The optiona ## Public resources Paths are relative to `/v1`. Detailed fields, union types and response shapes are -in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/openapi.yaml), fixed SDK source -and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/README.md#upstream-resource-inventory) +in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/openapi.yaml), fixed SDK source +and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md#upstream-resource-inventory) lists each operation and its coverage. | Resource | Route family | Semantics | @@ -44,11 +44,11 @@ lists each operation and its coverage. | Sessions and Turns | `/agents/sessions`, nested `/turns` | Admission, metadata, state and recovery queries | | Items and events | Session `/items`, `/events` | Persisted history and live SSE | | Artifacts | Session `/artifacts` | Captured output metadata, immutable content and deletion | -| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/subagents.md) | -| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-files.md) | -| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-templates.md) | -| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/source-files.md), distinct from live workspace files | -| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/file-resource-semantics.md) | +| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/subagents.md) | +| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-files.md) | +| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-templates.md) | +| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/source-files.md), distinct from live workspace files | +| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/file-resource-semantics.md) | | Vaults and Credentials | `/vaults`, nested `/credentials` | [Scoped write-only credential storage](/environments-and-files) | SSE is live, not a historical replay service. After a disconnect, query the Session, @@ -58,14 +58,14 @@ native execution. Do not automatically replay uncertain tool or file effects. ## Core extension fields The `/v1` route set is exactly the pinned SDK's operations, listed in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/upstream-routes.json); Core adds +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/upstream-routes.json); Core adds no route. It adds fields only inside `x_agents_core`, because it runs several harnesses and accepts custom model access: | Field | Where | Reference | | --- | --- | --- | -| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/harness-selection.md) | -| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/model-execution.md) | +| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/harness-selection.md) | +| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/model-execution.md) | Any other member of `x_agents_core` is rejected with 400. Deployment, placement, credential issuance and operational reads are not part of `/v1`; they belong to @@ -78,7 +78,7 @@ never to `self_hosted`; `none` Sessions accept only the deployment default. `openai_hosted` and `self_hosted` Sessions must end up with one: otherwise creation returns 400 with code `model_provider_required` and param `x_agents_core.model_provider`, and nothing is written. See -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/model-execution.md). +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/model-execution.md). An application reads a Session's model from `agent.model` and an explicitly selected harness from `agent.x_agents_core.harness`; a Session on the deployment @@ -90,8 +90,8 @@ The deployment chooses E2B or its own Docker/microsandbox nodes. A public `self_hosted` Environment uses caller-owned compute and the same colocated Runtime. For a `self_hosted` Session, the deployment operator issues the Environment's executor credential with the Core key; see -[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/environment-executor-credentials.md). +[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-executor-credentials.md). Our daemon transport and the supported native harness differences are explicit; they do not imply interoperability with OpenAI's stock executor transport. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/api/public-agent-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/api/public-agent-api.md) diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx index 80a3a168d..a74fc28fe 100644 --- a/apps/docs/content/docs/troubleshooting.mdx +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -123,7 +123,7 @@ refuses. `rotate-core-key` refuses to run on a Web-only installation. ## Projects and API keys Create Projects and issue their keys in Web, on **Projects and keys**, or with the -[Core API](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's +[Core API](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's plaintext is shown once; Core stores only its digest. All keys of a Project share its assets and execution principal, and each write records the key that made it. @@ -241,9 +241,9 @@ Core records which key made each public resource write, for the console. Set `core.write_audit_retention` in `config.json` (Go duration, at least `1h`, default `2160h`) to control how long non-creation history is kept; creation ownership is kept for good. Removing keys or resources does not delete these records. See the -[query contract](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/write-audit.md); administrator mutations +[query contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/write-audit.md); administrator mutations have a separate -[audit log](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither +[audit log](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither logs bodies or secrets. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/f9c46331224d589fc823ff69c2bc0fdc21525aec/docs/getting-started/operations.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/getting-started/operations.md) diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index dc03fbfdf..1748e9e47 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -1,5 +1,5 @@ { - "revision": "f9c46331224d589fc823ff69c2bc0fdc21525aec", + "revision": "8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05", "sources": { "docs/getting-started/README.md": "4eb3d21e073b25e2e6ffb8800e13b33032104ba44d8d93ea3e59394549dc7962", "docs/design-principles.md": "71285ff0b9a7796481b9fe42f7c4b71d0377c767157980ca6386792d9ca8951b", @@ -11,9 +11,9 @@ "docs/api/README.md": "592fc2aef474e36eb760be0dd1b2269b6766c0d984f826eaf6f47711ea139387", "contracts/agents-api/execution-tools.md": "e0b61f6c0c236c362186c5f6d0ad69a16dd8a8d9afabe1329a1fe22e5f4a71e9", "docs/api/public-agent-api.md": "18829b366bd2e49fe5beb76dc8a709bc1aa8e35d4fbfd2484a0c52b46cf75417", - "contracts/agents-api/environments.md": "1708597619bfe1ca38a42142db46f9ef11a129f56de5451b59092d18c32c7062", + "contracts/agents-api/environments.md": "4cbc2efb68e7527798b3d6836593aad5af1130416c861fc6b2b638681aea328a", "docs/getting-started/nodes.md": "c1bc854c4798b9757bbb8cea4474a03442ba6f4c10444d5505733e8b94046038", - "docs/getting-started/self-hosted.md": "ac46bb2518f7c2f8cb9a53cd0696b5fe442bd99e2fb62a1eba65d39c899e91e7", + "docs/getting-started/self-hosted.md": "82f45d07962f49ebffbda9f2c4c4ac029296b31a6cfa048f9fcf7adca32e9de9", "apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "docs/web/README.md": "9fce295f6f6ff04acefe338679c1e54723e123dd0027a778202adfa9ab95b6c4", "docs/api/web-management.md": "dc79df33dade6139749752c9ce53d4e126354cfabccada3e7826de4dd20fae62", @@ -22,40 +22,40 @@ "apps/docs/scripts/guides.json": "1f6036e3cccaf5a71cdd9ea790be6f395acdf871c4552c490b6747957a10b174" }, "outputs": { - "content/docs/index.mdx": "b9c5fafe1cd57a5ecefae7836c5c8bf855b8b16fbc922c0867e90c2d79a94131", + "content/docs/index.mdx": "206b07e7a3e4ffd5d45932115c8e759093fddf8ba8511981e9a6de7e8b9f998f", "content/docs/index.zh.mdx": "5bdd41893946f1d229eb4cf1599a97f2e38cb9e09722d2a69f62053376ac88a9", - "content/docs/concepts.mdx": "ef058d6f37c584fc946f16556ce6b643863f204eca66e38797197bba0b48d313", + "content/docs/concepts.mdx": "8914eb598c35bf1e78574d5825e7f084e26ec514cd65a6aa7a45d63f738bac40", "content/docs/concepts.zh.mdx": "c60644f395dbac5ee607cdb053249e05ec8352d28de97ef1c38d6c1e4443bbb1", - "content/docs/execution-model.mdx": "33e39c4b439f8aadcca14fedef176b4d470af7e50760d09c1a75ba40140f31ac", + "content/docs/execution-model.mdx": "47f7455632528121f8aa03ba76853b573914ba10e0c5c0d9915432acdb74b499", "content/docs/execution-model.zh.mdx": "9976a9203c6a83d49c2c753e3a8528a369078e87aeb88850654da948ac8c17e1", - "content/docs/install.mdx": "09041889ed7157abcfa727ff91aa5640a2404e53ec2e62bc9c316513f2e9ef16", + "content/docs/install.mdx": "e9b2b182e4d2e7482dba56cd5c618a3536457b805581b7243dea51cb3f4b8a23", "content/docs/install.zh.mdx": "f207607f0cfceef2012a4e772fc347ef254486327584289fee1c85414dffc907", - "content/docs/configure.mdx": "3e63da5868385af3dd315570dc86d51a784321dcb6300d9c8556711ad08645bf", + "content/docs/configure.mdx": "57a0e13a139bec2c5974ce1f388822034cac23c5e592790a439ed64d47d5e5e7", "content/docs/configure.zh.mdx": "1c4b149c332ade6f2eeca52319e3e7ccfaac3213db2fd084d66e2da517b6bbd6", - "content/docs/bootstrap-projects-keys.mdx": "2ad2ef37d87e6df51e5a234f0d8b611497872533767a2bbd9031993097c683d5", + "content/docs/bootstrap-projects-keys.mdx": "2436939c5f43f5e792b3f2464b56b2e5f3a33a7d6334938bff6426cc8a465cd5", "content/docs/bootstrap-projects-keys.zh.mdx": "509b4dcea4c865d6834bcaef78baa681180d48274931230f958a39e46011c88f", - "content/docs/quickstart.mdx": "18a1526b0fc32140c4b85246dc86b230398d80303ffa2fd7c2e1eb055a0bb05e", + "content/docs/quickstart.mdx": "15feda8a29813de9d109118ef3cb12615747593a295e8df2097f022a4e8f0e49", "content/docs/quickstart.zh.mdx": "c5f119b100b102918a45763834d0ed7fd436479fb411e16a62a408411dbcb3e1", - "content/docs/public-api.mdx": "abe9c3afadb6afd2fc4507a6b9c89ada175363c4aaefa6377b3f371cd75c7671", + "content/docs/public-api.mdx": "1d0db1d288637cfd48c0681faad57b9a45501d364cd4dd3cb0fc64b2d0f2ea3b", "content/docs/public-api.zh.mdx": "fb0efb9be2fba3f4a3dc71bf053d2c7eddffbfe010f59fa5a1ae60a3f068545b", - "content/docs/agents-and-tools.mdx": "20f3f4aa67f01e18ed29494175bd3e88e6176594b380bfa2287e87d7502241f6", + "content/docs/agents-and-tools.mdx": "03bea6348bef93793a37f8bcd4a3ac3c190dfa21172a3793b89bdf11fa044a9d", "content/docs/agents-and-tools.zh.mdx": "cc8d4e0ef0ea894f068f3bbd143d5ee70c6346bf98f425b1d5fe07ec3931f128", - "content/docs/sessions.mdx": "6e094466de44a327c5cdbae0dcc45178ea7e8244e5667abd69c7bb0682991806", + "content/docs/sessions.mdx": "f81fae4eedbe2978a1849d622a522827a74ad06e9ec5761c13373d5c38f85310", "content/docs/sessions.zh.mdx": "b1b1a7f8aacd9a902320f9692173ede24036124f85761cf02f23ef3ef2bcc4c1", - "content/docs/environments-and-files.mdx": "c25c722a12e9ac41c50532977bdcb69ab988822a4a50e8fe7ede22d8fd27921b", + "content/docs/environments-and-files.mdx": "d8b132b2531cab5b8311d3b7bd8c0863a3a8508563f5953f39faf8a55afc87f6", "content/docs/environments-and-files.zh.mdx": "ee64577220a798f023b330c65302c14438f25a34a78c4e36144bfdb4304a3ae5", - "content/docs/hosted-providers.mdx": "762e748a29023ab5f5db18a59c59ab20bda97afad3f15d59f41e33efa019333e", + "content/docs/hosted-providers.mdx": "4aa653df6af336971e916250377297acb6671183a34e3f8d036f068b46e66cb7", "content/docs/hosted-providers.zh.mdx": "d7f6115f052a09dc943f0ed516ff30a27da5847ed24d387d9d1c7e7faa006ab0", - "content/docs/self-hosted-execution.mdx": "4145809b2d61d4f05f27ccaeaf191794e726e7784fd99a38880c506e292840e6", + "content/docs/self-hosted-execution.mdx": "7413d12dba239a471f8e5d5c1296deb3ab33d2bf07452ff8639f713dba753a4b", "content/docs/self-hosted-execution.zh.mdx": "f11d34ce30d48039376256474bc9509a508a2f6b9906f49b5cf6e396707ae5c0", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", - "content/docs/console.mdx": "f05571cc0a5b0254b49b5d0e0713761a768e9e2d850a462bd0f77f51f3d0cb31", + "content/docs/console.mdx": "b1a6c89bf81ccd06ad290f9f7425ac0bc88ec6d2a450185f17a53a2e46de5845", "content/docs/console.zh.mdx": "72f953ba46dcc95933773ac0c4dffdc0d0a499be9d9a1cb9467db2659f1fa10d", - "content/docs/admin-api.mdx": "e0111965a0f1d36ce9765f27b35981e7c28ef0e9aceaff2a8a61847b6feba1d6", + "content/docs/admin-api.mdx": "dee0a195cd40b0f9903ec0f23cfd47a75c192d44805e9c2fc6db5a4199e47052", "content/docs/admin-api.zh.mdx": "07006de30df3080e54c3cd79ed0ea5337dce557aa2f11afea84edb7f92884d38", - "content/docs/observability.mdx": "7938fa836c51daf91c7843fa55921485241a87255442917cf74a688229a39be6", + "content/docs/observability.mdx": "53fbc9a5891234b0a6cfd5b44ba038d72bdb3f4d2bab06be0f704969d1aacd4d", "content/docs/observability.zh.mdx": "9574b3412a5754f41ab2d3a16062ac1cad06766be481a8e6bff1b84acbde7e7e", - "content/docs/troubleshooting.mdx": "b896d24e469a1659fbc328bbfd5d5022fedab2ee938a16c9a16e3bf11f11345d", + "content/docs/troubleshooting.mdx": "1e28d2d05ded1d0661c1c806973563328f652502598465354e8504f967837c1e", "content/docs/troubleshooting.zh.mdx": "6d8b4668e8c371120a9275daabbebc0d1ecf21a42ba32beeb92853c14b5a459a" } } diff --git a/apps/docs/scripts/generate-guides.mjs b/apps/docs/scripts/generate-guides.mjs index d97d41c28..348073832 100644 --- a/apps/docs/scripts/generate-guides.mjs +++ b/apps/docs/scripts/generate-guides.mjs @@ -8,7 +8,7 @@ const app = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const repo = path.resolve(app, '../..') const guides = JSON.parse(fs.readFileSync(path.join(app, 'scripts/guides.json'), 'utf8')) const routes = new Map(guides.map(g => [g.source, g.slug === 'index' ? '/' : '/' + g.slug])) -const sourceRevision = 'f9c46331224d589fc823ff69c2bc0fdc21525aec' +const sourceRevision = '8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05' const sourceURL = relative => `https://github.com/MiniMax-AI/parsar-core/blob/${sourceRevision}/${relative}` const digest = text => crypto.createHash('sha256').update(text).digest('hex') function mdx(text) { From 108513b24ead8d654a52a7419956218564df3a1d Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 16:40:41 +0000 Subject: [PATCH 26/78] Pin generated guides to integrated Runtime source --- apps/docs/content/docs/admin-api.mdx | 24 +++---- apps/docs/content/docs/agents-and-tools.mdx | 40 ++++++------ .../content/docs/bootstrap-projects-keys.mdx | 4 +- apps/docs/content/docs/concepts.mdx | 6 +- apps/docs/content/docs/configure.mdx | 16 ++--- apps/docs/content/docs/console.mdx | 18 +++--- .../content/docs/environments-and-files.mdx | 34 +++++----- apps/docs/content/docs/execution-model.mdx | 6 +- apps/docs/content/docs/hosted-providers.mdx | 8 +-- apps/docs/content/docs/index.mdx | 8 +-- apps/docs/content/docs/install.mdx | 6 +- apps/docs/content/docs/observability.mdx | 10 +-- apps/docs/content/docs/public-api.mdx | 64 +++++++++---------- apps/docs/content/docs/quickstart.mdx | 8 +-- .../content/docs/self-hosted-execution.mdx | 6 +- apps/docs/content/docs/sessions.mdx | 26 ++++---- apps/docs/content/docs/troubleshooting.mdx | 8 +-- apps/docs/content/guide-sources.json | 36 +++++------ apps/docs/scripts/generate-guides.mjs | 2 +- 19 files changed, 165 insertions(+), 165 deletions(-) diff --git a/apps/docs/content/docs/admin-api.mdx b/apps/docs/content/docs/admin-api.mdx index 5fa1b29f5..1e4fe2786 100644 --- a/apps/docs/content/docs/admin-api.mdx +++ b/apps/docs/content/docs/admin-api.mdx @@ -8,7 +8,7 @@ resource inspection, Project and key management, credential issuance, audit, usa and sandbox operations. It never calls `/v1` or `/api/v1`, and has no Agent execution, copy or arbitrary asset editing operation. -Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/core-errors.md), +Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core-errors.md), including optional typed safe details. Console sign-in endpoints retain their separate error shape described below. @@ -49,9 +49,9 @@ label, but could set any value. Never use it for authorization or as proof of origin. Web calls Core only through the typed Core clients in `packages/agents-client`: -[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/packages/agents-client/src/admin-client.ts) (`/core/v1`), +[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/packages/agents-client/src/admin-client.ts) (`/core/v1`), `SandboxAdminClient` (`/core/v1/sandbox`) and `CoreMetricsClient` -(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md) +(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) for methods, fields, filters, pagination and response shapes. Routes below are relative to `/core/v1`: @@ -62,8 +62,8 @@ Routes below are relative to `/core/v1`: | Resource lists/details/deletion | `/projects/{id}/agents`, `/sessions`, `/environment-templates`, `/skills`, `/files`, `/vaults`, including the documented nested reads | | Asset ownership | `GET /projects/{id}/resource-owners` with batched resource IDs | | Key operation history | `GET /projects/{id}/write-operations` with key/resource/time filters | -| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-executor-credentials.md)) | -| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/model-execution.md#deployment-defaults)) | +| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md)) | +| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md#deployment-defaults)) | | Usage and health | `GET /summary`, `/sandbox/runtime-observations`, `/metrics` | | Administrator audit | `GET /audit-log`; deployment-wide entries have `project_id: null` | @@ -76,9 +76,9 @@ it is operational attribution, not per-key billing. ## Sandbox administration -The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/sandbox-deployment.md), -[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md) -and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/core.openapi.yaml) +The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/sandbox-deployment.md), +[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core.openapi.yaml) define deployment and node operations: - `GET/POST/PUT /core/v1/sandbox/deployment` and @@ -115,7 +115,7 @@ is a durable serving pin, not proof of current connectivity. Target unknown, fai or update-required state does not by itself invalidate confirmed old-generation service; consume Core's connection/provider facts separately. -Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md#administrative-session-archive) +Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md#administrative-session-archive) at the current generation without reset. History and persisted Files/Artifacts survive; unpersisted workspace is lost and the original Session cannot resume. Cancel reset stops further archives, not cleanup already requested. After zero @@ -156,7 +156,7 @@ generated schema; do not interpret every empty or failed read as an absent resou `GET /core/v1/metrics?range=1h|6h|24h|7d` returns Core process, execution queue/slots, PostgreSQL and background-job measurements. It requires the Core key, rejects arbitrary query filters and never grants -Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/core-metrics.md) +Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core-metrics.md) for complete buckets, null values, units and process-local retention. Frontend implementation is maintained separately; this backend change does not modify Agent metrics or the public Agent API. @@ -165,7 +165,7 @@ Agent metrics or the public Agent API. The Core key reads a node and its host history through `GET /core/v1/sandbox/nodes/{node_id}?range=1h|6h|24h`. See the -[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/node-host-history.md) +[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/node-host-history.md) for nullable observations, freshness and aggregation. The node list is unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/api/web-management.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/api/web-management.md) diff --git a/apps/docs/content/docs/agents-and-tools.mdx b/apps/docs/content/docs/agents-and-tools.mdx index be015715a..888915ce6 100644 --- a/apps/docs/content/docs/agents-and-tools.mdx +++ b/apps/docs/content/docs/agents-and-tools.mdx @@ -4,7 +4,7 @@ description: "Native harness tools, shared declarations and the limits of execut --- Assessed against Core `206474a4c10901442b1faa094281bfb5559082b5` on 2026-09-22. -The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/upstream.json), Beta +The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream.json), Beta `agents=v1`. This matrix records the bounded execution/tools milestone. It does not establish complete protocol compatibility; the remaining limits below stay open. @@ -22,20 +22,20 @@ and Environment Plugin MCP have separate inventories and qualification. | Operation | Implemented behavior and real qualification | Unsupported or unverified boundary | | --- | --- | --- | -| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | -| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | -| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | -| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | -| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | -| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | +| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | +| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | +| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | +| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | +| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | +| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | | Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. MiniMax functions and self-hosted image workflows are unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | -| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | -| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | -| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | -| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | -| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | -| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | -| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | +| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | +| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | +| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | +| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | +| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | +| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | +| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | ## Required-action recovery contract @@ -52,7 +52,7 @@ application already performed the function, keep and submit that saved result; do not run an external effect again merely because an action remains visible. Core's acknowledgement boundary is native application. For an environment action, connect its exact Environment using the existing enrollment authorization. -[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/store/environment_connections.go) +[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/environment_connections.go) fence stale generations; transport connection can clear pre-Turn activity before native preparation. Neither registration nor an action disappearing proves that a model ran successfully. Query the matching Turn and Items for its outcome. @@ -61,8 +61,8 @@ Two timing questions remain open. Repeated `requires_action` notification order and acknowledgement timing are local implementation choices, not proven upstream semantics. Also, an admitted result that is cancelled before native observation can remain internally saved without a public output Item or `item.added`. -[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/internal/store/item_projection.go) and -[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md#public-function-configuration) preserve +[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/item_projection.go) and +[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#public-function-configuration) preserve this gap. Successful retry cannot manufacture the missing observation or establish that the model consumed the result. @@ -82,12 +82,12 @@ not a claim that every historical workflow was rerun at the current baseline. | M2 | `20260922/workspace-images/validation-summary.json`; Codex `public-run-_lr5uiy_`, Claude `public-run-sb65p9e9`; candidate `2c295116d66ed9aafc808ec49a8cb6dcb5c674c2`, merge `1adb2489bc3415039440224e9a7905c53e68a557` ([#17](https://github.com/MiniMax-AI/parsar-core/pull/17)). Kimi K3, Codex 0.153.4, Claude SDK 0.3.269/native 2.1.269; Docker seven-Turn image/workspace workflow. Codex's receipt conclusion is superseded by F2. | | F1 | `20260922/function-result-images/validation-summary.json`, `validated/function-image-public-2567456666/public.json`; candidate `d3cdb225bc7628b968b76c7e1b400101894ce8cd`, merge `fd23f169e1ede1b2f1c39a1d9dcce71886e3c006` ([#16](https://github.com/MiniMax-AI/parsar-core/pull/16)). Claude SDK 0.3.269/native 2.1.269 with Kimi K3 on `none`; success PNG/JPEG, failed text, retries, cancellation, history continuation. Earlier Codex transport-only evidence does not qualify current receipts. | | F2 | `20260922/function-result-receipts/validation-summary.json`, `candidate-public-owner-sdk.log`; hosted `public-run-4fn70foy` (104.13s), `none` `function-image-public-945075091` (83.918s). Candidate `6dceb98e1c56469fcc70cd82025ce77fc870ed3d`, merge `206474a4c10901442b1faa094281bfb5559082b5` ([#20](https://github.com/MiniMax-AI/parsar-core/pull/20)); real Kimi, Codex 0.153.4. Qualifies native receipts, not completed provider consumption. | -| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | +| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | | S1 | `20260922/structured-output/acceptance-summary.json`, `structured-public-1905281777/public.json`; real candidate `7f533d46c472f3cdf7c16ce9471c225a2ba7eded`, merge `8716e699dbc34904499c94b6b0b03857bbebfaad` ([#11](https://github.com/MiniMax-AI/parsar-core/pull/11)). Claude/Kimi `none` schema/function execution and cold continuation. Schema-specific active steering was not separately qualified here. | | S2 | `20260922/hosted-structured-output/validation-summary.json`, `validated/public-inline.log`, `public-run-qvq3csf1` (186.09s); candidate `f6d2c3f2a1dc2ff5f177213bd2b20d78496011d0`, merge `aa85d8ecc60e0a8b7f2494b73caa81216ee197e2` ([#18](https://github.com/MiniMax-AI/parsar-core/pull/18)). Claude SDK 0.3.269/native 2.1.269, real Kimi; Docker saved/inline schema, prepared/active input, native files and four completed structured answers. | | D1 | `20260922/deferred-tools/tool-search-public-2039155387/public.json`, `public-image-isolated-tests.log` (86.71s); merge `178507ae7a63d4068e1e82bef9cd256ba398ae00` ([#13](https://github.com/MiniMax-AI/parsar-core/pull/13)). Claude SDK 0.3.269/native 2.1.269/Kimi K3 `none`; text results with initial/active PNGs, cancellation and cold continuation. `claude-native-1790052750` separately records provider schema inventories and native feasibility. | | P1 | `20260922/tool-policy/acceptance.json`, `server-evidence/tool-policy-{codex-2495445746,claude_sdk-2539346823,mcode-3747575401}/public.json`; candidate `eeb432c7495265ae3a9309e32f5b4323611c3245`, merge `4b8754a6eda6696d9b18eb8d583953ac16c6800f` ([#14](https://github.com/MiniMax-AI/parsar-core/pull/14)). Codex/Claude Kimi K3, MiniMax M2.7; four `none` Sessions per harness, native disable controls and cold continuation. | -| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | +| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | | E2 | `20260922/execution-tools-closure/environment-actions/result.json` contains Codex/Claude Kimi passes (13 checks each) and the retained MiniMax direct-network failure; `mcode-relay-retry/result.json` separately passes MiniMax M2.7 (13 checks) after correcting provider routing. Source `206474a`; same shared SDK/raw workflow, isolated Core/PostgreSQL and user-managed Docker. Covers initial creation SSE, client disconnect, exact pending Environment/no Turn or Items, scoped enrollment, one actual completed model Turn and creation retries preserving history. This is connection-action qualification, not another full lifecycle or E2B regression. | Controlled tests establish parser, projection, atomicity and race behavior; native @@ -121,4 +121,4 @@ remain separate limitations. They do not authorize silently relabeling an unprov principal workflow as deferred. OAuth, full Usage, release publication, new tool kinds and new execution loops are outside this closure batch. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/execution-tools.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/execution-tools.md) diff --git a/apps/docs/content/docs/bootstrap-projects-keys.mdx b/apps/docs/content/docs/bootstrap-projects-keys.mdx index 2882ed2d3..2194e6472 100644 --- a/apps/docs/content/docs/bootstrap-projects-keys.mdx +++ b/apps/docs/content/docs/bootstrap-projects-keys.mdx @@ -69,7 +69,7 @@ and display only. Keep deployment, application, node and provider credentials ou Installation creates no Project or application key. An administrator creates a Project and issues named keys on the console's **Projects and keys** page, which -uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md), or with that +uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md), or with that API directly. The Project owns one tenant and one principal; its keys share assets and permissions. Writes retain each key's provenance. All Projects and application keys live in @@ -98,4 +98,4 @@ management request points to the console's Core key or Core connection. A resour deletion conflict must remain visible; it does not authorize an execution call. Node and daemon `/api/v1` routes and native Runtime interfaces retain their own authentication. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/web/core-connection.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/core-connection.md) diff --git a/apps/docs/content/docs/concepts.mdx b/apps/docs/content/docs/concepts.mdx index f453d3514..6ff1c2e41 100644 --- a/apps/docs/content/docs/concepts.mdx +++ b/apps/docs/content/docs/concepts.mdx @@ -83,12 +83,12 @@ ownership. No request bodies, secrets or file contents enter audit records. Do not add product users, RBAC, cross-Project shared assets, administrator execution, or old private-protocol compatibility to this management model. Existing public -execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/CONTRIBUTING.md). +execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md). Native failure classification is adapter-owned and uses finite structured native values. Optional Runtime error metadata is normalized once and retained through normal delivery and journal draining; it never replaces Core terminal authority, cancellation receipts, Usage or native identity. See -[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/native-error-classification.md). +[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/native-error-classification.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/design-principles.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/design-principles.md) diff --git a/apps/docs/content/docs/configure.mdx b/apps/docs/content/docs/configure.mdx index 143bd6f62..360b9717a 100644 --- a/apps/docs/content/docs/configure.mdx +++ b/apps/docs/content/docs/configure.mdx @@ -116,7 +116,7 @@ output or in Core's settings snapshot. Model providers are not process settings; The schema is -[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/deploy/install/config.schema.json); each +[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/deploy/install/config.schema.json); each installation keeps a copy in `generated/config.schema.json` for editors. Core serves the non-secret settings snapshot, with the path of `config.json` and the apply command, at `GET /core/v1/installation`. @@ -145,7 +145,7 @@ Which harnesses are enabled, and the default one, are process settings Exactly one provider serves the deployment: Docker, microsandbox or E2B. The deployment specification holds the per-sandbox resources and, for Docker and microsandbox, the Runtime release. Web's setup proposes the Standard size from -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/apps/web/src/features/sandbox/standard-sizes.json) and +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/apps/web/src/features/sandbox/standard-sizes.json) and derives Small and Large from it; the installer applies the bundle's copy of the same file. The database owns the saved values. @@ -183,8 +183,8 @@ Current Runtime generation coexistence and ownership-scoped garbage collection remain supported. They do not upgrade the installed node program or convert an old installation. History stays, and existing Sessions never move between providers. See the [nodes guide](/hosted-providers#change-the-sandbox-backend-or-size), -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) -and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/sandbox-deployment.md). +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/sandbox-deployment.md). ### Node capacity @@ -211,7 +211,7 @@ Session freezes the provider it starts with, so a later change affects only new Sessions. Self-hosted Sessions never use it. A hosted or self-hosted Session with no provider is refused with 400 `model_provider_required`. See [model providers](/quickstart#model-providers) and the -[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/model-execution.md#deployment-defaults). +[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md#deployment-defaults). The operator file `AGENTS_API_EXECUTION_OPTIONS_FILE` is retired; see [installation version policy](/troubleshooting#installation-version-policy). @@ -231,7 +231,7 @@ A Web-only installation has only `secrets/core.key`, a copy of its Core's key. Core reads only its environment. The installer renders `generated/core.env` from `config.json`; if you run Core yourself (see -[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/maintainers.md)), set these variables. Compose +[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/maintainers.md)), set these variables. Compose loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose must be 2.26.0 or newer. @@ -246,7 +246,7 @@ loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose mu | `OAC_INSTALLATION_ID` | `state.json`; pinned to the database | | `OAC_SETTINGS_FILE` | `generated/settings.json`, the snapshot Core serves at `GET /core/v1/installation`; Core does not act on it | | `OAC_EXECUTION_CONCURRENCY`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS` | The matching `core.*` settings | -| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime-history-api.md) | +| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-history-api.md) | | `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | `log.*`; Web reads the same three | | `OAC_E2B_STATE_DIR`, `OAC_E2B_PROVIDER_BIN` | The E2B receipt directory and, for native Core, the bundled helper. The E2B key and template live in the database | @@ -268,4 +268,4 @@ paths must be canonical absolute paths without control characters, quotes, backs or wildcards. Keep the installation ID and the database together; Core refuses a missing installation ID when its database already has a deployment. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/configuration.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/configuration.md) diff --git a/apps/docs/content/docs/console.mdx b/apps/docs/content/docs/console.mdx index 7f227af8a..4a39b51de 100644 --- a/apps/docs/content/docs/console.mdx +++ b/apps/docs/content/docs/console.mdx @@ -27,7 +27,7 @@ management client, and it sends nothing to `/v1`. | Platform | Projects and keys, Nodes, System | Project and key lifecycle; sandbox deployment and nodes; System: the installation's public address, API base URL, ID and source commit (read-only), each harness's default model provider (write-only key) beside its read-only startup state, the sandbox configuration every Project shares: provider, sandbox size, Runtime or E2B template build, idle suspension and reset, and Core's config.json startup settings with where to change them | Missing data is shown as missing (—), never as zero. How each figure is read and -bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/web/protocol-coverage.md). +bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/protocol-coverage.md). ## Management scope @@ -60,15 +60,15 @@ only the console server sends it to Core. - [Connection and authentication](/bootstrap-projects-keys) - [Architecture and ownership](/execution-model) -- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/web/protocol-coverage.md) -- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/web/roadmap.md) -- [React application](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/apps/web/README.md) +- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/protocol-coverage.md) +- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/roadmap.md) +- [React application](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/apps/web/README.md) -The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md) defines -management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md) +The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) defines +management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md) define the separate application interface. See the [design principles](/concepts) -for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/CONTRIBUTING.md) for required checks. +for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md) for required checks. -OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/LICENSE). +OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/LICENSE). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/web/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/README.md) diff --git a/apps/docs/content/docs/environments-and-files.mdx b/apps/docs/content/docs/environments-and-files.mdx index 7e955068d..896627ce9 100644 --- a/apps/docs/content/docs/environments-and-files.mdx +++ b/apps/docs/content/docs/environments-and-files.mdx @@ -3,19 +3,19 @@ title: "Environments and files" description: "Execution placement, Environment resources and workspace ownership." --- -This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/upstream.json), with partial +This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream.json), with partial coverage. Core-managed Docker runs the three qualified native harnesses. V1 user-managed `self_hosted` enrollment uses the same colocated Runtime for Codex, Claude SDK and MiniMax. `/workspace` names the packaged workspace; a self-hosted Session may instead select its exact canonical physical directory. The -[recorded deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/user-managed-runtime-v1.md) retains its historical +[recorded deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md) retains its historical source, paths and tested capability scope. For hosted compute, the deployment selects E2B, Docker or microsandbox through -[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md). +[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md). For the separate caller-managed E2B path, the user owns allocation, renewal and -cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/deploy/e2b/README.md). -[Templates](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; -[Files](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. +cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/deploy/e2b/README.md). +[Templates](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; +[Files](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. The internal Store now owns a durable Environment association for newly created `self_hosted` and `openai_hosted` snapshots, atomically with Session creation. @@ -33,15 +33,15 @@ readiness. Rotation/revocation, Session deletion and ownership loss deny further access without promising immediate cessation of native effects. Native history remains local to the bound Runtime and cannot be replaced on retry. There is no registry/Noise relay or transient service-side harness credential. -See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/README.md#user-managed-runtime-enrollment). +See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/README.md#user-managed-runtime-enrollment). ## Basic public Docker-hosted profile An explicitly configured default managed Docker provider enables `type=openai_hosted` for the qualified Codex, Claude Code and MiniMax Code profiles. Each uses the same Runtime lifecycle and workspace interfaces with its own native adapter/isolation. -See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. -The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) +See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. +The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) selects the qualified immutable Runtime image; advertised capabilities alone do not enable admission. An idle or initial-text creation commits Session, Environment and retry identity before the existing leased Worker provisions its allocation. @@ -58,7 +58,7 @@ setup, Skill/Plugin import and directory snapshot finalization through compute. Package installation and setup retain enabled provisioning network access; the requested network policy constrains native execution after setup. Unsupported hostname forms and installation combinations reject explicitly; see -the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-templates.md). Empty/null installation +the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Service-origin hosted MCP remains unsupported; Environment Plugin MCP has its own qualified transport matrix. @@ -78,11 +78,11 @@ semantics; this profile does not establish complete Environment compatibility. The application creates, renews and destroys its E2B sandbox through the official SDK. It deploys the shared Runtime, then enrolls that Runtime into a `self_hosted` Session. Core neither keeps an E2B allocation nor issues Provider renew/kill calls. -The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/deploy/e2b/README.md) owns packaging and +The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/deploy/e2b/README.md) owns packaging and user-side lifecycle instructions. Expiry or lost workspace/history must not trigger -transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/user-managed-runtime-v1.md) +transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md) records its own real deployment evidence. -The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md#e2b-v1-qualification) records a historical +The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#e2b-v1-qualification) records a historical Core-managed route; it does not establish this caller-managed enrollment result. Current deployment-managed E2B is a separate hosted configuration in the manager guide. @@ -120,7 +120,7 @@ connection alone is not readiness. Retries preserve identity and the five-minute database deadline. HTTP disconnect retains the reservation. Local expired/cancelled outcomes return 409, lost ownership 503, and deletion 404; exact hosted error status/body and pending-input crash recovery are unverified. See the -[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/CONTRIBUTING.md#environment-ownership-and-placement). +[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md#environment-ownership-and-placement). Cancellation-only batches use the existing locked admission and native delivery. An idle cancellation creates no Turn, and retry identity preserves the original @@ -194,7 +194,7 @@ helpers, protected initialization storage and isolation requirements. It does no a new native-host installer, another platform layout, or Windows/macOS isolation. This implementation description does not extend historical real-deployment acceptance; new managed and self-hosted capability coverage requires its own evidence. See the -[canonical preparation rules](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/CONTRIBUTING.md#shared-runtime-capability-preparation). +[canonical preparation rules](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md#shared-runtime-capability-preparation). ## Contract inventory @@ -249,7 +249,7 @@ from Session and Turn status. ## Ownership and placement decision -The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/CONTRIBUTING.md#environment-ownership-and-placement) +The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md#environment-ownership-and-placement) keep Environment lifecycle common while leaving process placement and native transport to adapters. Logical ownership does not require a machine per object. @@ -273,4 +273,4 @@ native history independently of disposable compute, or prove native restoration; never treat an Environment ID as a filesystem or history backup. Do not silently move an existing Session away from its bound device. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environments.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environments.md) diff --git a/apps/docs/content/docs/execution-model.mdx b/apps/docs/content/docs/execution-model.mdx index 449056642..5fe1f60d6 100644 --- a/apps/docs/content/docs/execution-model.mdx +++ b/apps/docs/content/docs/execution-model.mdx @@ -10,7 +10,7 @@ Agents API independently with their own Project keys. The management backend, `AdminClient` and the React console built on them are implemented. The [design principles](/concepts) define identity and authority. -The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md) defines exact +The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) defines exact routes, payloads, pagination and audit records. ## Request boundaries @@ -106,6 +106,6 @@ comes from the deployment's allocations; the hosted Runtime total counts hosted observation records across projects and reported lifecycle states. These sources have different coverage and refresh independently, so the console does not infer resource retention or cleanup from their difference. -Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/CONTRIBUTING.md). +Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/web/architecture.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/architecture.md) diff --git a/apps/docs/content/docs/hosted-providers.mdx b/apps/docs/content/docs/hosted-providers.mdx index d340c7904..53ba8ee54 100644 --- a/apps/docs/content/docs/hosted-providers.mdx +++ b/apps/docs/content/docs/hosted-providers.mdx @@ -10,7 +10,7 @@ With E2B you need no nodes. Self-hosted executors, which applications run for th own Sessions, are a different thing; see [Self-hosted executors](/self-hosted-execution). You add a node by generating a command in Web and pasting it on the host. The -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the node protocol, manual registration, placement and failure handling. ## Before you add a node @@ -187,7 +187,7 @@ The installation ID is in the command (`--installation-id`) and on the **System* 1. In Web, open **Nodes** and choose **Remove node** on the node's page, or **Remove** in its list row, then **Confirm removal**. Core refuses while the node still holds sandboxes, snapshots or pending cleanup; let them finish, or - [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) + [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) through the Core API. Removal is permanent: the host can come back only as a new node. 2. Web then shows **Clean up the host** with the uninstall command. Run it on the host: @@ -290,7 +290,7 @@ files. Shared daemon images remain; a host administrator may remove them only af confirming no installation still needs them. Microsandbox image cleanup remains scoped to the installation's private native store and verified ownership receipts. -The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) describes reset and API behavior. Same-team E2B edits have a separate online Core API path and do not involve node installation. @@ -303,4 +303,4 @@ convert or delete it. Preserve the old installation, Runtime resources and histo provision a fresh node separately through **Add node**. The current installer does not adopt historical resources or remove another installation's state. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/getting-started/nodes.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/nodes.md) diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 733d82293..4159e280e 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -19,10 +19,10 @@ applications call Core with. | [API reference](/public-api) | Developers | The `/v1`, `/core/v1` and `/api/v1` namespaces and their contracts | Deeper references: the -[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md) reference for operators, the -[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md), and -[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/maintainers.md) for building distributions +[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md), and +[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/maintainers.md) for building distributions and running Core without the installer. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/getting-started/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/README.md) diff --git a/apps/docs/content/docs/install.mdx b/apps/docs/content/docs/install.mdx index 0de90041e..c52a6237f 100644 --- a/apps/docs/content/docs/install.mdx +++ b/apps/docs/content/docs/install.mdx @@ -140,7 +140,7 @@ A deployment runs its sandboxes on exactly one backend: | `none` | Nothing yet | Choose the backend on the **Nodes** page in Web | The Standard sizes come from the bundle's copy of Web's -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/apps/web/src/features/sandbox/standard-sizes.json). The +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/apps/web/src/features/sandbox/standard-sizes.json). The installer saves the choice through the same administrator API that Web uses once the services are healthy. If Core refuses it, for example because E2B rejects the key, the installer prints Core's message and exits with an error; the services keep @@ -164,7 +164,7 @@ it installs anything: ``` Prepare the template build with the -[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/deploy/e2b/README.md). To change the backend or +[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/deploy/e2b/README.md). To change the backend or the sandbox size later, use the Nodes page in Web; see [Nodes](/hosted-providers#change-the-sandbox-backend-or-size). @@ -531,4 +531,4 @@ missing images, restores a missing `oac` command, copies node files newly placed `--install-dir`. It refuses a bundle from another release; see [installation version policy](/troubleshooting#installation-version-policy). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/getting-started/install.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/install.md) diff --git a/apps/docs/content/docs/observability.mdx b/apps/docs/content/docs/observability.mdx index 82936a2c3..658c0b304 100644 --- a/apps/docs/content/docs/observability.mdx +++ b/apps/docs/content/docs/observability.mdx @@ -7,7 +7,7 @@ Status: Phase 2 and initial Core Web consumption implemented. The current-snapsh `packages/agents-client` projection, and generated `core.openapi.yaml` contract are implemented and consumed by the Dashboard through complete Session/observation identity joins. Durable history uses the separate optional -[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside +[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside this phase. These are administrator reads under `/core/v1`, authenticated by the Core key, @@ -92,7 +92,7 @@ Authorization: Bearer ... ``` This returns the same object shape as a list item's `observation`, without the -administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md)). It never starts a Turn, creates +administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md)). It never starts a Turn, creates an Environment, provisions compute, renews a lease, or changes lifecycle state. A valid `environment:none` Session returns `200` with status `unsupported`; the @@ -193,7 +193,7 @@ Use the existing Agents API error envelope. | 400 | `invalid_request_error` / `invalid_request_error` | List: a repeated supported query key, or an empty or invalid limit or order, with the shared Beta list messages. Unknown list query keys are ignored. | | 400 | `invalid_request_error` / `unsupported_parameter` | Single-Session retrieval with any query parameter. | | 401 | `invalid_request_error` / `invalid_admin_key` | Missing or invalid Core key. | -| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | +| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | | 500 | `server_error` / `internal_error` | Integrity, ownership, or invalid provider evidence. | | 503 | `server_error` / `execution_unavailable` | Required Runtime observation service is not configured, or list collection exceeded its request budget. | @@ -273,10 +273,10 @@ deletion makes the candidate incomplete and prevents publication. - Token usage: use existing Session/Turn Usage. - Billing and cost: product/backend concern. - Historical series in these routes: the optional capability uses the separate - [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime-history-api.md). + [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-history-api.md). - Container logs and command output. - Provider credentials or native configuration. - Start, stop, pause, resume, restart, renew, or delete operations. - Idle classification and automatic shutdown. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime-observability-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-observability-api.md) diff --git a/apps/docs/content/docs/public-api.mdx b/apps/docs/content/docs/public-api.mdx index ccaa3e302..964db7ded 100644 --- a/apps/docs/content/docs/public-api.mdx +++ b/apps/docs/content/docs/public-api.mdx @@ -9,8 +9,8 @@ no credential works in another namespace. | Namespace | Caller | Credential | Contents | Reference | | --- | --- | --- | --- | --- | | `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`) | [Public API](/sessions) | -| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/core.openapi.yaml) | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime.openapi.yaml) | +| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core.openapi.yaml) | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime.openapi.yaml) | A Project API key gets 401 on `/core/v1` and `/api/v1`; the Core key gets 401 on `/v1` and `/api/v1`. Projects own assets. Multiple equally privileged keys share @@ -34,7 +34,7 @@ with the Core key on Core's loopback port, not through the public entry. Applications call every `/v1` route with a Project API key. The routes are exactly the 58 method and path pairs in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/upstream-routes.json); the +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream-routes.json); the [Public API guide](/sessions) groups them by resource. `/v1` has no Core-only route. @@ -45,18 +45,18 @@ with the Core key. | Routes | Contents | Contract | | --- | --- | --- | -| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/execution-configuration.md) | -| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/session-diagnostics.md) | +| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/execution-configuration.md) | +| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/session-diagnostics.md) | | `projects/{project_id}/sessions/{session_id}/runtime-observation`, `sandbox/runtime-observations` | Current Runtime observations | [Runtime observations](/observability) | -| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime-history-api.md) | -| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-executor-credentials.md) | -| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/installation.md) | -| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/core-metrics.md) | -| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/node-host-history.md) | -| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/model-execution.md#deployment-defaults) | +| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-history-api.md) | +| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md) | +| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/installation.md) | +| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core-metrics.md) | +| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/node-host-history.md) | +| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md#deployment-defaults) | ## Machine connection API @@ -65,42 +65,42 @@ the Core key or a Project API key. | Routes | Caller | Credential | Contract | | --- | --- | --- | --- | -| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | -| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-executor-credentials.md) | -| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/services/agents-api/README.md#user-managed-runtime-enrollment) | +| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | +| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md) | +| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/README.md#user-managed-runtime-enrollment) | ## Contract sources -- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/upstream.json): OpenAI +- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream.json): OpenAI Python SDK 3.13.0, exact upstream commit and `agents=v1`. -- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/upstream-routes.json) and - [fields](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/upstream-fields.json): extracted from the +- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream-routes.json) and + [fields](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream-fields.json): extracted from the pinned SDK by `scripts/extract-agents-api-upstream.py`. Contract tests require the public OpenAPI to have exactly these routes and to keep every other field inside `x_agents_core`. -- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/openapi.yaml): public schema snapshot; - combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/operation-evidence.md). -- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/core.openapi.yaml): generated `/core/v1` +- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/openapi.yaml): public schema snapshot; + combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/operation-evidence.md). +- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core.openapi.yaml): generated `/core/v1` routes, all authenticated by the Core key. -- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/runtime.openapi.yaml): - generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/node-generation-protocol.md); the private +- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime.openapi.yaml): + generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/node-generation-protocol.md); the private daemon transport is described in the Runtime credential guide. -- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md): Project/key +- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md): Project/key lifecycle, resources, explicit hosted Session archive, summary, errors/deletion preconditions, audit and historical copy provenance. - [Web integration](/admin-api): browser, console and Core boundaries and frontend handoff. -- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/CONTRIBUTING.md): +- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md): ownership, security and change requirements. Generated schemas do not establish complete compatibility or real execution -support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md) identifies +support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md) identifies qualified workflows, native differences and unresolved behavior. Update the relevant contract and this index when adding or moving an API surface. -Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/core-errors.md), +Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core-errors.md), including typed optional safe details and distinct console proxy rejection codes. The public and machine error contracts remain unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/api/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/api/README.md) diff --git a/apps/docs/content/docs/quickstart.mdx b/apps/docs/content/docs/quickstart.mdx index d95f2b79f..49a88bfca 100644 --- a/apps/docs/content/docs/quickstart.mdx +++ b/apps/docs/content/docs/quickstart.mdx @@ -101,8 +101,8 @@ The provider's protocol must match the harness: | Claude Code (`claude_sdk`) | `anthropic` | The provider's exact model ID | | MiniMax Code (`mcode`) | `anthropic` | `context_window` and `max_output_tokens` | -See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/harness-selection.md) and -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/model-execution.md) for the full contract. +See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/harness-selection.md) and +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md) for the full contract. Selecting a harness doesn't make an unsupported model or operation work. ## Model providers @@ -142,8 +142,8 @@ not replay history. Don't submit the same work as a new Session when a response lost. Files, Artifacts, cancellation and their current limits are in the -[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md). MCP credentials use the +[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md). MCP credentials use the separate Vault API; they are neither Project API keys nor model provider keys. The [API index](/public-api) lists every route. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/getting-started/quickstart.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/quickstart.md) diff --git a/apps/docs/content/docs/self-hosted-execution.mdx b/apps/docs/content/docs/self-hosted-execution.mdx index 016fe6077..a724057ff 100644 --- a/apps/docs/content/docs/self-hosted-execution.mdx +++ b/apps/docs/content/docs/self-hosted-execution.mdx @@ -136,7 +136,7 @@ create entries in the public API-managed installation arrays. Closing an executor, cancelling a Turn or losing its connection preserves the snapshot and workspace. The compute owner remains responsible for explicit resource cleanup. This source-level capability behavior requires its own real acceptance; -[earlier qualification](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/user-managed-runtime-v1.md) covered +[earlier qualification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md) covered only its recorded inputs and binaries, not local directory preparation or additional platforms. @@ -185,7 +185,7 @@ key_id=$(python3 -c 'import uuid; print(uuid.uuid4())'); echo "credential ID: $k Rotate with `{"key_id":"…","rotate":true}` on the same route; revoke with `DELETE …/executor-credentials/`. After an uncertain response, list the credentials with `GET` before trying again. The -[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-executor-credentials.md) +[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md) has every rule and error. @@ -201,4 +201,4 @@ continuation or automatic data deletion. Credential replacement for executors created by the current installer updates and restarts the same owned container. It does not authorize adopting an older executor. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/getting-started/self-hosted.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/self-hosted.md) diff --git a/apps/docs/content/docs/sessions.mdx b/apps/docs/content/docs/sessions.mdx index b3fc8a131..9b4751d65 100644 --- a/apps/docs/content/docs/sessions.mdx +++ b/apps/docs/content/docs/sessions.mdx @@ -34,8 +34,8 @@ leaves the others valid; archiving the Project revokes all its keys. The optiona ## Public resources Paths are relative to `/v1`. Detailed fields, union types and response shapes are -in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/openapi.yaml), fixed SDK source -and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/README.md#upstream-resource-inventory) +in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/openapi.yaml), fixed SDK source +and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#upstream-resource-inventory) lists each operation and its coverage. | Resource | Route family | Semantics | @@ -44,11 +44,11 @@ lists each operation and its coverage. | Sessions and Turns | `/agents/sessions`, nested `/turns` | Admission, metadata, state and recovery queries | | Items and events | Session `/items`, `/events` | Persisted history and live SSE | | Artifacts | Session `/artifacts` | Captured output metadata, immutable content and deletion | -| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/subagents.md) | -| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-files.md) | -| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-templates.md) | -| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/source-files.md), distinct from live workspace files | -| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/file-resource-semantics.md) | +| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/subagents.md) | +| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-files.md) | +| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-templates.md) | +| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/source-files.md), distinct from live workspace files | +| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/file-resource-semantics.md) | | Vaults and Credentials | `/vaults`, nested `/credentials` | [Scoped write-only credential storage](/environments-and-files) | SSE is live, not a historical replay service. After a disconnect, query the Session, @@ -58,14 +58,14 @@ native execution. Do not automatically replay uncertain tool or file effects. ## Core extension fields The `/v1` route set is exactly the pinned SDK's operations, listed in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/upstream-routes.json); Core adds +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream-routes.json); Core adds no route. It adds fields only inside `x_agents_core`, because it runs several harnesses and accepts custom model access: | Field | Where | Reference | | --- | --- | --- | -| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/harness-selection.md) | -| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/model-execution.md) | +| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/harness-selection.md) | +| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md) | Any other member of `x_agents_core` is rejected with 400. Deployment, placement, credential issuance and operational reads are not part of `/v1`; they belong to @@ -78,7 +78,7 @@ never to `self_hosted`; `none` Sessions accept only the deployment default. `openai_hosted` and `self_hosted` Sessions must end up with one: otherwise creation returns 400 with code `model_provider_required` and param `x_agents_core.model_provider`, and nothing is written. See -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/model-execution.md). +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md). An application reads a Session's model from `agent.model` and an explicitly selected harness from `agent.x_agents_core.harness`; a Session on the deployment @@ -90,8 +90,8 @@ The deployment chooses E2B or its own Docker/microsandbox nodes. A public `self_hosted` Environment uses caller-owned compute and the same colocated Runtime. For a `self_hosted` Session, the deployment operator issues the Environment's executor credential with the Core key; see -[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/environment-executor-credentials.md). +[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md). Our daemon transport and the supported native harness differences are explicit; they do not imply interoperability with OpenAI's stock executor transport. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/api/public-agent-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/api/public-agent-api.md) diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx index a74fc28fe..b1d4fe576 100644 --- a/apps/docs/content/docs/troubleshooting.mdx +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -123,7 +123,7 @@ refuses. `rotate-core-key` refuses to run on a Web-only installation. ## Projects and API keys Create Projects and issue their keys in Web, on **Projects and keys**, or with the -[Core API](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's +[Core API](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's plaintext is shown once; Core stores only its digest. All keys of a Project share its assets and execution principal, and each write records the key that made it. @@ -241,9 +241,9 @@ Core records which key made each public resource write, for the console. Set `core.write_audit_retention` in `config.json` (Go duration, at least `1h`, default `2160h`) to control how long non-creation history is kept; creation ownership is kept for good. Removing keys or resources does not delete these records. See the -[query contract](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/write-audit.md); administrator mutations +[query contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/write-audit.md); administrator mutations have a separate -[audit log](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither +[audit log](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither logs bodies or secrets. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05/docs/getting-started/operations.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/operations.md) diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 1748e9e47..b5aa27c92 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -1,5 +1,5 @@ { - "revision": "8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05", + "revision": "e35d3c9eda2cdb717fd4c3627404d362cc2e4dca", "sources": { "docs/getting-started/README.md": "4eb3d21e073b25e2e6ffb8800e13b33032104ba44d8d93ea3e59394549dc7962", "docs/design-principles.md": "71285ff0b9a7796481b9fe42f7c4b71d0377c767157980ca6386792d9ca8951b", @@ -22,40 +22,40 @@ "apps/docs/scripts/guides.json": "1f6036e3cccaf5a71cdd9ea790be6f395acdf871c4552c490b6747957a10b174" }, "outputs": { - "content/docs/index.mdx": "206b07e7a3e4ffd5d45932115c8e759093fddf8ba8511981e9a6de7e8b9f998f", + "content/docs/index.mdx": "d710539d3e0b5951cbf5b980142c31feda939dda9c39a38915045efd3492ecf6", "content/docs/index.zh.mdx": "5bdd41893946f1d229eb4cf1599a97f2e38cb9e09722d2a69f62053376ac88a9", - "content/docs/concepts.mdx": "8914eb598c35bf1e78574d5825e7f084e26ec514cd65a6aa7a45d63f738bac40", + "content/docs/concepts.mdx": "c7eb1d9cbddeabb8b132db37b825eed24456e7729d2784b78f3cd4f88a216fae", "content/docs/concepts.zh.mdx": "c60644f395dbac5ee607cdb053249e05ec8352d28de97ef1c38d6c1e4443bbb1", - "content/docs/execution-model.mdx": "47f7455632528121f8aa03ba76853b573914ba10e0c5c0d9915432acdb74b499", + "content/docs/execution-model.mdx": "cfc5d4245a7eaf693022717dc34e004a65467d1c30fe0f57de1835a569344365", "content/docs/execution-model.zh.mdx": "9976a9203c6a83d49c2c753e3a8528a369078e87aeb88850654da948ac8c17e1", - "content/docs/install.mdx": "e9b2b182e4d2e7482dba56cd5c618a3536457b805581b7243dea51cb3f4b8a23", + "content/docs/install.mdx": "fbcc7f893e29e5a03a26cdcb5e51b71569a23df5d2a8d268bcba4eb35b836126", "content/docs/install.zh.mdx": "f207607f0cfceef2012a4e772fc347ef254486327584289fee1c85414dffc907", - "content/docs/configure.mdx": "57a0e13a139bec2c5974ce1f388822034cac23c5e592790a439ed64d47d5e5e7", + "content/docs/configure.mdx": "d3c6bd0c0c5fe76725a57acc1681f66bf6eb3f642cbaf296ce40d5ea45d24d01", "content/docs/configure.zh.mdx": "1c4b149c332ade6f2eeca52319e3e7ccfaac3213db2fd084d66e2da517b6bbd6", - "content/docs/bootstrap-projects-keys.mdx": "2436939c5f43f5e792b3f2464b56b2e5f3a33a7d6334938bff6426cc8a465cd5", + "content/docs/bootstrap-projects-keys.mdx": "da60daed343138acaf5d961b72ca670c18171371d6e791883b170fd233461e41", "content/docs/bootstrap-projects-keys.zh.mdx": "509b4dcea4c865d6834bcaef78baa681180d48274931230f958a39e46011c88f", - "content/docs/quickstart.mdx": "15feda8a29813de9d109118ef3cb12615747593a295e8df2097f022a4e8f0e49", + "content/docs/quickstart.mdx": "0afc29fc97b166b11356343e67c4bb802b14cdc22d29d7c691683fc24f2d8a14", "content/docs/quickstart.zh.mdx": "c5f119b100b102918a45763834d0ed7fd436479fb411e16a62a408411dbcb3e1", - "content/docs/public-api.mdx": "1d0db1d288637cfd48c0681faad57b9a45501d364cd4dd3cb0fc64b2d0f2ea3b", + "content/docs/public-api.mdx": "53492062e7647ebbf39f469bb0ade267769cd08eabe73d9f42e0b9870356b4d8", "content/docs/public-api.zh.mdx": "fb0efb9be2fba3f4a3dc71bf053d2c7eddffbfe010f59fa5a1ae60a3f068545b", - "content/docs/agents-and-tools.mdx": "03bea6348bef93793a37f8bcd4a3ac3c190dfa21172a3793b89bdf11fa044a9d", + "content/docs/agents-and-tools.mdx": "efbfee0d551c3f362f900affde6fe2721910fabc11c85841e981d0a60655a5a5", "content/docs/agents-and-tools.zh.mdx": "cc8d4e0ef0ea894f068f3bbd143d5ee70c6346bf98f425b1d5fe07ec3931f128", - "content/docs/sessions.mdx": "f81fae4eedbe2978a1849d622a522827a74ad06e9ec5761c13373d5c38f85310", + "content/docs/sessions.mdx": "57328e8fb2aa645cb8334b6e9a8eb677e4123b5505027d2d6add5c8e6d030b38", "content/docs/sessions.zh.mdx": "b1b1a7f8aacd9a902320f9692173ede24036124f85761cf02f23ef3ef2bcc4c1", - "content/docs/environments-and-files.mdx": "d8b132b2531cab5b8311d3b7bd8c0863a3a8508563f5953f39faf8a55afc87f6", + "content/docs/environments-and-files.mdx": "cd5ca9579ef981b9e7c2d37ebec89d9e9d15283964334d74efaaf118bbd5c09c", "content/docs/environments-and-files.zh.mdx": "ee64577220a798f023b330c65302c14438f25a34a78c4e36144bfdb4304a3ae5", - "content/docs/hosted-providers.mdx": "4aa653df6af336971e916250377297acb6671183a34e3f8d036f068b46e66cb7", + "content/docs/hosted-providers.mdx": "de6ece49be3db64e5e5b223737673c86107e001dd540b228bb43ee2b02094a32", "content/docs/hosted-providers.zh.mdx": "d7f6115f052a09dc943f0ed516ff30a27da5847ed24d387d9d1c7e7faa006ab0", - "content/docs/self-hosted-execution.mdx": "7413d12dba239a471f8e5d5c1296deb3ab33d2bf07452ff8639f713dba753a4b", + "content/docs/self-hosted-execution.mdx": "b7d8c2424b02229527465b144f62059ca5c09dfe90474869ba57ce576d994e7f", "content/docs/self-hosted-execution.zh.mdx": "f11d34ce30d48039376256474bc9509a508a2f6b9906f49b5cf6e396707ae5c0", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", - "content/docs/console.mdx": "b1a6c89bf81ccd06ad290f9f7425ac0bc88ec6d2a450185f17a53a2e46de5845", + "content/docs/console.mdx": "40bc03f4ad3334c7b7d1e141827cd6eaf51144f34532007cfe7925c2d6cb2a82", "content/docs/console.zh.mdx": "72f953ba46dcc95933773ac0c4dffdc0d0a499be9d9a1cb9467db2659f1fa10d", - "content/docs/admin-api.mdx": "dee0a195cd40b0f9903ec0f23cfd47a75c192d44805e9c2fc6db5a4199e47052", + "content/docs/admin-api.mdx": "73c8f57db4e0131e3af1442a84dd61337622188f16d5c685b18b39750a1000b7", "content/docs/admin-api.zh.mdx": "07006de30df3080e54c3cd79ed0ea5337dce557aa2f11afea84edb7f92884d38", - "content/docs/observability.mdx": "53fbc9a5891234b0a6cfd5b44ba038d72bdb3f4d2bab06be0f704969d1aacd4d", + "content/docs/observability.mdx": "3d9f0891a7efb0a35a5cd7ba9db97d364511ae1e70ceecbdb7f815957909bc4b", "content/docs/observability.zh.mdx": "9574b3412a5754f41ab2d3a16062ac1cad06766be481a8e6bff1b84acbde7e7e", - "content/docs/troubleshooting.mdx": "1e28d2d05ded1d0661c1c806973563328f652502598465354e8504f967837c1e", + "content/docs/troubleshooting.mdx": "a87de7639962d7830a2c5905d69439eb17f762f87d7cdd4df1dbedc71278e7a9", "content/docs/troubleshooting.zh.mdx": "6d8b4668e8c371120a9275daabbebc0d1ecf21a42ba32beeb92853c14b5a459a" } } diff --git a/apps/docs/scripts/generate-guides.mjs b/apps/docs/scripts/generate-guides.mjs index 348073832..f403d7a78 100644 --- a/apps/docs/scripts/generate-guides.mjs +++ b/apps/docs/scripts/generate-guides.mjs @@ -8,7 +8,7 @@ const app = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const repo = path.resolve(app, '../..') const guides = JSON.parse(fs.readFileSync(path.join(app, 'scripts/guides.json'), 'utf8')) const routes = new Map(guides.map(g => [g.source, g.slug === 'index' ? '/' : '/' + g.slug])) -const sourceRevision = '8f64eb5458f0b784beb9d1dddb7a5ab4e8434a05' +const sourceRevision = 'e35d3c9eda2cdb717fd4c3627404d362cc2e4dca' const sourceURL = relative => `https://github.com/MiniMax-AI/parsar-core/blob/${sourceRevision}/${relative}` const digest = text => crypto.createHash('sha256').update(text).digest('hex') function mdx(text) { From da39b9e6868f12c2095ddb01bab8aba00735c2aa Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 16:44:33 +0000 Subject: [PATCH 27/78] Document retained Session identity in the name guard --- scripts/name-allowlist.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/name-allowlist.json b/scripts/name-allowlist.json index 37e679122..37c3f3d0d 100644 --- a/scripts/name-allowlist.json +++ b/scripts/name-allowlist.json @@ -843,5 +843,15 @@ "path": "apps/parsar-daemon/internal/dispatch/executor_test.go", "regex": "\"agents-api-(?:session)?\"", "reason": "Executor ownership validates the current Core and local-workspace AgentStateKey namespace; this change does not introduce an alternate identity or compatibility path." + }, + { + "path": "apps/parsar-daemon/internal/localworkspace/capabilities.go", + "regex": "\"agents-api-\"", + "reason": "AgentStateKey retains the existing daemon/native-session resume identity; capability snapshots bind to the Session UUID carried in that identity." + }, + { + "path": "apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go", + "regex": "\"agents-api-\"", + "reason": "AgentStateKey retains the existing daemon/native-session resume identity; capability snapshots bind to the Session UUID carried in that identity." } ] From 06f9c71cd9b6dd0690f95b64f13ce646cd3be9cb Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 16:52:03 +0000 Subject: [PATCH 28/78] Expose custom Runtime capability roots to isolated initialization --- .../runtime_initialization_linux.go | 2 +- .../agents-api/deploy/runtime/initialize.py | 31 +++++++++++++++---- .../deploy/runtime/initialize_receipt_test.py | 28 +++++++++++++++-- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go index 48f708066..34603e821 100644 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go @@ -28,7 +28,7 @@ func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInit if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { return agentcapabilities.ErrInvalid } - result, err := runInitializationProcess(ctx, "/usr/bin/python3", []string{"-I", "-S", MCPInitializer, "initialize", b.workspace}, bytes.NewReader(raw)) + result, err := runInitializationProcess(ctx, "/usr/bin/python3", []string{"-I", "-S", MCPInitializer, "initialize", b.workspace, b.capabilityRoot}, bytes.NewReader(raw)) if err != nil { return err } diff --git a/services/agents-api/deploy/runtime/initialize.py b/services/agents-api/deploy/runtime/initialize.py index aa7ae851a..901501885 100644 --- a/services/agents-api/deploy/runtime/initialize.py +++ b/services/agents-api/deploy/runtime/initialize.py @@ -107,7 +107,7 @@ def sandbox(network, cwd, workspace=None): return args + ['--chdir', cwd, '--'] -def run(request, workspace=None): +def run(request, workspace=None, capability_root=None): action = request['action'] if action == 'configure': configure(request.get('env', {})) @@ -116,6 +116,7 @@ def run(request, workspace=None): runpy.run_path('/usr/local/bin/oac-tool-root')['install'](request['packages'], workspace=workspace or str(ROOT / 'workspace')) return args = sandbox(request['network'], request.get('cwd', '/workspace'), workspace=workspace) + expose_capabilities(args, capability_root) if action == 'setup': command = request['command'] if not isinstance(command, str) or not command or '\x00' in command: @@ -210,6 +211,23 @@ def bind_parent(): os.close(parent_fd) +def expose_capabilities(args, capability_root): + """Expose only the Runtime-owned capability tree, including custom layouts.""" + if capability_root is None: + return + path = Path(capability_root) + if (not capability_root.startswith('/') or capability_root == '/' + or os.path.normpath(capability_root) != capability_root + or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in capability_root) + or path.resolve() != path): + raise ValueError('invalid Runtime capability directory') + # Configuration may precede the first bundle import or empty finalization. + if path.exists(): + if not path.is_dir(): + raise ValueError('invalid Runtime capability directory') + args[-1:-1] = ['--ro-bind', capability_root, capability_root] + + def stdio(installation_root, workspace, package, server): """Preserve the native MCP descriptors while entering the existing sandbox.""" for value in (installation_root, workspace): @@ -224,15 +242,16 @@ def stdio(installation_root, workspace, package, server): helper = '/tmp/oac-mcp-exec' # System-package roots predate daemon installation. Mount only the fixed # static helper, never native configuration, credentials or Runtime state. - args[-1:-1] = ['--ro-bind', '/usr/local/bin/oac-daemon', helper, '--ro-bind', installation_root, installation_root] + expose_capabilities(args, installation_root) + args[-1:-1] = ['--ro-bind', '/usr/local/bin/oac-daemon', helper] args += [helper, 'runtime-mcp-exec', installation_root, package, server] return stdio_lifetime(args) def main(): - workspace = None - if len(sys.argv) == 3 and sys.argv[1] == 'initialize': - workspace = sys.argv[2] + workspace, capability_root = None, None + if len(sys.argv) == 4 and sys.argv[1] == 'initialize': + workspace, capability_root = sys.argv[2:] elif len(sys.argv) != 1: try: if len(sys.argv) != 6 or sys.argv[1] != 'stdio': @@ -259,7 +278,7 @@ def main(): run(request) else: roots(workspace) - run(request, workspace=workspace) + run(request, workspace=workspace, capability_root=capability_root) except Exception as error: print(failed_receipt(error)) return 1 diff --git a/services/agents-api/deploy/runtime/initialize_receipt_test.py b/services/agents-api/deploy/runtime/initialize_receipt_test.py index 99e239fb2..2a6b51f3d 100644 --- a/services/agents-api/deploy/runtime/initialize_receipt_test.py +++ b/services/agents-api/deploy/runtime/initialize_receipt_test.py @@ -119,19 +119,43 @@ def test_private_initialization_uses_bound_workspace_and_empty_environment(self) workspace = str(Path(directory).resolve()) stdin = mock.Mock() stdin.buffer = io.BytesIO(b'{"version":1,"action":"configure"}') - with mock.patch.object(initialize.sys, 'argv', ['initialize', 'initialize', workspace]), \ + with mock.patch.object(initialize.sys, 'argv', ['initialize', 'initialize', workspace, workspace + '/caps']), \ mock.patch.object(initialize.sys, 'stdin', stdin), \ mock.patch.object(initialize, 'roots') as roots, \ mock.patch.object(initialize, 'run') as run, \ contextlib.redirect_stdout(io.StringIO()) as output: self.assertEqual(initialize.main(), 0) roots.assert_called_once_with(workspace) - run.assert_called_once_with({'version': 1, 'action': 'configure'}, workspace=workspace) + run.assert_called_once_with({'version': 1, 'action': 'configure'}, workspace=workspace, capability_root=workspace + '/caps') self.assertEqual(json.loads(output.getvalue()), {'version': 1, 'outcome': 'completed'}) with mock.patch.object(initialize, 'configure') as configure: initialize.run({'action': 'configure'}, workspace=workspace) configure.assert_called_once_with({}) + def test_setup_mounts_custom_capabilities_read_only(self): + with tempfile.TemporaryDirectory() as directory: + base = Path(directory).resolve() + capabilities = base / 'capabilities' + capabilities.mkdir() + for system in (False, True): + with self.subTest(system=system), \ + mock.patch.object(initialize, 'sandbox', return_value=['system-root' if system else 'bwrap', '--']), \ + mock.patch.object(initialize.subprocess, 'run') as execute: + initialize.run({'action': 'setup', 'network': 'enabled', 'command': 'true'}, + workspace=str(base / 'workspace'), capability_root=str(capabilities)) + args = execute.call_args.args[0] + self.assertIn(['--ro-bind', str(capabilities), str(capabilities)], + [args[i:i+3] for i in range(len(args)-2)]) + self.assertLess(args.index(str(capabilities)), args.index('--')) + alias = base / 'alias' + alias.symlink_to(capabilities, target_is_directory=True) + for invalid in ('relative', '/', str(alias), str(base / 'capabilities' / '..')): + with self.assertRaises(ValueError): + initialize.expose_capabilities(['bwrap', '--'], invalid) + args = ['bwrap', '--'] + initialize.expose_capabilities(args, str(base / 'not-installed')) + self.assertEqual(args, ['bwrap', '--']) + def test_sandboxed_step_reports_only_its_exit_status(self): for status in (1, 3, 100, 255): failure = subprocess.CalledProcessError(status, SANDBOXED, output=CANARY.encode(), stderr=CANARY.encode()) From f75045acbc6334df6779d2aa2441e7d64f0acf7c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 28 Sep 2026 16:54:21 +0000 Subject: [PATCH 29/78] Allow read-only Runtime snapshots within protected MiniMax directories --- packages/mcode-harness/launch.mjs | 2 +- packages/mcode-harness/snapshot.test.mjs | 74 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 packages/mcode-harness/snapshot.test.mjs diff --git a/packages/mcode-harness/launch.mjs b/packages/mcode-harness/launch.mjs index 994252762..81fb52b44 100644 --- a/packages/mcode-harness/launch.mjs +++ b/packages/mcode-harness/launch.mjs @@ -29,7 +29,7 @@ try { mkdirSync(profile.scratch,{recursive:true}); await SandboxManager.initialize({ network:{allowedDomains:domains,deniedDomains:profile.network==='disabled'?['*']:[],allowAll:profile.network==='enabled',strictAllowlist:true,allowAllUnixSockets:false,allowLocalBinding:false}, - filesystem:{denyRead:profile.protectedDirs,allowWrite:[profile.workspace,profile.scratch,...(profile.toolEnvironment ? ['/environment/packages'] : [])],denyWrite:[...(profile.capabilityRoot ? [profile.capabilityRoot] : []),...(profile.systemPackages ? ['/environment/packages/system'] : [])]}, + filesystem:{denyRead:profile.protectedDirs,allowRead:profile.capabilityRoot?[profile.capabilityRoot]:[],allowWrite:[profile.workspace,profile.scratch,...(profile.toolEnvironment ? ['/environment/packages'] : [])],denyWrite:[...(profile.capabilityRoot ? [profile.capabilityRoot] : []),...(profile.systemPackages ? ['/environment/packages/system'] : [])]}, seccomp:{applyPath:join(here,'dist/vendor/seccomp/x64/apply-seccomp')}, },undefined,false); const quote=s=>"'"+s.replaceAll("'","'\\''")+"'"; diff --git a/packages/mcode-harness/snapshot.test.mjs b/packages/mcode-harness/snapshot.test.mjs new file mode 100644 index 000000000..e5463f13d --- /dev/null +++ b/packages/mcode-harness/snapshot.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { copyFile, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; + +// This opt-in test requires an isolated Linux Runtime containing the built +// sandbox/worker artifact. It runs only native tools; no model is contacted. +const artifact = process.env.OAC_TEST_MCODE_NATIVE_ARTIFACT; +const isolated = process.env.OAC_TEST_MCODE_SNAPSHOT_ISOLATED === '1'; +test('capability read exceptions preserve denied ancestors and immutable snapshots', { + skip: !artifact || !isolated, timeout: 60_000, +}, async t => { + const fixture = await mkdtemp(join(tmpdir(), 'oac-mcode-snapshot-')); + t.after(() => rm(fixture, { recursive: true, force: true })); + await copyFile(new URL('./launch.mjs', import.meta.url), join(fixture, 'launch.mjs')); + await symlink(join(artifact, 'dist'), join(fixture, 'dist')); + const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"; + for (const [name, ancestor, root] of [ + ['standard', '/environment', '/environment/initialization/capabilities'], + ['custom', join(fixture, 'private-layout'), join(fixture, 'private-layout', 'frozen', 'skills')], + ]) { + await t.test(name, async t => { + const scratch = join(fixture, name + '-scratch'); + const secret = join(ancestor, 'private', 'snapshot-test-secret'); + await mkdir(root, { recursive: true }); + await mkdir(join(ancestor, 'private'), { recursive: true }); + await mkdir(scratch); + const script = join(root, 'proof.sh'), attachment = join(root, 'attachment.txt'); + await writeFile(script, 'printf SNAPSHOT_SCRIPT_READABLE', { mode: 0o700 }); + await writeFile(attachment, 'SNAPSHOT_ATTACHMENT_READABLE', { mode: 0o600 }); + await writeFile(secret, 'PRIVATE_SNAPSHOT_CANARY', { mode: 0o600 }); + const profile = join(fixture, name + '.json'); + const config = { workspace: '/workspace', scratch, network: 'disabled', + allowedDomains: [], protectedDirs: [ancestor], capabilityRoot: root, skills: true }; + await writeFile(profile, JSON.stringify(config)); + const executor = { execute(tool, input) { + const child = spawnSync(process.execPath, [join(fixture, 'launch.mjs'), profile, '/workspace'], { + input: JSON.stringify({ tool, input }), encoding: 'utf8', timeout: 15_000, + env: { PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/tmp', LANG: 'C.UTF-8' }, + }); + assert.ok(child.stdout, child.stderr || String(child.error)); + return JSON.parse(child.stdout); + } }; + const read = await executor.execute('bash', { + command: 'sh ' + quote(script) + '; cat ' + quote(attachment), + }); + assert.notEqual(read.isError, true, read.text); + assert.ok(read.text.includes('SNAPSHOT_SCRIPT_READABLE')); + assert.ok(read.text.includes('SNAPSHOT_ATTACHMENT_READABLE')); + const protectedResult = await executor.execute('bash', { + command: [ + 'if printf changed >> ' + quote(attachment) + '; then exit 41; fi', + 'if chmod 0777 ' + quote(script) + '; then exit 42; fi', + 'if touch ' + quote(join(root, 'new-sibling')) + '; then exit 43; fi', + 'if cat ' + quote(secret) + '; then exit 44; fi', + 'printf SNAPSHOT_PROTECTION_OK', + ].join('; '), + }); + assert.notEqual(protectedResult.isError, true); + assert.ok(protectedResult.text.includes('SNAPSHOT_PROTECTION_OK')); + assert.ok(!protectedResult.text.includes('PRIVATE_SNAPSHOT_CANARY')); + assert.equal(await readFile(attachment, 'utf8'), 'SNAPSHOT_ATTACHMENT_READABLE'); + assert.equal((await stat(script)).mode & 0o777, 0o700); + await assert.rejects(stat(join(root, 'new-sibling')), { code: 'ENOENT' }); + // Negative control proves that the ancestor deny really masks this root. + await writeFile(profile, JSON.stringify({ ...config, capabilityRoot: undefined, skills: false })); + const hidden = await executor.execute('bash', { command: 'cat ' + quote(attachment) }); + assert.equal(hidden.isError, true); + assert.ok(!hidden.text.includes('SNAPSHOT_ATTACHMENT_READABLE')); + }); + } +}); From 460861951c1d184220351d166fbe3a8df73ec338 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 17:03:13 +0000 Subject: [PATCH 30/78] Align Codex preparation fixture with frozen Runtime configuration --- .../internal/agent/codex/preparation_router_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go index 45eb0e8a3..b3410f785 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go @@ -4,9 +4,11 @@ import ( "context" "errors" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" "github.com/google/uuid" "os" + "path/filepath" "testing" "time" @@ -30,6 +32,10 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { for _, start := range []bool{false, true} { t.Run(map[bool]string{false: "disconnect-before-start", true: "transfer-and-cancel"}[start], func(t *testing.T) { req, cfg, root := preparationFixture(t) + if err := os.Chmod(root, 0700); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_CAPABILITY_DIRECTORY", filepath.Join(t.TempDir(), "capabilities")) t.Setenv("OAC_TEST_EXECUTOR_MODE", "complete") environment, session := uuid.NewString(), uuid.NewString() if err := os.MkdirAll(req.WorkDir, 0700); err != nil { @@ -51,7 +57,7 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { req.WorkDir = "" req.AgentStateKey = "agents-api-" + session req.DisableExecutionEnvironment = false - req.LocalEnvironment = &proto.LocalEnvironment{ID: environment, NetworkAccess: "enabled"} + req.LocalEnvironment = &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", NetworkAccess: "enabled", CapabilitySources: &agentcapabilities.Input{}} cfg.permissionProfile = "managed-workspace" cfg.runtimeNetwork = agentnetwork.Policy{Access: "enabled"} registry := agent.NewRegistry() From 98155113dfdfc31b55f24bb7cdeed10dbee16ffb Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 17:22:08 +0000 Subject: [PATCH 31/78] Exercise node initialization isolation through the Runtime peer --- .../store/runtime_compute_lifecycle_test.go | 13 +++ .../store/runtime_initialization_peer_test.go | 80 +++++++++++-------- .../runtime_node_lifecycle_fixture_test.go | 50 +++++++----- .../store/runtime_node_lifecycle_test.go | 7 +- 4 files changed, 91 insertions(+), 59 deletions(-) diff --git a/services/agents-api/internal/store/runtime_compute_lifecycle_test.go b/services/agents-api/internal/store/runtime_compute_lifecycle_test.go index 15bf70eb1..a5deba40b 100644 --- a/services/agents-api/internal/store/runtime_compute_lifecycle_test.go +++ b/services/agents-api/internal/store/runtime_compute_lifecycle_test.go @@ -26,6 +26,7 @@ import ( // The controlled provider records external effects independently of DB phases. // Lost replies retain those effects so recovery must use observation, not replay. type fakeCheckpointProvider struct { + preparation *initializationPeer lifecycleProvider computes map[string]sandbox.ComputeState snapshots map[string]sandbox.SnapshotIdentity @@ -191,11 +192,23 @@ func (p *fakeCheckpointProvider) connect(ctx context.Context, b sandbox.Bootstra } go func() { defer conn.Close() + transfer := initializationTransfer{peer: p.preparation} for { var env proto.Envelope if conn.ReadJSON(&env) != nil { return } + if env.Type == proto.TypeRuntimePrepare && p.preparation != nil { + reply, err := transfer.receive(env) + if err != nil { + p.preparation.t.Error(err) + return + } + if conn.WriteJSON(reply) != nil { + return + } + continue + } if env.Type == proto.TypeDeviceShutdown { return } diff --git a/services/agents-api/internal/store/runtime_initialization_peer_test.go b/services/agents-api/internal/store/runtime_initialization_peer_test.go index 786f6f0c8..cdb9f5368 100644 --- a/services/agents-api/internal/store/runtime_initialization_peer_test.go +++ b/services/agents-api/internal/store/runtime_initialization_peer_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "net/http" "sync/atomic" "testing" @@ -42,8 +43,7 @@ func (p *initializationPeer) connect(b sandbox.Bootstrap) error { } p.t.Cleanup(func() { _ = c.Close() }) go func() { - var request proto.RuntimePreparePayload - var data []byte + transfer := initializationTransfer{peer: p} for { var env proto.Envelope if c.ReadJSON(&env) != nil { @@ -52,41 +52,12 @@ func (p *initializationPeer) connect(b sandbox.Bootstrap) error { if env.Type != proto.TypeRuntimePrepare { continue } - var frame proto.RuntimePreparePayload - if env.DecodePayload(&frame) != nil || !proto.ValidRuntimePrepareRequest(frame) { - p.t.Error("invalid Runtime frame") + reply, err := transfer.receive(env) + if err != nil { + p.t.Error(err) return } - result := proto.RuntimePrepareResultPayload{} - switch frame.Step { - case "begin": - request = frame - data = nil - result.Outcome = "ready" - case "chunk": - if frame.Offset != len(data) { - p.t.Error("unordered initialization bytes") - return - } - data = append(data, frame.Data...) - result.Outcome = "received" - result.Offset = len(data) - case "commit": - if request.Action == "file" || request.Action == "skill" || request.Action == "plugin" { - sum := sha256.Sum256(data) - if request.SizeBytes != len(data) || request.SHA256 != hex.EncodeToString(sum[:]) { - p.t.Error("initialization digest changed") - return - } - } - result = p.apply(request, data) - p.writes.Add(1) - if result.Outcome == "completed" { - result.SizeBytes = len(data) - } - } - reply, err := proto.NewEnvelope(proto.TypeRuntimePrepareResult, env.ID, result) - if err != nil || c.WriteJSON(reply) != nil { + if c.WriteJSON(reply) != nil { return } } @@ -107,3 +78,42 @@ func (p *initializationPeer) RunCommand(context.Context, sandbox.Reference, sand func completedInitialization(proto.RuntimePreparePayload, []byte) proto.RuntimePrepareResultPayload { return proto.RuntimePrepareResultPayload{Outcome: "completed"} } + +// Each authenticated socket owns its own bounded preparation transfer. +type initializationTransfer struct { + peer *initializationPeer + request proto.RuntimePreparePayload + data []byte +} + +func (x *initializationTransfer) receive(env proto.Envelope) (proto.Envelope, error) { + var frame proto.RuntimePreparePayload + if env.DecodePayload(&frame) != nil || !proto.ValidRuntimePrepareRequest(frame) { + return proto.Envelope{}, errors.New("invalid Runtime frame") + } + result := proto.RuntimePrepareResultPayload{} + switch frame.Step { + case "begin": + x.request, x.data = frame, nil + result.Outcome = "ready" + case "chunk": + if frame.Offset != len(x.data) { + return proto.Envelope{}, errors.New("unordered initialization bytes") + } + x.data = append(x.data, frame.Data...) + result.Outcome, result.Offset = "received", len(x.data) + case "commit": + if x.request.Action == "file" || x.request.Action == "skill" || x.request.Action == "plugin" { + sum := sha256.Sum256(x.data) + if x.request.SizeBytes != len(x.data) || x.request.SHA256 != hex.EncodeToString(sum[:]) { + return proto.Envelope{}, errors.New("initialization digest changed") + } + } + result = x.peer.apply(x.request, x.data) + x.peer.writes.Add(1) + if result.Outcome == "completed" { + result.SizeBytes = len(x.data) + } + } + return proto.NewEnvelope(proto.TypeRuntimePrepareResult, env.ID, result) +} diff --git a/services/agents-api/internal/store/runtime_node_lifecycle_fixture_test.go b/services/agents-api/internal/store/runtime_node_lifecycle_fixture_test.go index 638d2013d..3208ee543 100644 --- a/services/agents-api/internal/store/runtime_node_lifecycle_fixture_test.go +++ b/services/agents-api/internal/store/runtime_node_lifecycle_fixture_test.go @@ -5,10 +5,8 @@ import ( "context" "encoding/json" "errors" - "fmt" "net/http" "net/http/httptest" - "strconv" "strings" "sync" "sync/atomic" @@ -17,6 +15,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" @@ -70,28 +69,21 @@ func (p *nodeIsolationProvider) GetCompute(ctx context.Context, r sandbox.Refere return p.fakeCheckpointProvider.GetCompute(ctx, r, c) } func (p *nodeIsolationProvider) RunCommand(ctx context.Context, r sandbox.Reference, c sandbox.Command) (sandbox.CommandResult, error) { - if err := p.block(ctx, r, "initialize"); err != nil { - return sandbox.CommandResult{}, err - } - size, err := strconv.Atoi(c.Args[len(c.Args)-1]) - if err != nil || len(c.Stdin) != size+32 { - return sandbox.CommandResult{}, sandbox.ErrInvalid - } - p.writes.Add(1) - return sandbox.CommandResult{Stdout: fmt.Sprintf("{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":%d}", size)}, nil + return p.preparation.RunCommand(ctx, r, c) } type nodeIsolationFixture struct { - t *testing.T - store *store.Store - pool *pgxpool.Pool - worker *execution.Worker - provider *nodeIsolationProvider - key, nodeA, nodeB string - epoch uint64 - runCancel context.CancelFunc - runDone chan error - stopOnce sync.Once + t *testing.T + store *store.Store + pool *pgxpool.Pool + worker *execution.Worker + provider *nodeIsolationProvider + key, nodeA, nodeB string + epoch uint64 + initializationCancel context.CancelFunc + runCancel context.CancelFunc + runDone chan error + stopOnce sync.Once } func newNodeIsolationFixture(t *testing.T, mode string) *nodeIsolationFixture { @@ -105,6 +97,19 @@ func newNodeIsolationFixture(t *testing.T, mode string) *nodeIsolationFixture { registry := gateway.NewRegistry() cp := &fakeCheckpointProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}, computes: map[string]sandbox.ComputeState{}, snapshots: map[string]sandbox.SnapshotIdentity{}, bootstraps: map[string]sandbox.Bootstrap{}, peers: map[string]*websocket.Conn{}, registry: registry} p := &nodeIsolationProvider{fakeCheckpointProvider: cp, blocked: map[string]bool{}, mode: mode, entered: make(chan struct{})} + preparationContext, cancelPreparation := context.WithCancel(t.Context()) + t.Cleanup(cancelPreparation) + cp.preparation = &initializationPeer{t: t, apply: func(request proto.RuntimePreparePayload, data []byte) proto.RuntimePrepareResultPayload { + if err := p.block(preparationContext, sandbox.Reference{EnvironmentID: request.EnvironmentID}, "initialize"); err != nil { + return proto.RuntimePrepareResultPayload{Outcome: "unknown", ErrorCode: "runtime_preparation_unconfirmed"} + } + if request.Action != "file" || request.File.Path != "/workspace/seed" || string(data) != "retained" { + t.Error("unexpected node initialization request") + return proto.RuntimePrepareResultPayload{Outcome: "failed", ErrorCode: "runtime_preparation_failed"} + } + p.writes.Add(1) + return completedInitialization(request, data) + }} handler := gateway.NewHandler(gateway.HandlerConfig{Authenticator: gateway.NewAuthenticator(s), Registry: registry}) server := httptest.NewServer(http.HandlerFunc(handler.WS)) cp.endpoint = "ws" + strings.TrimPrefix(server.URL, "http") @@ -116,7 +121,7 @@ func newNodeIsolationFixture(t *testing.T, mode string) *nodeIsolationFixture { cp.mu.Unlock() server.Close() }) - f := &nodeIsolationFixture{t: t, store: s, pool: pool, provider: p, key: uuid.NewString(), nodeA: uuid.NewString(), nodeB: uuid.NewString()} + f := &nodeIsolationFixture{initializationCancel: cancelPreparation, t: t, store: s, pool: pool, provider: p, key: uuid.NewString(), nodeA: uuid.NewString(), nodeB: uuid.NewString()} // Keep restored compute awake throughout the isolation assertions. // The suspension setup explicitly dates its activity two minutes in the past. policy := &execution.RuntimeSuspensionPolicy{IdleTimeout: time.Minute, Retention: time.Hour, MaxActive: 100, MaxRetained: 100} @@ -252,6 +257,7 @@ func (f *nodeIsolationFixture) run() { } func (f *nodeIsolationFixture) stop() { f.stopOnce.Do(func() { + f.initializationCancel() if f.runDone == nil { ctx, cancel := context.WithCancel(context.Background()) cancel() diff --git a/services/agents-api/internal/store/runtime_node_lifecycle_test.go b/services/agents-api/internal/store/runtime_node_lifecycle_test.go index 385d1d76d..3ba6bdfa6 100644 --- a/services/agents-api/internal/store/runtime_node_lifecycle_test.go +++ b/services/agents-api/internal/store/runtime_node_lifecycle_test.go @@ -45,7 +45,7 @@ func TestManagedNodesIsolateBlockedProviderAndInitialization(t *testing.T) { select { case <-f.provider.entered: case <-time.After(8 * time.Second): - t.Fatal("node A did not enter blocked provider") + t.Fatalf("node A did not enter blocked %s operation", mode) } online, err := f.store.RuntimeNodeAvailable(t.Context(), f.nodeA) if err != nil || !online { @@ -53,7 +53,7 @@ func TestManagedNodesIsolateBlockedProviderAndInitialization(t *testing.T) { } // Same-node direct callers and the scan all consume the same one-shot - // allocation receipt. A's in-flight provider operation remains blocked. + // allocation receipt. A's provider or Runtime operation remains blocked. tenant, _, env := f.session(f.nodeB, true) f.provider.mu.Lock() before := f.provider.creates @@ -120,6 +120,9 @@ func TestManagedNodesIsolateBlockedProviderAndInitialization(t *testing.T) { f.provider.mu.Lock() restores := f.provider.restores f.provider.mu.Unlock() + if f.provider.preparation.commandCalls.Load() != 0 { + t.Fatal("initialization invoked Provider.RunCommand") + } if restores != 1 || f.provider.promptFrames.Load() != 0 { t.Fatal("restore replayed or lifecycle sent model work") } From ed43316a3f9ce3f50c2aee46ea426d6407fc6554 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 17:49:33 +0000 Subject: [PATCH 32/78] Define the SandboxProvider onboarding contract and shared failure tests --- CONTRIBUTING.md | 19 ++ Makefile | 5 + README.md | 1 + docs/sandbox-provider.md | 183 ++++++++++++++++++ .../cmd/server/managed_generations.go | 2 +- .../agents-api/cmd/server/managed_setup.go | 4 +- .../archive_cancellation_cleanup_test.go | 2 +- .../internal/execution/runtime_adoption.go | 2 +- .../internal/execution/runtime_lifecycle.go | 4 +- .../runtime_retirement_failure_test.go | 2 +- .../execution/sandbox_deployment_setup.go | 2 +- .../sandbox_provider_contract_test.go | 54 ++++++ .../internal/sandbox/config/config.go | 2 +- .../internal/sandbox/contracttest/provider.go | 96 +++++++++ .../internal/sandbox/docker/contract_test.go | 140 ++++++++++++++ .../internal/sandbox/docker/provider.go | 2 +- .../internal/sandbox/docker/provider_test.go | 8 +- .../internal/sandbox/e2b/contract_test.go | 57 ++++++ .../internal/sandbox/e2b/provider.go | 2 +- .../sandbox/microsandbox/contract_test.go | 72 +++++++ .../internal/sandbox/microsandbox/provider.go | 2 +- .../agents-api/internal/sandbox/node/agent.go | 4 +- .../internal/sandbox/node/connection_test.go | 2 +- .../internal/sandbox/node/generations.go | 4 +- .../internal/sandbox/node/observations.go | 2 +- .../sandbox/node/observations_test.go | 4 +- .../agents-api/internal/sandbox/node/proxy.go | 8 +- .../agents-api/internal/sandbox/node/wire.go | 2 +- .../agents-api/internal/sandbox/provider.go | 63 ------ .../internal/sandbox/sandbox_provider.go | 106 ++++++++++ .../agents-api/internal/sandbox/suspension.go | 17 -- .../internal/store/runtime_lifecycle_test.go | 4 +- 32 files changed, 763 insertions(+), 114 deletions(-) create mode 100644 docs/sandbox-provider.md create mode 100644 services/agents-api/internal/execution/sandbox_provider_contract_test.go create mode 100644 services/agents-api/internal/sandbox/contracttest/provider.go create mode 100644 services/agents-api/internal/sandbox/docker/contract_test.go create mode 100644 services/agents-api/internal/sandbox/e2b/contract_test.go create mode 100644 services/agents-api/internal/sandbox/microsandbox/contract_test.go delete mode 100644 services/agents-api/internal/sandbox/provider.go create mode 100644 services/agents-api/internal/sandbox/sandbox_provider.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bcc4d162..919d92543 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,6 +59,25 @@ isolation remain separate work. the pinned public API, or authorize unrelated refactors or live Session configuration switching. +### Sandbox Provider contract + +The canonical Sandbox Provider entry point is +[`sandbox_provider.go`](services/agents-api/internal/sandbox/sandbox_provider.go), +with integration and operation semantics in [the onboarding guide](docs/sandbox-provider.md). +Use `sandbox.SandboxProvider` for compute lifecycle and bounded bootstrap operations; +checkpoint and read-only observation remain separate optional interfaces. +Registration/construction owns vendor configuration. Core's common lifecycle +selects optional behavior by interface capability, not a provider name. + +Retain the persisted allocation identity and cleanup responsibility after unknown +results. Never replay Create or initialization commands on transport failure. +Compute running, authenticated Runtime connection, prepared capabilities and Turn +acceptance are separate facts. A confirmed Kill cannot by itself settle an +outstanding Create. Capability preparation belongs to Runtime, and releasing an +executor does not reclaim its Environment. Do not add provider-specific execution +or preparation paths. New adapters must run the shared Sandbox Provider contract +suite through their native boundary and qualify their real resource behavior. + ## Workflow and quality Develop in an isolated worktree on a feature branch and submit a PR. Do not edit diff --git a/Makefile b/Makefile index d72d18bdd..6cac8dd95 100644 --- a/Makefile +++ b/Makefile @@ -153,3 +153,8 @@ check-e2b-provider: .PHONY: check-docs check-docs: node-deps pnpm check:docs + +# These packages are also exercised by check-agents-api in the full gate. +.PHONY: check-sandbox-provider-contract +check-sandbox-provider-contract: + go test ./services/agents-api/internal/sandbox/... -count=1 diff --git a/README.md b/README.md index c8f255455..8867ba767 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ native differences. | [Nodes and sandbox backends: operator reference](services/agents-api/HOSTED-SANDBOX-MANAGER.md) | Node protocol, manual registration, placement, reset | | [Protocol coverage](contracts/agents-api/README.md) and [harness selection](contracts/agents-api/harness-selection.md) | Supported operations and native differences | | [Add a Harness](contracts/agents-api/harness-onboarding.md) | Required adapter interfaces, lifecycle, capabilities, registration and acceptance | +| [Add a Sandbox Provider](docs/sandbox-provider.md) | Required lifecycle, optional capabilities, registration, ownership and contract tests | | [Landing page source](site/index.html) | The public site | ### Maintainers and advanced deployments diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md new file mode 100644 index 000000000..1a96727a1 --- /dev/null +++ b/docs/sandbox-provider.md @@ -0,0 +1,183 @@ +# Add a Sandbox Provider + +Start at [`sandbox_provider.go`](../services/agents-api/internal/sandbox/sandbox_provider.go). +It defines the required `SandboxProvider` and optional `CheckpointProvider` +interfaces. Use the existing types; do not introduce another lifecycle protocol or +an execution path for a particular vendor. + +Core owns durable Environment, allocation, placement and cleanup state. A Sandbox +Provider supplies compute and the bootstrap needed to start its Runtime (daemon). +Runtime prepares capabilities and workspaces through the common daemon protocol; +Harness adapters translate execution into native operations. The same Runtime +preparation and execution contract applies to a user-owned machine. That machine +has no Core-owned compute allocation to create or destroy. + +Sandbox isolation belongs to the outer infrastructure. A daemon running directly +on a user's machine runs with that user's authority; installing the daemon does +not establish a filesystem, tool or network sandbox. Provider isolation and +network enforcement must be qualified independently of daemon connectivity. + +## What to implement + +| Contract | Requirement | Responsibility | +| --- | --- | --- | +| `sandbox.SandboxProvider` | Required | `Create`, `GetInfo`, `Renew`, `Kill`, and bounded bootstrap/diagnostic `RunCommand` | +| `sandbox.CheckpointProvider` | Optional, separate interface | Exact compute incarnations, snapshot capture/restore, retained-source resume and cleanup | +| `runtimeobs.Source` | Optional, separate interface | Read-only, ownership-checked resource observations | +| `runtimeobs.BatchSource` | Optional, separate interface | Bounded observations in input order, with per-target errors; `ok=false` means no batch read occurred | + +Do not implement an optional interface with successful no-op methods. Core uses +interface assertions to select optional operations. Advertised support requires +contract and native acceptance evidence; a healthy node or an available CLI does +not establish it. Observation never renews a lease, starts compute or prepares a +Harness. See the [observation contract](../contracts/agents-api/runtime-observability.md). + +`RunCommand` is an existing administrative bootstrap/diagnostic facility, not an +alternate route for Skills, Plugins, MCP setup, initial files, Session execution or +Files. Those use Runtime. Confidential command input travels in `Command.Stdin`, +not argv or logs. Preserve its byte order, bounded output and actual exit status. + +## Register an adapter + +Registration is explicit construction, not an init-time plugin registry: + +1. Put native SDK calls, ownership checks, identity translation and private + configuration in an adapter package under `services/agents-api/internal/sandbox`. + Assert `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)` at compile time. +2. For a node-local adapter, add its typed construction and readiness probe in + [`sandbox/config`](../services/agents-api/internal/sandbox/config/config.go). + The existing `Built` result returns the provider, probe, installation identity, + backend fingerprint and specification digest; the factory also returns its + close function. Construction must not create an Environment. +3. For a direct adapter, register its construction in + [`managedSetup.provider`](../services/agents-api/cmd/server/managed_setup.go). + Node-local adapters are selected there through the existing node proxy. Keep + vendor-specific deployment validation and SDK setup at this boundary. +4. Preserve the existing `execution.RuntimeProvider` deployment binding: + `ProviderKind`, installation ID, backend fingerprint, generation, mode and + node ownership identify the selected backend. The database owns the selection; + the in-memory selection is not an alternate authority. Register optional + interfaces on direct adapters and their transport wrappers consistently. +5. Qualify the provider's operator configuration and resource limits in the + existing deployment specification/enrollment path. The shipped deployment + parser and persistence constraints explicitly enumerate supported backends; + update that registration surface when adding one. Do not bypass it with an + arbitrary provider string. This is deployment admission, not a new execution + workflow. Adding a new public capability requires its own protocol change. + +Today Docker and microsandbox use nodes; E2B is constructed directly. The node +proxy exposes checkpoint operations only for its registered checkpoint-capable +backend. A new node backend must register both construction and the corresponding +proxy capability; otherwise that capability is unavailable. Provider names belong +in this adapter/configuration wiring, not Session scheduling, capability +preparation or Turn execution. Common lifecycle code uses `CheckpointProvider` +to admit suspension, independently of a provider name. + +The backend fingerprint identifies a native resource namespace, not mutable +capacity. Core retains deployment generations so old owned allocations continue +to resolve to their original backend. This is resource ownership, not historical +binary compatibility. Do not repoint retained allocations at a replacement backend. + +## Identity and resource responsibility + +`Reference` is the exact `(TenantID, EnvironmentID, AllocationID)` tuple. Core +persists a fresh allocation ID **before** `Create`; it is not the Environment ID. +The adapter also binds resources to its installation. Do not locate or authorize +resources by a bare native ID, display name, guessed path or an unverified label. +All mutations, reads and cleanup must verify the same ownership. + +Core serializes lifecycle operations and retains the allocation after any +uncertain mutation. An adapter must preserve enough native identity/receipts for +observation and cleanup. A failed call may return both `Info` and an error: +preserve reference-bound settlement evidence without converting failure to success. +Adapters must not silently create replacement resources, overwrite credentials or +switch to a new allocation after a conflict. + +`Info.ProviderID` and `Info.State` describe compute. Core recognizes `running` +only with the matching Reference and a native identity. Other native states can +be observed without claiming readiness. `BootstrapComplete` says the bootstrap +reached its final mutating step. `CreateSettled` proves the original attempt can +no longer mutate resources; it does not mean success. In particular: + +- `State="absent"` plus matching Reference and `CreateSettled=true` is an explicit + creation-absence receipt, with no native ID or completed bootstrap. +- `ErrNotFound`, an empty listing, a timeout or a successful `Kill` alone is not + proof that an in-flight create cannot appear later. +- Core can also settle creation from a matching running resource with completed + bootstrap. It must retain unknown creation until it has such evidence or an + explicit receipt, even if a cleanup attempt currently sees no resources. + +`Kill` owns cleanup of the allocation's compute and retained resources, including +partial bootstrap storage. It must not remove another tenant's resource on a +name collision. Core releases durable ownership only after confirmed cleanup +**and** settled creation. Executor release or Harness cancellation does not delete +an Environment, its workspace or its Sandbox Provider allocation. + +## Operation outcomes and retries + +All calls receive bounded contexts. Expiry or cancellation ends the caller's +wait; it does not prove rollback, native stop, cleanup or absence. An adapter or +transport must not detach untracked mutations or replay a timed-out command. + +| Operation | Confirmed result | Failure or unknown result | Recovery | +| --- | --- | --- | --- | +| `Create` | Matching compute and bootstrap evidence; execution still needs Runtime preparation | Invalid/foreign configuration rejects; duplicate returns `ErrExists`; transport failure may hide created resources | Observe the original Reference. Never replay `Create`, even with a new credential. Preserve partial resources for owned cleanup. | +| `GetInfo` | Current compute observation without mutation | `ErrNotFound` is only a missing observation; an error is not absence proof | Repeat a bounded read; never turn it into create/start/renew. | +| `Renew` | Existing native lease extended, or an observation for a provider without leases | Timeout may hide a lease extension; stopped/missing compute stays stopped/missing | Observe then let the normal reconciler renew the same allocation. Never revive it or fabricate a lease expiry. | +| `Kill` | Owned compute and retained storage removed; repeated confirmed absence succeeds | Error retains ownership and cleanup intent; ownership mismatch must not delete foreign resources | Retry cleanup of the same Reference after outstanding creation/mutation is fenced. Do not release the durable owner early. | +| `RunCommand` | Collected output and actual exit code; nonzero exit is a settled command failure | Missing native completion is `ErrCommandUnconfirmed`; partial output is not success | Do not replay. Preserve the owner and reclaim before reuse when completion cannot be proved. | + +Use `ErrInvalid`, `ErrOwnership`, `ErrExists`, `ErrNotFound`, +`ErrComputeUnconfirmed` and `ErrCommandUnconfirmed` for their existing meanings. +An unclassified native/transport error is conservatively unknown, not permission +to retry a mutation. Core must not interpret provider diagnostics as lifecycle +truth or expose native error text/credentials. The node boundary maps errors to +fixed codes; direct SDK details stay private. + +Checkpoint support adds `Compute` generation/name/ID and `SnapshotIdentity`. +Persist operation IDs and provider-returned snapshot provenance unchanged. +`ObserveOnly` on suspend/resume observes the previous attempt and must not start +another capture or restore. `ResumeCompute` only thaws the retained source; it +must not cold-start a stopped one. Cleanup targets the exact compute incarnation +and snapshot, not whichever instance currently has the same display name. See +[the lifecycle implementation](../services/agents-api/internal/execution/runtime_compute.go) +and its failure tests before advertising this capability. + +## Four distinct readiness facts + +| Fact | Evidence | Does not establish | +| --- | --- | --- | +| Compute available | Provider observation for the owned allocation | Authenticated Runtime connection or prepared capabilities | +| Runtime connected | Gateway authentication and exact Environment/device binding | Completed Runtime preparation or a usable Harness | +| Capabilities prepared | Successful common Runtime preparation with the fixed configuration/snapshot | Acceptance or completion of a Turn | +| Execution admitted | Qualified Harness capabilities and the existing executor/Turn acceptance path | A completed input, cancellation or reclaimed compute | + +For both hosted and self-hosted environments, Core uses the daemon preparation +and execution protocol. Core resolves configuration and supplies resources; +Runtime installs/reads local capabilities and freezes the result for the Session. +Reconnection can reuse that prepared content; a new Session takes a new snapshot. +A provider must not implement a competing preparation path. + +## Validate the integration + +Run `make check-sandbox-provider-contract` while developing. It runs the shared +[`contracttest`](../services/agents-api/internal/sandbox/contracttest) suite through +real adapter boundaries using controlled native failures, plus existing adapter +and node transport tests. The same packages are included in `make check`. +New adapters should call the public failure runner with native-side fixtures, +not substitute a fake implementation of `SandboxProvider` for the adapter under +test. Preserve tests for foreign ownership, unknown mutation results, cancellation, +no automatic replay, failed cleanup and reference-bound settlement. + +Node tests separately exercise disconnect/reconnect fencing and cleanup after a +lost create response. Provider helper protocols and the node protocol require an +exact version match and reject mismatches; do not add fallback decoders or old +binary migration. Direct in-process interfaces have no independent wire version. + +Run `make check` with its dedicated database before completion. Retain native +acceptance for SDK behavior that fixtures cannot prove: creation, lease behavior, +owned partial cleanup, declared isolation/limits, and snapshots where supported. +The opt-in Docker lifecycle/recovery tests use `AGENTS_RUNTIME_DOCKER_TEST_IMAGE`; +SDK helper tests use `make check-e2b-provider` and +`make check-microsandbox-provider`. Passing controlled contract tests is not a +claim of live cloud or model acceptance. diff --git a/services/agents-api/cmd/server/managed_generations.go b/services/agents-api/cmd/server/managed_generations.go index 310addfc2..ff978435d 100644 --- a/services/agents-api/cmd/server/managed_generations.go +++ b/services/agents-api/cmd/server/managed_generations.go @@ -27,7 +27,7 @@ type e2bGenerationRouter struct { store generationStore } -func (p *e2bGenerationRouter) route(ctx context.Context, r sandbox.Reference) (sandbox.Provider, func(), error) { +func (p *e2bGenerationRouter) route(ctx context.Context, r sandbox.Reference) (sandbox.SandboxProvider, func(), error) { release, err := p.setup.e2bCalls.Enter(ctx) if err != nil { return nil, nil, err diff --git a/services/agents-api/cmd/server/managed_setup.go b/services/agents-api/cmd/server/managed_setup.go index df7f21c4e..19b0399c6 100644 --- a/services/agents-api/cmd/server/managed_setup.go +++ b/services/agents-api/cmd/server/managed_setup.go @@ -133,7 +133,7 @@ func (s *managedSetup) configuration(setup store.SandboxSetup) (execution.Prepar } selected := &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Generation: setup.Generation, Mode: setup.Mode, AdmissionPaused: setup.AdmissionPaused, CoreURL: s.publicURL + "/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider} - if setup.Provider == "microsandbox" { + if _, supportsCheckpoint := provider.(sandbox.CheckpointProvider); supportsCheckpoint { selected.Suspension = &execution.RuntimeSuspensionPolicy{IdleTimeout: time.Duration(setup.IdleSeconds) * time.Second, Retention: time.Duration(setup.RetentionSeconds) * time.Second, MaxActive: 4, MaxRetained: 16} } @@ -176,7 +176,7 @@ func (s *managedSetup) Observe(ctx context.Context, target runtimeobs.Target) (r return source.Observe(ctx, target) } -func (s *managedSetup) provider(setup store.SandboxSetup) (sandbox.Provider, error) { +func (s *managedSetup) provider(setup store.SandboxSetup) (sandbox.SandboxProvider, error) { switch setup.Provider { case "docker", "microsandbox": if s.hub == nil { diff --git a/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go b/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go index 4ccfa7374..637e70ef6 100644 --- a/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go +++ b/services/agents-api/internal/execution/archive_cancellation_cleanup_test.go @@ -25,7 +25,7 @@ import ( // Provider callbacks inspect the real database at the instant destructive // cleanup is invoked. They do not create containers or claim native evidence. type waitingCleanupProvider struct { - sandbox.Provider + sandbox.SandboxProvider beforeKill func() } diff --git a/services/agents-api/internal/execution/runtime_adoption.go b/services/agents-api/internal/execution/runtime_adoption.go index 0fdb2f885..7a425f8f1 100644 --- a/services/agents-api/internal/execution/runtime_adoption.go +++ b/services/agents-api/internal/execution/runtime_adoption.go @@ -11,7 +11,7 @@ import ( // VerifyLegacyRuntimeOwnership uses only positive, read-only Provider observations. // It runs before node placement and never becomes an execution fallback route. -func VerifyLegacyRuntimeOwnership(ctx context.Context, p sandbox.Provider, allocation store.RuntimeAllocation) (err error) { +func VerifyLegacyRuntimeOwnership(ctx context.Context, p sandbox.SandboxProvider, allocation store.RuntimeAllocation) (err error) { failure := "resource_unconfirmed" defer func() { if err == nil { diff --git a/services/agents-api/internal/execution/runtime_lifecycle.go b/services/agents-api/internal/execution/runtime_lifecycle.go index cf00b5d29..113857c08 100644 --- a/services/agents-api/internal/execution/runtime_lifecycle.go +++ b/services/agents-api/internal/execution/runtime_lifecycle.go @@ -35,7 +35,7 @@ type RuntimeProvider struct { CoreURL string InstallationID string BackendFingerprint string - Provider sandbox.Provider + Provider sandbox.SandboxProvider AdmissionPaused bool Suspension *RuntimeSuspensionPolicy } @@ -95,7 +95,7 @@ func validatedRuntimeProvider(config *RuntimeProvider, registry *gateway.Registr if copied.Mode != "" && copied.Mode != "nodes" && copied.Mode != "direct" { return RuntimeProvider{}, sandbox.ErrInvalid } - if copied.Mode == "direct" && (copied.ProviderKind != "e2b" || copied.LocalNodeID != "" || copied.Suspension != nil) { + if copied.Mode == "direct" && (copied.ProviderKind == "" || copied.LocalNodeID != "" || copied.Suspension != nil) { return RuntimeProvider{}, sandbox.ErrInvalid } if config.Suspension != nil { diff --git a/services/agents-api/internal/execution/runtime_retirement_failure_test.go b/services/agents-api/internal/execution/runtime_retirement_failure_test.go index 18096d7ee..8be2b55b6 100644 --- a/services/agents-api/internal/execution/runtime_retirement_failure_test.go +++ b/services/agents-api/internal/execution/runtime_retirement_failure_test.go @@ -217,7 +217,7 @@ func TestFailedInventoryRetirementClosesAdmissionAndRetainsGate(t *testing.T) { } // No provider method is called by the retirement ownership fixture. -type drainFixtureProvider struct{ sandbox.Provider } +type drainFixtureProvider struct{ sandbox.SandboxProvider } // Done is evaluated only after lockMutation's initial admission check, letting // this test close admission while a request is already waiting for its gate. diff --git a/services/agents-api/internal/execution/sandbox_deployment_setup.go b/services/agents-api/internal/execution/sandbox_deployment_setup.go index d31c9ce0f..c2c494c6e 100644 --- a/services/agents-api/internal/execution/sandbox_deployment_setup.go +++ b/services/agents-api/internal/execution/sandbox_deployment_setup.go @@ -111,7 +111,7 @@ func (m *runtimeManager) ensureDeployment(parent context.Context) (bool, error) if err != nil || config == nil { return false, err } - if config.InstallationID != m.setupInstallationID || config.LocalNodeID != "" || config.loadDeployment != nil || (config.ProviderKind != "docker" && config.ProviderKind != "microsandbox" && config.ProviderKind != "e2b") { + if config.InstallationID != m.setupInstallationID || config.LocalNodeID != "" || config.loadDeployment != nil || config.ProviderKind == "" { return false, sandbox.ErrInvalid } copied, err := validatedRuntimeProvider(config, m.registry) diff --git a/services/agents-api/internal/execution/sandbox_provider_contract_test.go b/services/agents-api/internal/execution/sandbox_provider_contract_test.go new file mode 100644 index 000000000..1dfe0a915 --- /dev/null +++ b/services/agents-api/internal/execution/sandbox_provider_contract_test.go @@ -0,0 +1,54 @@ +package execution + +import ( + "context" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/google/uuid" +) + +// A trusted registration is sufficient for the common deployment loader. Public +// configuration admission still validates its explicitly registered backend. +func TestSandboxProviderRegistrationDoesNotRequireAnExecutionVendorBranch(t *testing.T) { + for _, mode := range []string{"nodes", "direct"} { + t.Run(mode, func(t *testing.T) { + id := uuid.NewString() + config := &RuntimeProvider{InstallationID: id, ProviderKind: "contract-fixture", Mode: mode, + CoreURL: "https://core.example/api/v1", BackendFingerprint: strings.Repeat("a", 64), Provider: &lifecycleOnlySandbox{}} + m, err := newRuntimeManager(nil, gateway.NewRegistry(), NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return config, nil })) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { m.stop(); m.drain() }) + ready, err := m.ensureDeployment(t.Context()) + if err != nil || !ready { + t.Fatalf("registered provider cannot enter common lifecycle: %v", err) + } + if m.config.Provider != config.Provider || m.config.ProviderKind != config.ProviderKind || m.config.Mode != mode { + t.Fatal("registration identity changed") + } + }) + } +} + +// Methods must not run during registration; the loader only binds an adapter. +type lifecycleOnlySandbox struct{} + +func (*lifecycleOnlySandbox) Create(context.Context, sandbox.Bootstrap) (sandbox.Info, error) { + panic("registration created compute") +} +func (*lifecycleOnlySandbox) GetInfo(context.Context, sandbox.Reference) (sandbox.Info, error) { + panic("registration read compute") +} +func (*lifecycleOnlySandbox) Renew(context.Context, sandbox.Reference) (sandbox.Info, error) { + panic("registration renewed compute") +} +func (*lifecycleOnlySandbox) Kill(context.Context, sandbox.Reference) error { + panic("registration killed compute") +} +func (*lifecycleOnlySandbox) RunCommand(context.Context, sandbox.Reference, sandbox.Command) (sandbox.CommandResult, error) { + panic("registration executed command") +} diff --git a/services/agents-api/internal/sandbox/config/config.go b/services/agents-api/internal/sandbox/config/config.go index 880208ed9..8f9d68e34 100644 --- a/services/agents-api/internal/sandbox/config/config.go +++ b/services/agents-api/internal/sandbox/config/config.go @@ -65,7 +65,7 @@ func Load(file string) (Config, error) { type Built struct { SpecificationDigest string - Provider sandbox.Provider + Provider sandbox.SandboxProvider InstallationID, BackendFingerprint string Probe func(context.Context) error } diff --git a/services/agents-api/internal/sandbox/contracttest/provider.go b/services/agents-api/internal/sandbox/contracttest/provider.go new file mode 100644 index 000000000..f72a8ee79 --- /dev/null +++ b/services/agents-api/internal/sandbox/contracttest/provider.go @@ -0,0 +1,96 @@ +// Package contracttest runs shared lifecycle assertions against real adapters +// backed by controlled native transports. It is test support only. +package contracttest + +import ( + "context" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "reflect" + "testing" + "time" +) + +type Fault string + +const ( + TransportFailure Fault = "transport_failure" + Canceled Fault = "canceled_after_dispatch" + ForeignOwnership Fault = "foreign_ownership" + CleanupFailure Fault = "cleanup_failure" +) + +type Scenario struct { + Operation string + Fault Fault +} + +// Fixture exposes the production adapter. Calls records all native requests; +// WantCalls includes legitimate multi-step work before the injected fault. Exact +// comparison detects replay, fallback and implicit cleanup after uncertain Create. +type Fixture struct { + Provider sandbox.SandboxProvider + Bootstrap sandbox.Bootstrap + Calls func() []string + WantCalls []string +} + +// Factory configures native responses. Invoke cancel only after dispatch begins. +type Factory func(t *testing.T, scenario Scenario, cancel context.CancelFunc) Fixture + +// RunFailures requires errors without mandating one native error type. Failed +// or canceled calls must not invent proof of settlement or bootstrap completion. +func RunFailures(t *testing.T, factory Factory) { + t.Helper() + for _, operation := range []string{"create", "inspect", "renew", "kill"} { + faults := []Fault{TransportFailure, Canceled, ForeignOwnership} + if operation == "kill" { + faults = append(faults, CleanupFailure) + } + for _, fault := range faults { + t.Run(operation+"/"+string(fault), func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + f := factory(t, Scenario{operation, fault}, cancel) + var info sandbox.Info + var err error + switch operation { + case "create": + info, err = f.Provider.Create(ctx, f.Bootstrap) + case "inspect": + info, err = f.Provider.GetInfo(ctx, f.Bootstrap.Reference) + case "renew": + info, err = f.Provider.Renew(ctx, f.Bootstrap.Reference) + case "kill": + err = f.Provider.Kill(ctx, f.Bootstrap.Reference) + } + if err == nil { + t.Fatal("native failure was reported as success") + } + if fault == Canceled && ctx.Err() == nil { + t.Fatal("fixture did not cancel the dispatched operation") + } + if info.CreateSettled || info.BootstrapComplete { + t.Fatalf("failed operation invented settlement or bootstrap proof: %+v", info) + } + if info.Reference != (sandbox.Reference{}) && info.Reference != f.Bootstrap.Reference { + t.Fatalf("failure exposed a foreign reference: %+v", info.Reference) + } + if got := f.Calls(); !reflect.DeepEqual(got, f.WantCalls) { + t.Fatalf("native calls = %v, want %v; unexpected replay, mutation or cleanup", got, f.WantCalls) + } + }) + } + } +} + +// AssertObservation checks identity and the expected native state, without +// equating a running process with authenticated Runtime or executor readiness. +func AssertObservation(t *testing.T, got sandbox.Info, err error, reference sandbox.Reference, providerID, state string) { + t.Helper() + if err != nil { + t.Fatal(err) + } + if got.Reference != reference || got.ProviderID == "" || providerID != "" && got.ProviderID != providerID || got.State != state { + t.Fatalf("observation lost identity or native state: %+v", got) + } +} diff --git a/services/agents-api/internal/sandbox/docker/contract_test.go b/services/agents-api/internal/sandbox/docker/contract_test.go new file mode 100644 index 000000000..622f78171 --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/contract_test.go @@ -0,0 +1,140 @@ +package docker + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/contracttest" + "github.com/google/uuid" + "github.com/moby/moby/client" +) + +// The script covers only native calls before a fault. Full Docker creation and +// bootstrap remain covered by the existing opt-in lifecycle and recovery tests. +type contractStep struct { + method, path string + status int + body any + fault contracttest.Fault +} + +func dockerContractFixture(t *testing.T, cancel context.CancelFunc, script func(*Provider, sandbox.Reference) []contractStep) contracttest.Fixture { + t.Helper() + b := sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + var mu sync.Mutex + var calls []string + var steps []contractStep + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/v1.52") + call := r.Method + " " + path + mu.Lock() + index := len(calls) + calls = append(calls, call) + mu.Unlock() + if index >= len(steps) || call != steps[index].method+" "+steps[index].path { + t.Errorf("unexpected native request: %s", call) + http.Error(w, "unexpected request", 500) + return + } + step := steps[index] + switch step.fault { + case contracttest.Canceled: + cancel() + return + case contracttest.TransportFailure: + connection, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + _ = connection.Close() + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(step.status) + if step.body != nil { + _ = json.NewEncoder(w).Encode(step.body) + } + })) + t.Cleanup(server.Close) + c, err := client.New(client.WithHost(server.URL), client.WithAPIVersion("1.52")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + p, err := New(c, Config{InstallationID: uuid.NewString(), Image: "sha256:" + strings.Repeat("a", 64), Network: "bridge", Seccomp: "{}"}) + if err != nil { + t.Fatal(err) + } + steps = script(p, b.Reference) + want := make([]string, len(steps)) + for i, step := range steps { + want[i] = step.method + " " + step.path + } + return contracttest.Fixture{Provider: p, Bootstrap: b, Calls: func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), calls...) + }, WantCalls: want} +} + +func TestProviderContract(t *testing.T) { + contracttest.RunFailures(t, func(t *testing.T, s contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture { + return dockerContractFixture(t, cancel, func(p *Provider, r sandbox.Reference) []contractStep { + inspect := "/containers/" + p.name(r) + "/json" + home, environment := "/volumes/"+p.name(r)+"-home", "/volumes/"+p.name(r)+"-environment" + missing := map[string]string{"message": "not found"} + owned := map[string]any{"Id": "native-owned", "Config": map[string]any{"Labels": p.labels(r)}, "State": map[string]string{"Status": "running"}} + foreign := map[string]any{"Labels": map[string]string{"io.oac.tenant": uuid.NewString()}} + if s.Fault == contracttest.ForeignOwnership { + if s.Operation == "kill" { + // Verify every ownership check precedes any deletion, including volumes. + return []contractStep{{http.MethodGet, inspect, 200, owned, ""}, {http.MethodGet, home, 200, foreign, ""}} + } + return []contractStep{{http.MethodGet, inspect, 200, map[string]any{"Id": "foreign", "Config": foreign}, ""}} + } + switch s.Operation { + case "create": + return []contractStep{ + {http.MethodGet, inspect, 404, missing, ""}, + {http.MethodGet, home, 404, missing, ""}, + {http.MethodGet, environment, 404, missing, ""}, + {http.MethodPost, "/volumes/create", 0, nil, s.Fault}, + } + case "kill": + volume := map[string]any{"Labels": p.labels(r)} + steps := []contractStep{{http.MethodGet, inspect, 200, owned, ""}, {http.MethodGet, home, 200, volume, ""}, {http.MethodGet, environment, 200, volume, ""}} + if s.Fault == contracttest.CleanupFailure { + return append(steps, contractStep{http.MethodDelete, "/containers/native-owned", 204, nil, ""}, contractStep{http.MethodDelete, home, 500, map[string]string{"message": "cleanup failed"}, ""}) + } + return append(steps, contractStep{http.MethodDelete, "/containers/native-owned", 0, nil, s.Fault}) + default: + return []contractStep{{http.MethodGet, inspect, 0, nil, s.Fault}} + } + }) + }) +} + +func TestProviderContractObservation(t *testing.T) { + for _, operation := range []string{"inspect", "renew"} { + t.Run(operation, func(t *testing.T) { + f := dockerContractFixture(t, func() {}, func(p *Provider, r sandbox.Reference) []contractStep { + return []contractStep{{http.MethodGet, "/containers/" + p.name(r) + "/json", 200, map[string]any{"Id": "native-owned", "Config": map[string]any{"Labels": p.labels(r)}, "State": map[string]string{"Status": "exited"}}, ""}} + }) + var got sandbox.Info + var err error + if operation == "renew" { + got, err = f.Provider.Renew(t.Context(), f.Bootstrap.Reference) + } else { + got, err = f.Provider.GetInfo(t.Context(), f.Bootstrap.Reference) + } + contracttest.AssertObservation(t, got, err, f.Bootstrap.Reference, "native-owned", "exited") + }) + } +} diff --git a/services/agents-api/internal/sandbox/docker/provider.go b/services/agents-api/internal/sandbox/docker/provider.go index 4983ca25c..589443553 100644 --- a/services/agents-api/internal/sandbox/docker/provider.go +++ b/services/agents-api/internal/sandbox/docker/provider.go @@ -35,7 +35,7 @@ type Provider struct { config Config } -var _ sandbox.Provider = (*Provider)(nil) +var _ sandbox.SandboxProvider = (*Provider)(nil) func New(c *client.Client, config Config) (*Provider, error) { if c == nil || !validID(config.InstallationID) || (!strings.HasPrefix(config.Image, "sha256:") && !strings.Contains(config.Image, "@sha256:")) || config.Seccomp == "" || config.Network == "" || config.Network == "host" || strings.HasPrefix(config.Network, "container:") { diff --git a/services/agents-api/internal/sandbox/docker/provider_test.go b/services/agents-api/internal/sandbox/docker/provider_test.go index e4f6be5ea..000b6caf2 100644 --- a/services/agents-api/internal/sandbox/docker/provider_test.go +++ b/services/agents-api/internal/sandbox/docker/provider_test.go @@ -14,6 +14,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeobs" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/contracttest" "github.com/containerd/errdefs" "github.com/google/uuid" "github.com/moby/moby/client" @@ -107,12 +108,7 @@ func TestDockerProviderLifecycle(t *testing.T) { } }) info, e := p.Create(ctx, b) - if e != nil { - t.Fatal(e) - } - if info.State != "running" || info.ProviderID == "" { - t.Fatalf("bad compute observation: %+v", info) - } + contracttest.AssertObservation(t, info, e, b.Reference, "", "running") resources, e := p.Observe(ctx, runtimeobs.Target{ TenantID: b.TenantID, SessionID: b.SessionID, EnvironmentID: b.EnvironmentID, Mode: runtimeobs.ModeManaged, Instance: runtimeobs.Instance{AllocationID: b.AllocationID, ProviderKey: installationID, DeviceID: b.DeviceID}, diff --git a/services/agents-api/internal/sandbox/e2b/contract_test.go b/services/agents-api/internal/sandbox/e2b/contract_test.go new file mode 100644 index 000000000..f4d55e827 --- /dev/null +++ b/services/agents-api/internal/sandbox/e2b/contract_test.go @@ -0,0 +1,57 @@ +package e2b + +import ( + "context" + "errors" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/contracttest" + "github.com/google/uuid" + "testing" +) + +type contractCaller func(context.Context, Request) (Response, error) + +func (f contractCaller) Call(ctx context.Context, q Request) (Response, error) { return f(ctx, q) } + +func TestProviderContract(t *testing.T) { + contracttest.RunFailures(t, func(t *testing.T, s contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture { + p, _, r := fixture(t) + b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + var calls []string + p.caller = contractCaller(func(ctx context.Context, q Request) (Response, error) { + calls = append(calls, q.Operation) + if q.Reference != r { + t.Fatal("native request lost allocation identity") + } + switch s.Fault { + case contracttest.Canceled: + cancel() + return Response{}, ctx.Err() + case contracttest.ForeignOwnership: + if s.Operation == "kill" { + return Response{Version: ProtocolVersion, ErrorCode: "ownership"}, nil + } + foreign := r + foreign.AllocationID = uuid.NewString() + return Response{Version: ProtocolVersion, Info: &sandbox.Info{Reference: foreign, ProviderID: "foreign", State: "running", CreateSettled: true, BootstrapComplete: true}}, nil + case contracttest.CleanupFailure: + return Response{Version: ProtocolVersion, ErrorCode: "unconfirmed"}, nil + default: + return Response{}, errors.New("lost helper response") + } + }) + return contracttest.Fixture{Provider: p, Bootstrap: b, Calls: func() []string { return calls }, WantCalls: []string{s.Operation}} + }) +} + +func TestProviderContractObservation(t *testing.T) { + p, f, r := fixture(t) + f.response.Info = &sandbox.Info{Reference: r, ProviderID: "native-owned", State: "running", CreateSettled: true, BootstrapComplete: true} + b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + got, err := p.Create(bounded(t), b) + contracttest.AssertObservation(t, got, err, r, "native-owned", "running") + got, err = p.GetInfo(bounded(t), r) + contracttest.AssertObservation(t, got, err, r, "native-owned", "running") + got, err = p.Renew(bounded(t), r) + contracttest.AssertObservation(t, got, err, r, "native-owned", "running") +} diff --git a/services/agents-api/internal/sandbox/e2b/provider.go b/services/agents-api/internal/sandbox/e2b/provider.go index e1ec26507..d6865e284 100644 --- a/services/agents-api/internal/sandbox/e2b/provider.go +++ b/services/agents-api/internal/sandbox/e2b/provider.go @@ -67,7 +67,7 @@ type Provider struct { now func() time.Time } -var _ sandbox.Provider = (*Provider)(nil) +var _ sandbox.SandboxProvider = (*Provider)(nil) func validID(value string) bool { id, err := uuid.Parse(value) diff --git a/services/agents-api/internal/sandbox/microsandbox/contract_test.go b/services/agents-api/internal/sandbox/microsandbox/contract_test.go new file mode 100644 index 000000000..3a11cb15d --- /dev/null +++ b/services/agents-api/internal/sandbox/microsandbox/contract_test.go @@ -0,0 +1,72 @@ +package microsandbox + +import ( + "context" + "errors" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/contracttest" + "github.com/google/uuid" + "testing" +) + +func TestProviderContract(t *testing.T) { + contracttest.RunFailures(t, func(t *testing.T, s contracttest.Scenario, cancel context.CancelFunc) contracttest.Fixture { + c, r := testConfig(), testRef() + b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + var calls []string + p, err := NewWithCaller(c, callerFunc(func(ctx context.Context, q Request) (Response, error) { + calls = append(calls, q.Operation) + if q.Reference != r { + t.Fatal("native request lost allocation identity") + } + switch s.Fault { + case contracttest.Canceled: + cancel() + return Response{}, ctx.Err() + case contracttest.ForeignOwnership: + if s.Operation == "kill" { + return Response{Version: ProtocolVersion, ErrorCode: "ownership"}, nil + } + foreign := r + foreign.AllocationID = uuid.NewString() + return Response{Version: ProtocolVersion, State: &State{Compute: Compute{Name: Name(c, foreign, 0), ID: "foreign"}, Status: "running", BootstrapComplete: true}}, nil + case contracttest.CleanupFailure: + return Response{Version: ProtocolVersion, ErrorCode: "unconfirmed"}, nil + default: + return Response{}, errors.New("lost helper response") + } + })) + if err != nil { + t.Fatal(err) + } + nativeOperation := s.Operation + if nativeOperation == "renew" { + nativeOperation = "inspect" + } + return contracttest.Fixture{Provider: p, Bootstrap: b, Calls: func() []string { return calls }, WantCalls: []string{nativeOperation}} + }) +} + +func TestProviderContractObservation(t *testing.T) { + c, r := testConfig(), testRef() + p, err := NewWithCaller(c, callerFunc(func(_ context.Context, q Request) (Response, error) { + if q.Operation != "inspect" && q.Operation != "create" { + t.Fatal("observation mutated compute") + } + status := "stopped" + if q.Operation == "create" { + status = "running" + } + return Response{Version: ProtocolVersion, State: &State{Compute: Compute{Name: Name(c, r, 0), ID: "native-owned"}, Status: status}}, nil + })) + if err != nil { + t.Fatal(err) + } + b := sandbox.Bootstrap{Reference: r, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "https://core.example/api/v1", Credential: "synthetic", NetworkAccess: "enabled"} + got, err := p.Create(deadline(t), b) + contracttest.AssertObservation(t, got, err, r, "native-owned", "running") + got, err = p.GetInfo(deadline(t), r) + contracttest.AssertObservation(t, got, err, r, "native-owned", "stopped") + got, err = p.Renew(deadline(t), r) + contracttest.AssertObservation(t, got, err, r, "native-owned", "stopped") +} diff --git a/services/agents-api/internal/sandbox/microsandbox/provider.go b/services/agents-api/internal/sandbox/microsandbox/provider.go index 25439cad7..4e787cbd7 100644 --- a/services/agents-api/internal/sandbox/microsandbox/provider.go +++ b/services/agents-api/internal/sandbox/microsandbox/provider.go @@ -15,7 +15,7 @@ type Provider struct { caller Caller } -var _ sandbox.Provider = (*Provider)(nil) +var _ sandbox.SandboxProvider = (*Provider)(nil) var _ sandbox.CheckpointProvider = (*Provider)(nil) func New(c Config) (*Provider, error) { return NewWithCaller(c, &ProcessCaller{}) } diff --git a/services/agents-api/internal/sandbox/node/agent.go b/services/agents-api/internal/sandbox/node/agent.go index dd6fff58a..a570a01fa 100644 --- a/services/agents-api/internal/sandbox/node/agent.go +++ b/services/agents-api/internal/sandbox/node/agent.go @@ -22,7 +22,7 @@ type AgentConfig struct { StateDirectory string Identity Identity Credential string - Provider sandbox.Provider + Provider sandbox.SandboxProvider Probe func(context.Context) (Health, error) // Dialer is optional, primarily for an operator-supplied TLS trust configuration. Dialer *websocket.Dialer @@ -68,7 +68,7 @@ func (c *agentConnection) write(f frame) error { type work struct { request request connection *agentConnection - provider sandbox.Provider + provider sandbox.SandboxProvider release func() ready bool } diff --git a/services/agents-api/internal/sandbox/node/connection_test.go b/services/agents-api/internal/sandbox/node/connection_test.go index 5d6cedddc..a1da83978 100644 --- a/services/agents-api/internal/sandbox/node/connection_test.go +++ b/services/agents-api/internal/sandbox/node/connection_test.go @@ -152,7 +152,7 @@ func TestCopiedIdentityCannotReplaceNodeWithInflightCreate(t *testing.T) { duplicate := &fakeProvider{} var release sync.Once defer release.Do(func() { close(original.release) }) - start := func(dir string, p sandbox.Provider) (context.CancelFunc, chan error) { + start := func(dir string, p sandbox.SandboxProvider) (context.CancelFunc, chan error) { ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { diff --git a/services/agents-api/internal/sandbox/node/generations.go b/services/agents-api/internal/sandbox/node/generations.go index 57b45ba85..4fa625845 100644 --- a/services/agents-api/internal/sandbox/node/generations.go +++ b/services/agents-api/internal/sandbox/node/generations.go @@ -13,7 +13,7 @@ import ( type GenerationProvider struct { Generation uint64 SpecificationDigest string - Provider sandbox.Provider + Provider sandbox.SandboxProvider Probe func(context.Context) error Close func() } @@ -187,7 +187,7 @@ func (m *GenerationManager) Retained(after uint64) []sandbox.GenerationReference } // Acquire is called before queue admission, so queued work also prevents GC. -func (m *GenerationManager) Acquire(generation uint64) (sandbox.Provider, bool, func(), error) { +func (m *GenerationManager) Acquire(generation uint64) (sandbox.SandboxProvider, bool, func(), error) { m.mu.Lock() defer m.mu.Unlock() g := m.values[generation] diff --git a/services/agents-api/internal/sandbox/node/observations.go b/services/agents-api/internal/sandbox/node/observations.go index db52c2865..c63c3215c 100644 --- a/services/agents-api/internal/sandbox/node/observations.go +++ b/services/agents-api/internal/sandbox/node/observations.go @@ -39,7 +39,7 @@ func (p *provider) Observe(ctx context.Context, target runtimeobs.Target) (runti return *out.Sample, nil } -func observeProvider(ctx context.Context, provider sandbox.Provider, target runtimeobs.Target) (runtimeobs.Sample, error) { +func observeProvider(ctx context.Context, provider sandbox.SandboxProvider, target runtimeobs.Target) (runtimeobs.Sample, error) { source, ok := provider.(runtimeobs.Source) if !ok { return runtimeobs.Sample{}, runtimeobs.ErrUnavailable diff --git a/services/agents-api/internal/sandbox/node/observations_test.go b/services/agents-api/internal/sandbox/node/observations_test.go index bbbcecb97..547d4f7f5 100644 --- a/services/agents-api/internal/sandbox/node/observations_test.go +++ b/services/agents-api/internal/sandbox/node/observations_test.go @@ -37,7 +37,7 @@ func observationTarget(r sandbox.Reference, installation string) runtimeobs.Targ Instance: runtimeobs.Instance{AllocationID: r.AllocationID, ProviderKey: installation, AllocationState: "running", ComputePhase: "running", ProviderState: json.RawMessage(`{"current":{"id":"exact-incarnation","generation":3}}`)}, TokenUsage: &runtimeobs.TokenUsage{InputTokens: 123, OutputTokens: 456}} } -func runObservationNode(t *testing.T, hub *Hub, url string, id Identity, provider sandbox.Provider) context.CancelFunc { +func runObservationNode(t *testing.T, hub *Hub, url string, id Identity, provider sandbox.SandboxProvider) context.CancelFunc { t.Helper() dir := stateDir(t) stored, err := InitIdentity(dir, url, id) @@ -140,7 +140,7 @@ func TestObservationWirePreservesUnavailableAndRejectsMismatchedIdentity(t *test q := request{ID: uuid.NewString(), ConnectionID: uuid.NewString(), Operation: "observe", Reference: r, Observation: &target, TimeoutMillis: 1000} providers := []struct { name string - provider sandbox.Provider + provider sandbox.SandboxProvider want error }{ {"unsupported", &fakeProvider{}, runtimeobs.ErrUnavailable}, diff --git a/services/agents-api/internal/sandbox/node/proxy.go b/services/agents-api/internal/sandbox/node/proxy.go index ba969a5d4..83c159306 100644 --- a/services/agents-api/internal/sandbox/node/proxy.go +++ b/services/agents-api/internal/sandbox/node/proxy.go @@ -13,18 +13,18 @@ type provider struct { } type checkpointProvider struct{ *provider } -var _ sandbox.Provider = (*provider)(nil) +var _ sandbox.SandboxProvider = (*provider)(nil) var _ sandbox.CheckpointProvider = (*checkpointProvider)(nil) // Provider advertises checkpoint support only for a deployment using microsandbox. -func (h *Hub) Provider(kind string, resolve Resolver) sandbox.Provider { +func (h *Hub) Provider(kind string, resolve Resolver) sandbox.SandboxProvider { p := &provider{hub: h, resolve: resolve, kind: kind} if kind == "microsandbox" { return &checkpointProvider{p} } return p } -func (h *Hub) Proxy(id, kind string) sandbox.Provider { +func (h *Hub) Proxy(id, kind string) sandbox.SandboxProvider { return h.Provider(kind, func(context.Context, sandbox.Reference) (string, error) { return id, nil }) } func (p *provider) call(ctx context.Context, q request) (response, error) { @@ -150,7 +150,7 @@ func (p *checkpointProvider) ResumeCompute(ctx context.Context, r sandbox.Refere // GenerationProvider routes every operation with allocation-owned generation, // distinct from the request's compute generation. -func (h *Hub) GenerationProvider(kind string, resolve func(context.Context, sandbox.Reference) (string, uint64, error)) sandbox.Provider { +func (h *Hub) GenerationProvider(kind string, resolve func(context.Context, sandbox.Reference) (string, uint64, error)) sandbox.SandboxProvider { p := &provider{hub: h, kind: kind, resolveGeneration: resolve} if kind == "microsandbox" { return &checkpointProvider{p} diff --git a/services/agents-api/internal/sandbox/node/wire.go b/services/agents-api/internal/sandbox/node/wire.go index 8fc7b2c39..e04a719ba 100644 --- a/services/agents-api/internal/sandbox/node/wire.go +++ b/services/agents-api/internal/sandbox/node/wire.go @@ -271,7 +271,7 @@ func (q request) validate() error { } return sandbox.ErrInvalid } -func execute(ctx context.Context, p sandbox.Provider, q request) response { +func execute(ctx context.Context, p sandbox.SandboxProvider, q request) response { out := response{ID: q.ID, ConnectionID: q.ConnectionID} err := q.validate() if err != nil { diff --git a/services/agents-api/internal/sandbox/provider.go b/services/agents-api/internal/sandbox/provider.go deleted file mode 100644 index f5a69b770..000000000 --- a/services/agents-api/internal/sandbox/provider.go +++ /dev/null @@ -1,63 +0,0 @@ -// Package sandbox manages compute for an already authorized Environment. -// It does not schedule Turns or implement routine execution and Files operations. -package sandbox - -import ( - "context" - "errors" -) - -var ( - ErrInvalid = errors.New("invalid sandbox configuration") - ErrOwnership = errors.New("sandbox ownership mismatch") - ErrExists = errors.New("sandbox allocation already exists") - ErrNotFound = errors.New("sandbox allocation not found") - ErrCommandUnconfirmed = errors.New("initialization command outcome unconfirmed; reclaim allocation before reuse") -) - -// Reference must be persisted by the caller before Create. AllocationID is a fresh -// UUID for one attempt, not the Environment ID. Serialize lifecycle operations for -// an allocation; a lost Create response is resolved with GetInfo, never by replay. -type Reference struct{ TenantID, EnvironmentID, AllocationID string } - -type Bootstrap struct { - Reference - SessionID, DeviceID, CoreURL, Credential string - NetworkAccess string - AllowedDomains []string -} - -// Info describes compute only. Running does not establish daemon authentication, -// native preparation, Environment readiness or a renewable provider lease. -type Info struct { - Reference - ProviderID, State string - // BootstrapComplete is provider evidence that initialization has reached its - // last mutating step. It does not establish daemon or native readiness. - BootstrapComplete bool - // CreateSettled proves that the original create and initialization attempt can - // no longer mutate resources. An absent observation needs this explicit proof; - // an ordinary missing resource or empty provider listing is not sufficient. - CreateSettled bool -} -type Command struct { - Args []string - Directory string - // Stdin carries confidential initialization bytes without exposing them in argv. - Stdin []byte -} - -const MaxCommandInputBytes = 50*1024*1024 + 32 - -type CommandResult struct { - Stdout, Stderr string - ExitCode int -} - -type Provider interface { - Create(context.Context, Bootstrap) (Info, error) - GetInfo(context.Context, Reference) (Info, error) - Renew(context.Context, Reference) (Info, error) - Kill(context.Context, Reference) error - RunCommand(context.Context, Reference, Command) (CommandResult, error) -} diff --git a/services/agents-api/internal/sandbox/sandbox_provider.go b/services/agents-api/internal/sandbox/sandbox_provider.go new file mode 100644 index 000000000..019d3722a --- /dev/null +++ b/services/agents-api/internal/sandbox/sandbox_provider.go @@ -0,0 +1,106 @@ +// Package sandbox defines the Sandbox Provider contract for authorized compute. +// Start at sandbox_provider.go and docs/sandbox-provider.md when adding an adapter. +// Core owns durable Environment/allocation state and lifecycle serialization; +// SandboxProvider owns compute and bootstrap, Runtime owns capability preparation, +// and Harness adapters own native execution. Compute running is not execution ready. +// +// Required operations are on SandboxProvider. CheckpointProvider is an independent +// optional capability; runtimeobs.Source and runtimeobs.BatchSource define optional +// read-only observation. Implementing an interface is a capability claim, not proof +// of qualification: run the common contract tests and adapter-specific acceptance. +// +// Registration is explicit construction, not a global init-time registry. Node-local +// adapters register in sandbox/config.Build; Core's cmd/server managedSetup.provider +// constructs direct adapters or node proxies. execution.RuntimeProvider binds the +// selected adapter to installation, backend, deployment generation and node identity. +// Keep vendor configuration at those construction boundaries; common lifecycle code +// selects behavior through these contracts, never through a vendor name. +package sandbox + +import ( + "context" + "errors" +) + +var ( + ErrInvalid = errors.New("invalid sandbox configuration") + ErrOwnership = errors.New("sandbox ownership mismatch") + ErrExists = errors.New("sandbox allocation already exists") + ErrNotFound = errors.New("sandbox allocation not found") + ErrCommandUnconfirmed = errors.New("initialization command outcome unconfirmed; reclaim allocation before reuse") +) + +// Reference must be persisted by the caller before Create. AllocationID is a fresh +// UUID for one attempt, not the Environment ID. Serialize lifecycle operations for +// an allocation; a lost Create response is resolved with GetInfo, never by replay. +type Reference struct{ TenantID, EnvironmentID, AllocationID string } + +type Bootstrap struct { + Reference + SessionID, DeviceID, CoreURL, Credential string + NetworkAccess string + AllowedDomains []string +} + +// Info describes compute only. Running does not establish daemon authentication, +// native preparation, Environment readiness or a renewable provider lease. +type Info struct { + Reference + ProviderID, State string + // BootstrapComplete is provider evidence that initialization has reached its + // last mutating step. It does not establish daemon or native readiness. + BootstrapComplete bool + // CreateSettled proves that the original create and initialization attempt can + // no longer mutate resources. An absent observation needs this explicit proof; + // an ordinary missing resource or empty provider listing is not sufficient. + CreateSettled bool +} +type Command struct { + Args []string + Directory string + // Stdin carries confidential initialization bytes without exposing them in argv. + Stdin []byte +} + +const MaxCommandInputBytes = 50*1024*1024 + 32 + +type CommandResult struct { + Stdout, Stderr string + ExitCode int +} + +// SandboxProvider manages one persisted Reference at a time. Every call has a +// bounded context; cancellation ends the caller's wait, not proof of native stop. +// Non-nil errors retain ownership, including partial results. Do not retry Create +// or RunCommand after unknown delivery; observe/reclaim the original Reference. +// Implementations verify installation plus Reference ownership before mutation. +// See docs/sandbox-provider.md for settlement, cleanup and retry requirements. +type SandboxProvider interface { + // Create performs the original attempt once; no credential overwrite on conflict. + Create(context.Context, Bootstrap) (Info, error) + // GetInfo observes compute without creating, starting, renewing or preparing it. + GetInfo(context.Context, Reference) (Info, error) + // Renew extends an existing native lease where supported; otherwise it observes. + // It never revives stopped compute or establishes execution readiness. + Renew(context.Context, Reference) (Info, error) + // Kill confirms removal of owned compute and retained resources. Absence is + // idempotent, but nil alone cannot settle an outstanding Create. + Kill(context.Context, Reference) error + RunCommand(context.Context, Reference, Command) (CommandResult, error) +} + +// CheckpointProvider is optional. It supplements the existing provider with +// exact-incarnation operations; Worker and Store remain the lifecycle owner. +type CheckpointProvider interface { + SandboxProvider + Initial(context.Context, Reference) (Compute, error) + NewCompute(context.Context, Reference, uint64, *SnapshotIdentity) (Compute, error) + GetCompute(context.Context, Reference, Compute) (ComputeState, error) + Suspend(context.Context, SuspendRequest) (ComputeState, error) + Resume(context.Context, ResumeRequest) (ComputeState, error) + KillCompute(context.Context, Reference, Compute) error + DeleteSnapshot(context.Context, Reference, SnapshotIdentity) error + RunCommandCompute(context.Context, Reference, Compute, Command) (CommandResult, error) + // ResumeCompute thaws only the same resident instance after an aborted pause. + ResumeCompute(context.Context, Reference, Compute) (ComputeState, error) +} diff --git a/services/agents-api/internal/sandbox/suspension.go b/services/agents-api/internal/sandbox/suspension.go index 80f694bca..5a600a877 100644 --- a/services/agents-api/internal/sandbox/suspension.go +++ b/services/agents-api/internal/sandbox/suspension.go @@ -1,7 +1,6 @@ package sandbox import ( - "context" "errors" ) @@ -56,19 +55,3 @@ type ResumeRequest struct { // Recovery observes the previous target and never starts a new restore. ObserveOnly bool } - -// CheckpointProvider is optional. It supplements the existing provider with -// exact-incarnation operations; Worker and Store remain the lifecycle owner. -type CheckpointProvider interface { - Provider - Initial(context.Context, Reference) (Compute, error) - NewCompute(context.Context, Reference, uint64, *SnapshotIdentity) (Compute, error) - GetCompute(context.Context, Reference, Compute) (ComputeState, error) - Suspend(context.Context, SuspendRequest) (ComputeState, error) - Resume(context.Context, ResumeRequest) (ComputeState, error) - KillCompute(context.Context, Reference, Compute) error - DeleteSnapshot(context.Context, Reference, SnapshotIdentity) error - RunCommandCompute(context.Context, Reference, Compute, Command) (CommandResult, error) - // ResumeCompute thaws only the same resident instance after an aborted pause. - ResumeCompute(context.Context, Reference, Compute) (ComputeState, error) -} diff --git a/services/agents-api/internal/store/runtime_lifecycle_test.go b/services/agents-api/internal/store/runtime_lifecycle_test.go index 37d00da64..45ee941e1 100644 --- a/services/agents-api/internal/store/runtime_lifecycle_test.go +++ b/services/agents-api/internal/store/runtime_lifecycle_test.go @@ -71,12 +71,12 @@ func (p *lifecycleProvider) RunCommand(context.Context, sandbox.Reference, sandb return sandbox.CommandResult{}, errors.New("not used") } -func managedWorker(t *testing.T, s *store.Store, key string, p sandbox.Provider) (*execution.Worker, func()) { +func managedWorker(t *testing.T, s *store.Store, key string, p sandbox.SandboxProvider) (*execution.Worker, func()) { t.Helper() return managedWorkerMode(t, s, key, p, false) } -func managedWorkerMode(t *testing.T, s *store.Store, key string, p sandbox.Provider, maintenance bool) (*execution.Worker, func()) { +func managedWorkerMode(t *testing.T, s *store.Store, key string, p sandbox.SandboxProvider, maintenance bool) (*execution.Worker, func()) { t.Helper() registry := gateway.NewRegistry() if peer, ok := p.(interface { From 85b7eb1aedab04a1a3cf62567272a11ae36b7fb6 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 17:54:21 +0000 Subject: [PATCH 33/78] Define the Core Runtime communication contract and reject incompatible peers --- CONTRIBUTING.md | 47 ++- Makefile | 5 + README.md | 1 + .../internal/cli/agent_discovery.go | 1 - apps/parsar-daemon/internal/cli/claude_sdk.go | 2 +- apps/parsar-daemon/internal/cli/connect.go | 3 +- .../internal/cli/connect_suspend.go | 2 +- .../internal/cli/mcode_workspace.go | 2 +- .../internal/contracttest/wire_test.go | 374 ++++++++++++++++++ .../dispatch/interaction_decisions.go | 3 - .../internal/dispatch/router_test.go | 6 +- apps/parsar-daemon/internal/transport/ws.go | 4 +- contracts/agents-api/message-input.md | 2 +- docs/runtime-protocol.md | 226 +++++++++++ internal/agentdaemon/device/state.go | 1 - internal/agentdaemon/gateway/handler.go | 2 +- internal/agentdaemon/gateway/preparation.go | 2 +- .../agentdaemon/gateway/preparation_test.go | 4 +- internal/agentdaemon/gateway/session.go | 71 +--- internal/agentdaemon/gateway/session_test.go | 100 ++--- internal/agentdaemon/gateway/subscription.go | 49 +-- .../agentdaemon/gateway/subscription_test.go | 22 -- internal/agentdaemon/proto/envelope.go | 12 +- internal/agentdaemon/proto/envelope_test.go | 9 +- internal/agentdaemon/proto/inbound.go | 48 +-- internal/agentdaemon/proto/version.go | 41 +- .../execution/runtime_protocol_test.go | 54 +++ .../agents-api/internal/execution/support.go | 3 - .../store/environment_worker_helpers_test.go | 2 +- .../store/local_environment_worker_test.go | 1 - 30 files changed, 811 insertions(+), 288 deletions(-) create mode 100644 apps/parsar-daemon/internal/contracttest/wire_test.go create mode 100644 docs/runtime-protocol.md create mode 100644 services/agents-api/internal/execution/runtime_protocol_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bcc4d162..2a1114e0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,6 +59,34 @@ isolation remain separate work. the pinned public API, or authorize unrelated refactors or live Session configuration switching. +## Core–Runtime integration contract + +The [Core–Runtime protocol](docs/runtime-protocol.md) is the integration entry +point for message order, identities, receipts, capability declarations and failure +ownership. The shared types and validators live only in +`internal/agentdaemon/proto`; do not introduce a parallel schema or per-Harness +orchestration path. Both hosted and self-hosted peers use the same contract. +Protocol changes update matching peers together, with an exact wire-version +check and no historical wire fallback. + +Core queues and Runtime writes are transport observations, not native acceptance +or completion. The gateway exposes only durable Run subscriptions: connection +loss or overflow closes the subscription with an error and never fabricates +native error/done events. A timeout leaves unconfirmed effects unknown; transport +reconnection never authorizes mutation replay. The public Turn may still be +`failed` for an observation failure (`delivery_unknown` or +`event_stream_incomplete`); that status does not prove native failure or cleanup. +Runtime owns native settlement +and cleanup; Executor release does not reclaim Sandbox Provider compute. +The daemon runs with its starting user's authority, not as an isolation boundary; +Core-managed sandbox infrastructure owns outer isolation. + +`make check-runtime-contract` is the focused shared-contract entry point. Its +wire, gateway, transport, dispatcher, real WebSocket and observation-result tests +also run in the required `make check` through `check-go` and `check-agents-api`. Keep failure, cancellation, disconnect +and cleanup assertions in that common suite; native adapter acceptance remains +required for advertised capabilities. + ## Workflow and quality Develop in an isolated worktree on a feature branch and submit a PR. Do not edit @@ -496,7 +524,7 @@ forwarding. The explicit daemon-executor decision supersedes the previous native executor interoperability requirement. The superseded execution route is removed; retain reusable filesystem helpers, necessary regressions and historical evidence without a compatibility layer. -The private daemon wire protocol is 0.7.0. Initial, prepared and active input use +The private daemon wire protocol is 0.8.0. Initial, prepared and active input use the same ordered MessageInput contract, replacing scalar prompts and attachments. User-message boundaries and text/image order remain intact through Core and the Runtime wire; adapters own native conversion and receipt aggregation. Text-only @@ -506,8 +534,8 @@ unqualified harnesses (Claude SDK, MiniMax Code) reject them at admission rather their input rewritten. Codex has a flat native input list and uses blank-line separators between messages; this does not preserve independent native user-message boundaries. No old wire fallback is maintained. -Deploy Core and daemon together; the existing major/minor WebSocket check rejects -older major/minor peers before dispatch rather than ignoring removed fields. +Deploy Core and daemon together; the WebSocket check requires the exact wire +version, including patch, and rejects mismatches before dispatch. The independently packaged Claude bridge uses protocol 3 for a prepared Executor and separately identified Turns; readiness rejects other protocol versions. Image-bearing messages require a qualified profile/placement before persistence @@ -1176,7 +1204,7 @@ a chain of old writable disks across suspension cycles. No Kubernetes, distribut scheduler or snapshot replication belongs in this V1 profile. Queued work and live Environment file access request wake. History and published -artifact reads do not. Planned suspension uses private daemon wire 0.7.0 with an +artifact reads do not. Planned suspension uses private daemon wire 0.8.0 with an Environment and suspension token; a PID/start-time fenced local control signal wakes the parked daemon, which reauthenticates before admitting new work. A transient disconnect before confirmation retries the same armed suspension with @@ -1278,11 +1306,12 @@ through the provider-neutral bootstrap and checked against execution preparation The shared policy includes enabled, disabled and an exact-host restricted allowlist. Core preserves public spelling/order/duplicates and only permits Template overrides that narrow authority. Adapters translate a normalized copy into native settings; -Core and Docker never select native profile names. Every hosted execution peer -must support `local_environment_network_policy` and receive the complete bound -policy; missing policy never falls back to enabled or an older peer path. Read-only -workspace access does not require execution network policy. A declaration alone -does not qualify an image or admit public hosted creation. +Core and Docker never select native profile names. Execution preparation receives +the complete bound policy; missing policy never falls back to enabled or an older +peer path. Network isolation belongs to the surrounding sandbox, not a daemon +capability declaration. Read-only workspace access does not require execution +network policy. A declaration alone does not qualify an image or admit public +hosted creation. Codex restricted networking uses its native managed network requirements and proxy. Its adapter preserves the image's filesystem, approval and hook requirements and diff --git a/Makefile b/Makefile index d72d18bdd..052bf1756 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,11 @@ check-sqlc: check-go: go test ./apps/parsar-daemon/... ./internal/... ./contracts/agents-api/... ./scripts/openapi-split -count=1 +.PHONY: check-runtime-contract +check-runtime-contract: + go test ./internal/agentdaemon/proto ./internal/agentdaemon/gateway ./apps/parsar-daemon/internal/transport ./apps/parsar-daemon/internal/dispatch ./apps/parsar-daemon/internal/contracttest -count=1 + go test ./services/agents-api/internal/execution -run '^TestRuntimeProtocol' -count=1 + build-daemon: @set -e; output="$${OAC_DEV_HOME:-$$HOME/.oac}/build/daemon"; \ [[ "$$output" == /* ]] || { echo 'Daemon output directory must be absolute' >&2; exit 1; }; \ diff --git a/README.md b/README.md index c8f255455..bfadf51e2 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ native differences. | [API reference](docs/api/README.md) | The `/v1`, `/core/v1` and `/api/v1` namespaces | | [Nodes and sandbox backends: operator reference](services/agents-api/HOSTED-SANDBOX-MANAGER.md) | Node protocol, manual registration, placement, reset | | [Protocol coverage](contracts/agents-api/README.md) and [harness selection](contracts/agents-api/harness-selection.md) | Supported operations and native differences | +| [Core–Runtime protocol](docs/runtime-protocol.md) | Runtime integration, messages, receipts, failure ownership and contract checks | | [Add a Harness](contracts/agents-api/harness-onboarding.md) | Required adapter interfaces, lifecycle, capabilities, registration and acceptance | | [Landing page source](site/index.html) | The public site | diff --git a/apps/parsar-daemon/internal/cli/agent_discovery.go b/apps/parsar-daemon/internal/cli/agent_discovery.go index ad727c627..a2233066a 100644 --- a/apps/parsar-daemon/internal/cli/agent_discovery.go +++ b/apps/parsar-daemon/internal/cli/agent_discovery.go @@ -155,7 +155,6 @@ func discoverAgentCLIs(rc *runContext, profile string, checks agentCLIChecks) (a out.Codex.Version = codexVersion out.Codex.Capabilities.NativeSessionRecovery = codex.SupportsNativeSessionRecovery(codexVersion) out.Codex.Capabilities.LocalEnvironment = codex.SupportsLocalEnvironment(codexVersion) - out.Codex.Capabilities.LocalEnvironmentNetworkPolicy = codex.SupportsLocalNetworkPolicy(codexVersion) out.Codex.Capabilities.MCPHTTPRequired = codex.SupportsNativeSessionRecovery(codexVersion) fmt.Fprintf(rc.stdout, "Codex preflight ok (%s)\n", codexVersion) } else if errors.Is(codexErr, codex.ErrCLINotFound) { diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go index 746288935..f0b3ea151 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -94,7 +94,7 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex } caps := &out.Info.Capabilities caps.EnvironmentNone, caps.FunctionTools = false, info.SupportsWorkspaceFunctions() - caps.Preparation, caps.LocalEnvironment, caps.LocalEnvironmentNetworkPolicy = true, true, true + caps.Preparation, caps.LocalEnvironment = true, true caps.WorkspaceReadPreparation, caps.NativeSessionRecovery = true, true } out.Info.Available, out.Info.Version = true, info.SDK diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index 61f0ebe4a..e76aacb35 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -309,7 +309,7 @@ func mainLoopRemote(rc *runContext, profile string, prof auth.Profile, agentCLIs DeviceID: boot.DeviceID, Credential: prof.RunnerCredential, // DaemonVersion is the WIRE-PROTOCOL version, not the build - // tag. proto.VersionCompatible is a strict major.minor + // tag. proto.VersionCompatible requires an exact version // match against proto.Version. Build-tag reporting goes // in heartbeat's DaemonVersion field. DaemonVersion: proto.Version, @@ -420,7 +420,6 @@ func pumpConn(parentCtx context.Context, conn *transport.Conn, registry *agent.R Timestamp: time.Now().Unix(), ActiveRequests: router.ActiveRuns(), DaemonVersion: Version, - ClaudeAvailable: agentCLIs.ClaudeCode.Available, // legacy server compatibility SupportedAgentKinds: kinds, } }, obslog.Bg().With("component", "heartbeat")) diff --git a/apps/parsar-daemon/internal/cli/connect_suspend.go b/apps/parsar-daemon/internal/cli/connect_suspend.go index c471a12f0..0253f0a5f 100644 --- a/apps/parsar-daemon/internal/cli/connect_suspend.go +++ b/apps/parsar-daemon/internal/cli/connect_suspend.go @@ -161,7 +161,7 @@ func (s *suspendedRouter) heartbeats(ctx context.Context, conn *transport.Conn, caps := &kinds[i].Capabilities caps.WorkspaceOutputExport = s.local.CanExport() && caps.LocalEnvironment && caps.WorkspaceReadPreparation } - return proto.HeartbeatPayload{Timestamp: time.Now().Unix(), ActiveRequests: s.router.ActiveRuns(), DaemonVersion: Version, ClaudeAvailable: discovery.ClaudeCode.Available, SupportedAgentKinds: kinds} + return proto.HeartbeatPayload{Timestamp: time.Now().Unix(), ActiveRequests: s.router.ActiveRuns(), DaemonVersion: Version, SupportedAgentKinds: kinds} }, obslog.Bg()) } diff --git a/apps/parsar-daemon/internal/cli/mcode_workspace.go b/apps/parsar-daemon/internal/cli/mcode_workspace.go index 9e7d45f8d..9d5f37922 100644 --- a/apps/parsar-daemon/internal/cli/mcode_workspace.go +++ b/apps/parsar-daemon/internal/cli/mcode_workspace.go @@ -71,6 +71,6 @@ func discoverMCodeWorkspace(rc *runContext, discovery *agentCLIDiscovery) { discovery.MCodeWorkspace = &c caps := &discovery.MCode.Capabilities caps.EnvironmentNone = false - caps.Preparation, caps.LocalEnvironment, caps.LocalEnvironmentNetworkPolicy = true, true, true + caps.Preparation, caps.LocalEnvironment = true, true caps.WorkspaceReadPreparation = true } diff --git a/apps/parsar-daemon/internal/contracttest/wire_test.go b/apps/parsar-daemon/internal/contracttest/wire_test.go new file mode 100644 index 000000000..d2b38ca98 --- /dev/null +++ b/apps/parsar-daemon/internal/contracttest/wire_test.go @@ -0,0 +1,374 @@ +// Package contracttest exercises the shared protocol over an actual WebSocket, +// using the production gateway, transport and dispatcher with a controlled adapter. +package contracttest + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/dispatch" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +const credential = "synthetic-contract-credential" + +type credentialStore struct{} + +func (credentialStore) GetDeviceCredential(context.Context, string) (device.Credential, bool, error) { + return device.Credential{ID: "runtime", WorkspaceID: "tenant", Type: gateway.RuntimeTypeAgentDaemon, CredentialHash: device.HashCredential(credential)}, true, nil +} + +func newGateway(t *testing.T) (*gateway.Registry, string) { + t.Helper() + reg := gateway.NewRegistry() + handler := gateway.NewHandler(gateway.HandlerConfig{Registry: reg, Authenticator: gateway.NewAuthenticator(credentialStore{})}) + server := httptest.NewServer(http.HandlerFunc(handler.WS)) + t.Cleanup(server.Close) + return reg, "ws" + strings.TrimPrefix(server.URL, "http") +} + +func dial(t *testing.T, endpoint, version string) (*transport.Conn, error) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + return transport.Dial(ctx, transport.DialOptions{WSURL: endpoint, DeviceID: "runtime", Credential: credential, DaemonVersion: version}) +} + +func TestWireContractRejectsVersionMismatchWithoutReconnect(t *testing.T) { + _, endpoint := newGateway(t) + for _, version := range []string{"0.7.0", "0.8.99", "0.8.", proto.Version + "-dev", proto.Version + "+build"} { + t.Run(version, func(t *testing.T) { + attempts := 0 + _, err := transport.Reconnect(t.Context(), func(context.Context) (*transport.Conn, error) { + attempts++ + return dial(t, endpoint, version) + }, transport.DefaultBackoff, nil) + if !errors.Is(err, transport.ErrIncompatibleVersion) || !errors.Is(err, transport.ErrPermanent) || attempts != 1 { + t.Fatalf("mismatch must stop after one attempt: attempts=%d error=%v", attempts, err) + } + }) + } +} + +type wireFixture struct { + core *gateway.Session + conn *transport.Conn + router *dispatch.Router + reg *gateway.Registry + endpoint string + stopped chan struct{} + shutdownErr error +} + +func connectFixture(t *testing.T, factory agent.ExecutorFactory) *wireFixture { + t.Helper() + reg, endpoint := newGateway(t) + conn, err := dial(t, endpoint, proto.Version) + if err != nil { + t.Fatal(err) + } + core, err := reg.WaitForDevice(t.Context(), "runtime", 3*time.Second) + if err != nil { + t.Fatal(err) + } + kinds := agent.NewRegistry() + kinds.RegisterKind(proto.SupportedAgentKind{Kind: "contract", Available: true, Capabilities: proto.AgentKindCapabilities{EnvironmentNone: true}}, + func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + return nil, errors.New("prepared execution must not use prompt_request") + }) + kinds.RegisterExecutor("contract", factory) + router, err := dispatch.New(dispatch.Config{Registry: kinds, Sender: conn}) + if err != nil { + t.Fatal(err) + } + f := &wireFixture{core: core, conn: conn, router: router, reg: reg, endpoint: endpoint, stopped: make(chan struct{})} + go func() { + for env := range conn.Recv() { + if err := router.Handle(t.Context(), env); err != nil { + t.Errorf("dispatch: %v", err) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + f.shutdownErr = router.Shutdown(ctx) + close(f.stopped) + }() + t.Cleanup(func() { + conn.Close() + select { + case <-f.stopped: + if f.shutdownErr != nil { + t.Error(f.shutdownErr) + } + case <-time.After(4 * time.Second): + t.Error("Runtime shutdown did not complete") + } + core.Close("test finished") + }) + return f +} + +func (f *wireFixture) send(t *testing.T, kind, id string, payload any) { + t.Helper() + env, err := proto.NewEnvelope(kind, id, payload) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + if err := f.core.Send(ctx, env); err != nil { + t.Fatal(err) + } +} + +func receive(t *testing.T, sub *gateway.Subscription) proto.Envelope { + t.Helper() + select { + case env, ok := <-sub.Events: + if !ok { + t.Fatalf("subscription closed: %v", sub.Err()) + } + return env + case <-time.After(3 * time.Second): + t.Fatal("no protocol response") + } + return proto.Envelope{} +} + +func status(t *testing.T, sub *gateway.Subscription, want string) proto.PreparationStatusPayload { + t.Helper() + for { + env := receive(t, sub) + var got proto.PreparationStatusPayload + if env.Type != proto.TypePreparationStatus || env.DecodePayload(&got) != nil { + t.Fatalf("invalid preparation response: %+v", env) + } + if got.State == want { + return got + } + if got.State != "preparing" && got.State != "starting" { + t.Fatalf("preparation state=%s, want=%s", got.State, want) + } + } +} + +func (f *wireFixture) prepare(t *testing.T) (*gateway.Subscription, proto.PreparationStatusPayload) { + t.Helper() + sub, err := f.core.SubscribePreparation("prepare") + if err != nil { + t.Fatal(err) + } + f.send(t, proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{ + SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "contract", AgentStateKey: "agents-api-session", StrictResume: true, DisableExecutionEnvironment: true}, + }) + return sub, status(t, sub, "ready") +} + +type controlledExecutor struct { + turn chan *controlledTurn + starts atomic.Int32 + closes atomic.Int32 +} + +func newExecutor() *controlledExecutor { + return &controlledExecutor{turn: make(chan *controlledTurn, 1)} +} +func (e *controlledExecutor) Close(context.Context) error { e.closes.Add(1); return nil } +func (e *controlledExecutor) StartTurn(_ context.Context, id string, _ proto.MessageInput, out chan<- proto.Envelope) (agent.Turn, error) { + e.starts.Add(1) + turn := &controlledTurn{id: id, out: out, cancelling: make(chan struct{}), allowCancel: make(chan struct{}), settled: make(chan struct{})} + e.turn <- turn + return turn, nil +} + +type controlledTurn struct { + id string + out chan<- proto.Envelope + cancelling, allowCancel, settled chan struct{} + cancelOnce, finishOnce sync.Once +} + +func (turn *controlledTurn) Cancel(ctx context.Context) error { + turn.cancelOnce.Do(func() { close(turn.cancelling) }) + select { + case <-turn.allowCancel: + turn.finishOnce.Do(func() { close(turn.out); close(turn.settled) }) + return nil + case <-ctx.Done(): + return ctx.Err() + } +} +func (turn *controlledTurn) CancellationOutcome() proto.DonePayload { + return proto.DonePayload{Content: "partial", Metadata: map[string]any{proto.DoneMetaAgentSessionID: "native-session"}} +} +func (turn *controlledTurn) AwaitSettlement(ctx context.Context) (agent.TurnSettlement, error) { + select { + case <-turn.settled: + return agent.TurnSettlement{Reason: "cancelled fixture"}, nil + case <-ctx.Done(): + return agent.TurnSettlement{}, ctx.Err() + } +} + +func TestWireContractCancellationWaitsForSettlement(t *testing.T) { + executor := newExecutor() + f := connectFixture(t, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return executor, nil }) + prepared, ready := f.prepare(t) + if executor.starts.Load() != 0 { + t.Fatal("preparation submitted input") + } + run, err := f.core.SubscribeDurable("run") + if err != nil { + t.Fatal(err) + } + f.send(t, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{ExecutorID: ready.ExecutorID, Handle: ready.Handle, RunID: "run", Input: proto.TextInput("hello")}) + started := status(t, prepared, "started") + if started.RunID != "run" || started.ExecutorID != ready.ExecutorID { + t.Fatal("start lost execution identity") + } + var turn *controlledTurn + select { + case turn = <-executor.turn: + case <-time.After(3 * time.Second): + t.Fatal("no native turn") + } + var unblock sync.Once + release := func() { unblock.Do(func() { close(turn.allowCancel) }) } + t.Cleanup(release) + result := make(chan proto.InteractionDecisionAckPayload, 1) + ackErr := make(chan error, 1) + go func() { + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + env, _ := proto.NewEnvelope(proto.TypePromptCancel, "run", proto.PromptCancelPayload{DeliveryID: "cancel"}) + ack, err := f.core.SendAndWaitInteractionAck(ctx, env, "cancel") + result <- ack + ackErr <- err + }() + select { + case <-turn.cancelling: + case <-time.After(3 * time.Second): + t.Fatal("cancellation was not received") + } + select { + case <-result: + t.Fatal("receipt preceded native settlement") + default: + } + release() + select { + case ack := <-result: + if err := <-ackErr; err != nil || !ack.Applied || ack.Outcome == nil || ack.Outcome.Content != "partial" { + t.Fatalf("invalid cancellation receipt: %+v, %v", ack, err) + } + case <-time.After(3 * time.Second): + t.Fatal("missing settled cancellation receipt") + } + f.core.Unsubscribe("run") + for env := range run.Events { + if env.ID != "run" { + t.Fatal("settled cancellation output lost Run correlation") + } + } + if executor.starts.Load() != 1 { + t.Fatal("input was submitted more than once") + } +} + +func TestWireContractPreparationFailureCleansUpWithoutRunCompletion(t *testing.T) { + executor := newExecutor() + f := connectFixture(t, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { + return executor, errors.New("native setup failed") + }) + sub, err := f.core.SubscribePreparation("prepare") + if err != nil { + t.Fatal(err) + } + run, err := f.core.SubscribeDurable("run") + if err != nil { + t.Fatal(err) + } + f.send(t, proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: "session", Configuration: proto.PromptRequestPayload{AgentKind: "contract", AgentStateKey: "agents-api-session", StrictResume: true, DisableExecutionEnvironment: true}}) + failed := status(t, sub, "failed") + if failed.ErrorCode != "preparation_failed" || executor.starts.Load() != 0 || executor.closes.Load() != 1 { + t.Fatalf("failed preparation retained resources or started input: %+v starts=%d closes=%d", failed, executor.starts.Load(), executor.closes.Load()) + } + if len(run.Events) != 0 { + t.Fatal("preparation failure manufactured a Run result") + } +} + +func TestWireContractDisconnectIsUnknownAndReconnectDoesNotReplay(t *testing.T) { + executor := newExecutor() + f := connectFixture(t, func(context.Context, proto.PromptRequestPayload) (agent.Executor, error) { return executor, nil }) + prepared, ready := f.prepare(t) + run, err := f.core.SubscribeDurable("run") + if err != nil { + t.Fatal(err) + } + f.send(t, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{ExecutorID: ready.ExecutorID, Handle: ready.Handle, RunID: "run", Input: proto.TextInput("hello")}) + status(t, prepared, "started") + var turn *controlledTurn + select { + case turn = <-executor.turn: + case <-time.After(3 * time.Second): + t.Fatal("no native turn") + } + close(turn.allowCancel) + // Work has started. Losing observation cannot establish its result even + // when Runtime subsequently settles cleanup. + f.conn.Close() + select { + case <-f.core.Closed(): + case <-time.After(3 * time.Second): + t.Fatal("gateway stayed connected") + } + for env := range run.Events { + if env.Type == proto.TypeError || env.Type == proto.TypeDone { + t.Fatal("disconnect manufactured execution failure/completion") + } + } + if !errors.Is(run.Err(), gateway.ErrSessionClosed) { + t.Fatalf("disconnect outcome: %v", run.Err()) + } + conn, err := dial(t, f.endpoint, proto.Version) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + core, err := f.reg.WaitForDevice(t.Context(), "runtime", 3*time.Second) + if err != nil { + t.Fatal(err) + } + defer core.Close("test done") + if core == f.core || f.reg.LookupRun("run") != nil { + t.Fatal("reconnect inherited stale Run ownership") + } + select { + case env := <-conn.Recv(): + t.Fatalf("reconnect replayed work for old handle %s: %+v", ready.Handle, env) + case <-time.After(30 * time.Millisecond): + } + select { + case <-f.stopped: + if f.shutdownErr != nil || executor.closes.Load() != 1 { + t.Fatalf("disconnect cleanup: %v, closes=%d", f.shutdownErr, executor.closes.Load()) + } + case <-time.After(3 * time.Second): + t.Fatal("disconnect cleanup did not settle") + } + if executor.starts.Load() != 1 { + t.Fatal("reconnect replayed input") + } +} diff --git a/apps/parsar-daemon/internal/dispatch/interaction_decisions.go b/apps/parsar-daemon/internal/dispatch/interaction_decisions.go index c4ead6f33..711593393 100644 --- a/apps/parsar-daemon/internal/dispatch/interaction_decisions.go +++ b/apps/parsar-daemon/internal/dispatch/interaction_decisions.go @@ -266,9 +266,6 @@ func (r *Router) indexPermissionFrame(s *sessionState, env proto.Envelope) { return } requestID := strings.TrimSpace(p.RequestID) - if requestID == "" { - requestID = strings.TrimSpace(env.ID) - } if requestID == "" { return } diff --git a/apps/parsar-daemon/internal/dispatch/router_test.go b/apps/parsar-daemon/internal/dispatch/router_test.go index 028d6dbc2..a636c622e 100644 --- a/apps/parsar-daemon/internal/dispatch/router_test.go +++ b/apps/parsar-daemon/internal/dispatch/router_test.go @@ -436,8 +436,7 @@ func TestPromptForUserChoiceDecisionRoutesToSession(t *testing.T) { // payload.AskID to seed askIndex. askEnv := mustEnv(t, proto.TypePromptForUserChoice, "run_ask", proto.PromptForUserChoicePayload{ AskID: "ask_abcd1234", - Question: "?", - Options: []proto.PromptForUserChoiceOption{{Label: "yes"}, {Label: "no"}}, + Questions: []proto.PromptForUserChoiceQuestion{{Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "yes"}, {Label: "no"}}}}, ToolUseID: "toolu_42", }) sess.out <- askEnv @@ -492,8 +491,7 @@ func TestPromptForUserChoiceDecisionClearsIndexOnAgentUnknown(t *testing.T) { askEnv := mustEnv(t, proto.TypePromptForUserChoice, "run_ask_u", proto.PromptForUserChoicePayload{ AskID: "ask_xxxxxxxx", - Question: "?", - Options: []proto.PromptForUserChoiceOption{{Label: "yes"}}, + Questions: []proto.PromptForUserChoiceQuestion{{Question: "?", Options: []proto.PromptForUserChoiceOption{{Label: "yes"}}}}, ToolUseID: "toolu_y", }) sess.out <- askEnv diff --git a/apps/parsar-daemon/internal/transport/ws.go b/apps/parsar-daemon/internal/transport/ws.go index 8ea9d2f5f..93f7f95f4 100644 --- a/apps/parsar-daemon/internal/transport/ws.go +++ b/apps/parsar-daemon/internal/transport/ws.go @@ -33,7 +33,7 @@ type DialOptions struct { Credential string // DaemonVersion is the X.Y.Z string used for - // proto.VersionCompatible. Mismatches close at upgrade time (4126). + // proto.VersionCompatible. Mismatches reject the HTTP upgrade with 426. DaemonVersion string // HandshakeTimeout caps WS upgrade wait. Zero → 10s. @@ -192,7 +192,7 @@ func (c *Conn) Close() error { // StartHeartbeats kicks off a ticker that calls payloadFn every // interval and Sends the resulting HeartbeatPayload. Returns // immediately. Caller-controlled because the heartbeat carries fields -// (active_requests, claude_available) only the agent layer knows. +// (active_requests, supported_agent_kinds) only the agent layer knows. // Nil logger falls back to log.Bg(). func (c *Conn) StartHeartbeats(parentCtx context.Context, interval time.Duration, payloadFn func() proto.HeartbeatPayload, logger *slog.Logger) { if interval <= 0 || payloadFn == nil { diff --git a/contracts/agents-api/message-input.md b/contracts/agents-api/message-input.md index 7d7f70f1f..89142914f 100644 --- a/contracts/agents-api/message-input.md +++ b/contracts/agents-api/message-input.md @@ -74,7 +74,7 @@ before. ## Runtime boundary -Private wire 0.7.0 uses `MessageInput` for initial requests, prepared start and +Private wire 0.8.0 uses `MessageInput` for initial requests, prepared start and active steering. Each message contains ordered `InputContent` parts, also reused by function-result content. Core does not download, transcode or repair media. The separate Claude bridge reports protocol 2; daemon readiness rejects protocol 1. diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md new file mode 100644 index 000000000..df3cc515d --- /dev/null +++ b/docs/runtime-protocol.md @@ -0,0 +1,226 @@ +# Core–Runtime protocol + +This is the integration entry point for a Runtime that executes work for Core. +The wire definitions live once in +[`internal/agentdaemon/proto`](../internal/agentdaemon/proto); +Core's [gateway](../internal/agentdaemon/gateway) and the reference Runtime's +[dispatcher](../apps/parsar-daemon/internal/dispatch) both use them. +The [machine HTTP API](../contracts/agents-api/runtime.openapi.yaml) describes +registration and connection endpoints. This document defines the meaning and +ordering of the messages after connection; it does not replace the typed payloads. + +Use this protocol for hosted and self-hosted Runtime implementations. A new +Harness implements the [adapter contract](../contracts/agents-api/harness-onboarding.md) +behind the Runtime registry. Do not add a Core orchestration branch named after +the Harness, operating system or Sandbox Provider. + +## Ownership and connection + +Core owns durable Session, Turn, input and Environment records, scheduling and +reconciliation. Runtime owns native Executors, active Turns, transfer state and +cleanup until settlement. A Sandbox Provider owns placement and the surrounding +compute lifecycle. Releasing an execution admission or closing an Executor does +not delete, suspend or reclaim a sandbox. + +An installed daemon runs with the authority of the user who starts it. The +protocol does not make the daemon a tool, filesystem or network isolation +boundary. Core-managed Docker, E2B or other sandboxes provide the outer isolation. +A logical workspace, native tool configuration, advertised capability or successful +preparation is not proof of containment. + +1. Obtain the appropriate machine credential using the documented registration + flow. Applications use Project API keys; operators use Core keys. Neither is + a Runtime connection credential. +2. Bootstrap through `/api/v1/agent-daemon/bootstrap` with an Authorization Bearer + header and device identity. Use its authenticated connection URL. +3. Dial the reverse WebSocket at `/api/v1/agent-daemon/ws`, supplying `device_id` + and `version` query parameters and the Bearer header. Never put credentials + in a URL, payload log or trace. +4. Send an immediate heartbeat, then continue at the configured interval. + Declare `supported_agent_kinds`, availability and capabilities explicitly. + An absent first heartbeat means capabilities are unknown; an omitted kind in + a received heartbeat means it is not advertised. Neither permits inference. +5. Dispatch ordered JSON envelopes over the connection. Heartbeats establish + liveness only, not execution progress or a receipt for earlier messages. + +The wire version is [`proto.Version`](../internal/agentdaemon/proto/version.go), +independent of the Runtime build version reported in heartbeats. Core accepts +only an exact match, including the patch component. A mismatch returns HTTP 426 +`incompatible_version` before dispatch; the daemon treats it as permanent and +stops reconnecting. Deploy matching peers together. Removed fields, inferred +Claude availability and old interaction shapes have no compatibility path. + +Each physical connection has fresh routing, admission handles and transfer state. +A newer connection replaces the previous device connection. Core fences owner +leases and evicts old Run/interaction routes; the new connection does not inherit +them. A valid credential and connection are not authority to choose another +Session or Environment binding. + +## Envelope and identity + +Every data frame is one JSON +[`Envelope`](../internal/agentdaemon/proto/envelope.go): +`type`, type-dependent `id`, typed `payload`, and optional W3C `trace`. +Trace is diagnostic correlation only; missing or invalid trace data creates a +local trace and never changes ownership. Do not use a trace ID as a request ID. + +| Identity | Scope and meaning | +| --- | --- | +| Device ID and connection | Authenticated Runtime routing and connection ownership | +| Session ID / Environment ID | Core-owned configuration and workspace binding; canonical UUIDs where required by the payload validator | +| Executor ID | Runtime-owned native resource, potentially retained across settled Turns with identical configuration | +| Preparation request ID | `Envelope.id` for prepare/start/release/status; distinct from a Run | +| Admission handle | Runtime-generated reservation, valid only on its accepting connection | +| Run ID | Execution attempt; `Envelope.id` for output, cancellation, active input and functions | +| Interaction ID | `permission_request.payload.request_id` or `prompt_for_user_choice.payload.ask_id`; these request envelopes still carry the Run ID | +| Delivery ID / input ID / call ID | Resolve attempt, active-input receipt and native function identity respectively; never interchangeable | +| Transfer ID / suspension ID | Connection-local transfer correlation / persisted suspension-attempt fencing | + +Decision and permission-cancel envelopes use the interaction ID. Cancellation and +function-result acknowledgements use the Run ID. All application decision +receipts additionally match the delivery ID. A reply without the required +correlation cannot establish acceptance. + +## Message families + +Use the linked source definitions for required fields, validators, limits and +finite error categories. There is no parallel payload schema to keep in sync. + +| Core → Runtime | Runtime → Core | Definition | +| --- | --- | --- | +| `runtime_prepare` | `runtime_prepare_result` | [Initialization and capability transfer](../internal/agentdaemon/proto/runtime_prepare.go) | +| `execution_prepare`, `execution_start`, `execution_release` | `preparation_status` | [Execution admission](../internal/agentdaemon/proto/preparation.go) | +| `prompt_request`, `prompt_cancel`, `device_shutdown` | `delta`, `thinking`, `output_message`, `tool_call`, `usage`, `error`, `done`, `heartbeat` | [Requests](../internal/agentdaemon/proto/outbound.go), [events and capabilities](../internal/agentdaemon/proto/inbound.go) | +| `permission_decision`, `prompt_for_user_choice_decision` | `permission_request`, `permission_cancel`, `prompt_for_user_choice`, `interaction_decision_ack` | [Requests](../internal/agentdaemon/proto/outbound.go), [interactions](../internal/agentdaemon/proto/inbound.go) | +| `prompt_steer` | `prompt_steer_ack` | [Active input receipts](../internal/agentdaemon/proto/steering.go) | +| `function_result` | `function_call`, `interaction_decision_ack` | [Function calls](../internal/agentdaemon/proto/functions.go) | +| `workspace_read`, `workspace_write`, `workspace_export` | Matching `*_result` | [Read](../internal/agentdaemon/proto/workspace_read.go), [write](../internal/agentdaemon/proto/workspace_write.go), [export](../internal/agentdaemon/proto/workspace_export.go) | +| `environment_quiesce`, `environment_resume` | `environment_quiesced`, `environment_resumed` | [Suspension fencing](../internal/agentdaemon/proto/suspend.go) | + +Initial and active input share [ordered MessageInput](../internal/agentdaemon/proto/message_input.go). +Adapters preserve message/content order and explicitly reject unsupported content. +Usage frames and the final usage snapshot replace earlier cumulative snapshots; +do not add them. An absent measurement is unknown, not zero. + +## Preparation and execution order + +Hosted and self-hosted execution use the same preparation semantics. Placement +selects a connection and Runtime-owned workspace; Harness adapters perform native +configuration. The existing `prompt_request` message remains a direct execution +operation, not a fallback after failed prepared execution. + +Environment initialization uses `runtime_prepare`. For files or archives, send +`begin`, wait for `ready`, send ordered chunks and await matching `received` +offsets, then `commit` and await `completed`. Initialization and finalization +have typed headers without file data. Validate the expected outcome, offset, +size and finite error code with the shared validator. Only one transfer is +allowed per connection. A chunk receipt confirms staged bytes, not installation. +A completed commit confirms that operation, not that a future Turn has executed. + +For an execution Turn: + +1. Subscribe to preparation status before sending `execution_prepare` with + immutable Session configuration and no Run input. +2. `preparing` means the Runtime owns preparation; `ready` supplies the Executor + ID, admission handle, revision and expiry. Neither submits user input. +3. Subscribe to the Run before sending `execution_start` with that Executor, + handle, Run ID and ordered input. A valid start transfers the reservation + once. `started` confirms transfer to the Turn, not Turn completion; output + can race status delivery and must already have a subscriber. +4. Consume Run events until a native terminal outcome or loss of observation. + An execution error is followed by `done` to close that execution stream. + `done` closes the stream; preceding errors remain part of its outcome. +5. On abandonment before start, send `execution_release`. After ownership + transfers to a Run, use `prompt_cancel`; releasing the old handle cannot + cancel its successor. + +Preparation observations use monotonically increasing revisions per handle. +Ignore older or repeated revisions; do not apply a status for a different handle. +Typical transitions are `preparing → ready → starting → started`, or termination +by `released`, `expired`, or `failed`. A `rejected` control operation has an +`operation` and error code but does not replace the resource's current revision. +Read-only workspace preparation cannot start a Turn. Expiry does not remove the +Runtime's obligation to settle cleanup. + +## What each acknowledgement proves + +| Observation | Proven fact | +| --- | --- | +| Core gateway `Send` returns nil | Envelope entered the local send queue | +| Runtime transport `Send` returns nil | WebSocket write completed locally | +| Transfer `received`, active-input `written` | Defined receive/write phase occurred; native execution or consumption is unconfirmed | +| Preparation `preparing` / `ready` | Preparation accepted / reservation ready, with no submitted Run input | +| Preparation `started` | Admission transferred to the identified Turn | +| Active-input `accepted` | Adapter confirmed native consumption under its declared receipt semantics | +| `interaction_decision_ack.applied=true` | Identified operation settled; cancellation additionally requires native settlement | +| `done` and preceding execution events | Execution stream completed with its observed outcome | + +There is no generic receipt for every envelope. A successful send is not proof +that the peer received, accepted or completed a request. Process exit, a stop +signal and a canceled local context do not prove successful cancellation. +A `done` frame may also close a settled cancellation stream; it does not +override the cancellation receipt or imply successful execution. Cancellation +receipts may retain partial content, native identity and usage in `outcome` +even when no `done` is published. Failure to obtain settlement must +remain failed or unknown; it cannot become `applied=true`. + +## Failures, retries and cleanup + +Transport and execution outcomes are separate. Core's only Run subscription +entry point is `SubscribeDurable`; inspect `Subscription.Err()` when its event +channel closes. Disconnection and subscriber overflow close it with an explicit +observation error, without fabricating `error` or `done`. Core retains durable +truth and reconciles from confirmed facts. Runtime retains cleanup ownership +until native work, input receipts, interactions and child work have settled. + +The public Turn status is a separate, existing projection: +[`execution/delivery.go`](../services/agents-api/internal/execution/delivery.go) +records an unsuccessful orchestration attempt as `failed`, including +`delivery_unknown` after an unconfirmed send and `event_stream_incomplete` +after subscription failure. A closed subscription can replace the send reason +with `event_stream_incomplete`; both retain an unknown native effect. +This public `failed` status is not proof that the Harness failed, that no side +effect occurred or that cleanup completed. Native error classification comes +only from observed Runtime error frames. Consumers must keep the observation +reason and any native evidence distinct; this contract does not add a public +`unknown` status or change the existing Turn state machine. + +| Condition | Required responsibility | +| --- | --- | +| Unsupported capability or invalid binding | Reject explicitly before starting the unsupported operation; do not select another Harness | +| Confirmed preparation/execution failure | Preserve the finite error category and any observed result; Runtime settles its resources | +| Deadline or connection loss after dispatch | Caller has an unknown effect unless an application receipt proves otherwise; do not convert it to execution failure | +| Reconnection | Reestablish transport and capability advertisement; do not replay input, initialization, transfers or unresolved mutations | +| Duplicate preparation/start | Existing connection-local identity and fingerprint rules apply; a conflicting request rejects and an old handle cannot start replacement work | +| Duplicate input/function/decision | Use that family's existing receipt identity and conflict rules; no transport-wide deduplication or exactly-once promise exists | +| Cleanup failure | Retain resource ownership and report unconfirmed cleanup; a resource is not reusable merely because a waiter timed out | +| Lost cancellation receipt | Cancellation may have happened; lack of receipt cannot establish success or authorize another execution | + +Only retry operations whose own contract establishes that retry is safe. Retired +preparation request IDs may eventually allocate a new handle, so they are not +durable idempotency keys. Native-session resume is an explicit operation with +verified identity, not a response to a socket failure. A transient connection +error permits reconnecting the channel; authentication/version rejection requires +operator correction. Cleanup of an Executor remains separate from the Sandbox +Provider's confirmed reclamation of compute. + +## Contract verification + +Run `make check-runtime-contract` from the repository root. It exercises the +shared wire validators, gateway, transport and dispatcher, plus +[real WebSocket contract scenarios](../apps/parsar-daemon/internal/contracttest/wire_test.go) +using a controlled Harness adapter, plus the [observation-result regression](../services/agents-api/internal/execution/runtime_protocol_test.go). It requires no model credentials or external +sandbox. These tests are also included in `make check` through `check-go` and +`check-agents-api`. + +The suite checks incompatible versions, preparation failure, cancellation +settlement, connection loss without invented terminal events, reconnect without +replay, stale/duplicate handles and receipts, cleanup failures, bounded transfer +validation and resource ownership after timeout. Existing detailed fault +injection remains next to the owning gateway/dispatcher implementation. + +For another Runtime or Harness, reuse these protocol sequences and assertions, +then run its own native acceptance for the capabilities it advertises. A passing +controlled-adapter test establishes the transport contract, not native Harness +behavior, OS support, provider authentication or sandbox isolation. Update the +shared types, this guide and the contract checks together when semantics change. diff --git a/internal/agentdaemon/device/state.go b/internal/agentdaemon/device/state.go index 4d9ac1a6a..42b9b69ea 100644 --- a/internal/agentdaemon/device/state.go +++ b/internal/agentdaemon/device/state.go @@ -72,7 +72,6 @@ type KindCapabilities struct { ToolObservations bool `json:"tool_observations,omitempty"` EnvironmentNone bool `json:"environment_none,omitempty"` LocalEnvironment bool `json:"local_environment,omitempty"` - LocalEnvironmentNetworkPolicy bool `json:"local_environment_network_policy,omitempty"` Preparation bool `json:"preparation,omitempty"` WorkspaceReadPreparation bool `json:"workspace_read_preparation,omitempty"` WorkspaceOutputExport bool `json:"workspace_output_export,omitempty"` diff --git a/internal/agentdaemon/gateway/handler.go b/internal/agentdaemon/gateway/handler.go index cbfb56f1e..9783d5faf 100644 --- a/internal/agentdaemon/gateway/handler.go +++ b/internal/agentdaemon/gateway/handler.go @@ -101,7 +101,7 @@ func NewHandler(cfg HandlerConfig) *Handler { // WS is the websocket upgrade entry point. Errors before the upgrade // return JSON 4xx; errors during the WS read loop fall to Session.Close -// which fans synthetic error/done to every active subscriber. +// which closes subscriptions with a transport error, without execution events. // // @Summary Agent-daemon WebSocket upgrade // @Description Long-lived duplex channel for daemon runtimes. Authenticated by the runner bearer in the HTTP Authorization header before upgrade. diff --git a/internal/agentdaemon/gateway/preparation.go b/internal/agentdaemon/gateway/preparation.go index 8f09abd56..286624fc2 100644 --- a/internal/agentdaemon/gateway/preparation.go +++ b/internal/agentdaemon/gateway/preparation.go @@ -25,7 +25,7 @@ func (s *Session) SubscribePreparation(requestID string) (*Subscription, error) return nil, errors.New("agentdaemon gateway: invalid, duplicate or excess preparation subscription") } ch := make(chan proto.Envelope, 16) - sub := &Subscription{Events: ch, ch: ch, durable: true} + sub := &Subscription{Events: ch, ch: ch} s.preparations[requestID] = &preparationSubscription{sub: sub} return sub, nil } diff --git a/internal/agentdaemon/gateway/preparation_test.go b/internal/agentdaemon/gateway/preparation_test.go index ed6ea3acf..496a0994b 100644 --- a/internal/agentdaemon/gateway/preparation_test.go +++ b/internal/agentdaemon/gateway/preparation_test.go @@ -77,8 +77,8 @@ func TestPreparationCloseAndOverflowDoNotInventRunEvents(t *testing.T) { } func TestPreparationCapabilitySurvivesHeartbeatMapping(t *testing.T) { - kinds := deviceKindsFromHeartbeat(proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Preparation: true, LocalEnvironment: true, LocalEnvironmentNetworkPolicy: true, WorkspaceReadPreparation: true, NativeSessionRecovery: true}}}}) - if len(kinds) != 1 || (!kinds[0].Capabilities.Preparation || !kinds[0].Capabilities.LocalEnvironment || !kinds[0].Capabilities.LocalEnvironmentNetworkPolicy || !kinds[0].Capabilities.WorkspaceReadPreparation || !kinds[0].Capabilities.NativeSessionRecovery) { + kinds := deviceKindsFromHeartbeat(proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{Preparation: true, LocalEnvironment: true, WorkspaceReadPreparation: true, NativeSessionRecovery: true}}}}) + if len(kinds) != 1 || (!kinds[0].Capabilities.Preparation || !kinds[0].Capabilities.LocalEnvironment || !kinds[0].Capabilities.WorkspaceReadPreparation || !kinds[0].Capabilities.NativeSessionRecovery) { t.Fatal("preparation capability lost") } } diff --git a/internal/agentdaemon/gateway/session.go b/internal/agentdaemon/gateway/session.go index edd779e1c..f74b7e5fd 100644 --- a/internal/agentdaemon/gateway/session.go +++ b/internal/agentdaemon/gateway/session.go @@ -205,8 +205,6 @@ func (s *Session) LastSeen() time.Time { // AgentKindStatus returns the latest advertised descriptor for kind. // found=false means the daemon has not advertised that kind; snapshotKnown // distinguishes "no heartbeat yet" from "heartbeat arrived and omitted it". -// Before the first heartbeat, legacy Claude Code behavior is preserved so -// older daemons can still receive claude_code prompt_requests immediately. func (s *Session) AgentKindStatus(kind string) (info device.SupportedAgentKind, found bool, snapshotKnown bool) { kind = strings.TrimSpace(kind) if kind == "" { @@ -218,9 +216,6 @@ func (s *Session) AgentKindStatus(kind string) (info device.SupportedAgentKind, copy(kinds, s.supportedKinds) s.kindsMu.RUnlock() if !seen { - if kind == "claude_code" { - return legacyClaudeCodeKind(), true, false - } return device.SupportedAgentKind{}, false, false } for _, candidate := range kinds { @@ -240,34 +235,20 @@ func (s *Session) setSupportedAgentKinds(kinds []device.SupportedAgentKind) { s.kindsMu.Unlock() } -func legacyClaudeCodeKind() device.SupportedAgentKind { - return device.SupportedAgentKind{ - Kind: "claude_code", - Available: true, - Capabilities: device.KindCapabilities{ - Streaming: true, - Permissions: true, - Usage: true, - Resume: true, - }, - } -} - -// Close tears the session down: closes the WS, drains subscribers -// with a synthetic error+done pair, and deregisters. Idempotent. +// Close closes the transport and subscriptions with ErrSessionClosed, then +// releases connection ownership. It establishes no execution outcome. Idempotent. func (s *Session) Close(reason string) { s.closeOnce.Do(func() { s.stopReceiptTimer() close(s.closed) _ = s.conn.Close() - // Synthetic error + done so the connector's translation loop - // sees a clean EOF and unsubscribes naturally. + // Transport failure must remain distinct from native execution facts. s.subsMu.Lock() subs := s.subs s.subs = map[string]*Subscription{} s.subsMu.Unlock() for runID, sub := range subs { - s.closeSubscription(runID, sub, reason) + s.closeSubscription(sub) s.reg.DetachRun(runID) } s.reg.Deregister(s) @@ -291,24 +272,6 @@ func (s *Session) CloseWithCode(code int, reason string) { s.Close(reason) } -func (s *Session) deliverSynthetic(runID string, ch chan proto.Envelope, reason string) { - if reason == "" { - reason = "device disconnected" - } - errEnv, _ := proto.NewEnvelope(proto.TypeError, runID, proto.ErrorPayload{Error: reason}) - doneEnv, _ := proto.NewEnvelope(proto.TypeDone, runID, proto.DonePayload{}) - // Non-blocking — drop rather than hang the close path on a - // wedged subscriber. - select { - case ch <- errEnv: - default: - } - select { - case ch <- doneEnv: - default: - } -} - // Send queues an envelope for the WS write loop. Returns ErrSessionClosed // if the session has shut down, or context.DeadlineExceeded if the send // queue is full for longer than ctx's timeout. @@ -540,21 +503,6 @@ func (s *Session) handleHeartbeat(env proto.Envelope) { } func deviceKindsFromHeartbeat(p proto.HeartbeatPayload) []device.SupportedAgentKind { - if len(p.SupportedAgentKinds) == 0 { - if !p.ClaudeAvailable { - return nil - } - return []device.SupportedAgentKind{{ - Kind: "claude_code", - Available: true, - Capabilities: device.KindCapabilities{ - Streaming: true, - Permissions: true, - Usage: true, - Resume: true, - }, - }} - } out := make([]device.SupportedAgentKind, 0, len(p.SupportedAgentKinds)) for _, info := range p.SupportedAgentKinds { out = append(out, device.SupportedAgentKind{ @@ -575,7 +523,6 @@ func deviceKindsFromHeartbeat(p proto.HeartbeatPayload) []device.SupportedAgentK ToolObservations: info.Capabilities.ToolObservations, EnvironmentNone: info.Capabilities.EnvironmentNone, LocalEnvironment: info.Capabilities.LocalEnvironment, - LocalEnvironmentNetworkPolicy: info.Capabilities.LocalEnvironmentNetworkPolicy, Preparation: info.Capabilities.Preparation, WorkspaceReadPreparation: info.Capabilities.WorkspaceReadPreparation, WorkspaceOutputExport: info.Capabilities.WorkspaceOutputExport, @@ -631,11 +578,9 @@ func (s *Session) dispatch(env proto.Envelope) { requestID = strings.TrimSpace(p.RequestID) } if requestID == "" { - requestID = strings.TrimSpace(env.ID) - } - if requestID != "" { - s.reg.AttachPermission(requestID, s) + return } + s.reg.AttachPermission(requestID, s) case proto.TypePermissionCancel: if env.ID != "" { s.reg.DetachPermission(env.ID) @@ -671,9 +616,7 @@ func (s *Session) dispatch(env proto.Envelope) { // All run-correlated frames fan to the matching subscriber. Current // permission and prompt-for-user-choice frames keep their interaction ID - // in the payload so Envelope.ID remains the run ID. Legacy permission - // frames put the permission ID in Envelope.ID; those are still indexed - // above but cannot be correlated to a run subscriber. + // in the payload so Envelope.ID remains the run ID. if env.ID == "" { return } diff --git a/internal/agentdaemon/gateway/session_test.go b/internal/agentdaemon/gateway/session_test.go index 30fc4396f..74f7b4ad6 100644 --- a/internal/agentdaemon/gateway/session_test.go +++ b/internal/agentdaemon/gateway/session_test.go @@ -206,7 +206,8 @@ func TestSession_DispatchDeliversToSubscriber(t *testing.T) { sess.Start() defer sess.Close("test done") - ch, err := sess.Subscribe("run-1") + sub, err := sess.SubscribeDurable("run-1") + ch := sub.Events if err != nil { t.Fatalf("Subscribe: %v", err) } @@ -231,7 +232,8 @@ func TestSession_DoneFrameAutoUnsubscribes(t *testing.T) { sess.Start() defer sess.Close("test done") - ch, _ := sess.Subscribe("run-1") + sub, _ := sess.SubscribeDurable("run-1") + ch := sub.Events env, _ := proto.NewEnvelope(proto.TypeDone, "run-1", proto.DonePayload{Content: "ok"}) raw, _ := jsonMarshal(env) conn.Feed(raw) @@ -265,7 +267,8 @@ func TestSession_PermissionRequestIndexedInRegistry(t *testing.T) { sess.Start() defer sess.Close("test done") - ch, err := sess.Subscribe("run-1") + sub, err := sess.SubscribeDurable("run-1") + ch := sub.Events if err != nil { t.Fatalf("subscribe: %v", err) } @@ -299,34 +302,27 @@ func TestSession_PermissionRequestIndexedInRegistry(t *testing.T) { } } -func TestSession_CloseFansSyntheticErrorAndDone(t *testing.T) { - reg := NewRegistry() - conn := newFakeConn() - sess := NewSession(conn, "dev-1", "wks-1", "0.1.0", reg, nil) - sess.Start() - - ch, _ := sess.Subscribe("run-1") +func TestSession_CloseReportsUnknownWithoutExecutionEvents(t *testing.T) { + sess := NewSession(newFakeConn(), "device", "tenant", proto.Version, NewRegistry(), nil) + sub, err := sess.SubscribeDurable("run") + if err != nil { + t.Fatal(err) + } sess.Close("simulated drop") + for env := range sub.Events { + t.Fatalf("disconnect fabricated execution event: %s", env.Type) + } + if !errors.Is(sub.Err(), ErrSessionClosed) { + t.Fatalf("missing transport error: %v", sub.Err()) + } +} - gotErr, gotDone := false, false - deadline := time.After(2 * time.Second) - for !gotErr || !gotDone { - select { - case env, ok := <-ch: - if !ok { - if !gotErr || !gotDone { - t.Fatalf("channel closed before delivering synthetic error+done (gotErr=%v gotDone=%v)", gotErr, gotDone) - } - return - } - switch env.Type { - case proto.TypeError: - gotErr = true - case proto.TypeDone: - gotDone = true - } - case <-deadline: - t.Fatalf("synthetic frames not delivered (gotErr=%v gotDone=%v)", gotErr, gotDone) +func TestSession_NoCapabilitiesBeforeHeartbeat(t *testing.T) { + sess := NewSession(newFakeConn(), "device", "tenant", proto.Version, NewRegistry(), nil) + defer sess.Close("test done") + for _, kind := range []string{"claude_code", "codex", "contract"} { + if info, found, known := sess.AgentKindStatus(kind); found || known || info.Available { + t.Fatalf("unadvertised engine was available: %+v", info) } } } @@ -443,33 +439,45 @@ func TestSession_HeartbeatPersistsSupportedAgentKinds(t *testing.T) { } } -func TestSession_HeartbeatInfersClaudeCodeFromLegacyFlag(t *testing.T) { +func TestSession_HeartbeatDoesNotInferCapabilities(t *testing.T) { reg := NewRegistry() conn := newFakeConn() heartbeat := newFakeHeartbeatStore() - sess := NewSession(conn, "dev-legacy", "wks-1", "0.1.0", reg, nil) + sess := NewSession(conn, "device", "tenant", proto.Version, reg, nil) sess.heartbeat = heartbeat sess.Start() defer sess.Close("test done") - env, _ := proto.NewEnvelope(proto.TypeHeartbeat, "", proto.HeartbeatPayload{ - Timestamp: 1710000100, - DaemonVersion: "0.1.0-old", - ClaudeAvailable: true, - }) - raw, _ := jsonMarshal(env) - conn.Feed(raw) - + conn.Feed([]byte(`{"type":"heartbeat","payload":{"ts":1710000100,"claude_available":true}}`)) got := heartbeat.waitDaemonHeartbeat(t) - if len(got.SupportedAgentKinds) != 1 { - t.Fatalf("SupportedAgentKinds len = %d, want 1: %#v", len(got.SupportedAgentKinds), got.SupportedAgentKinds) + if len(got.SupportedAgentKinds) != 0 { + t.Fatalf("undeclared capabilities inferred: %#v", got.SupportedAgentKinds) + } +} + +func TestSession_PermissionRequiresPayloadIdentity(t *testing.T) { + reg := NewRegistry() + sess := NewSession(newFakeConn(), "device", "tenant", proto.Version, reg, nil) + defer sess.Close("test done") + sub, err := sess.SubscribeDurable("run") + if err != nil { + t.Fatal(err) + } + env, _ := proto.NewEnvelope(proto.TypePermissionRequest, "run", proto.PermissionRequestPayload{Tool: "test"}) + sess.dispatch(env) + if _, err := reg.LookupPermission("run"); !errors.Is(err, ErrPermissionNotRegistered) { + t.Fatal("run ID was treated as an interaction ID") + } + if len(sub.Events) != 0 { + t.Fatal("invalid permission request was forwarded") } - claude := got.SupportedAgentKinds[0] - if claude.Kind != "claude_code" || !claude.Available || !claude.Capabilities.Streaming || !claude.Capabilities.Permissions || !claude.Capabilities.Usage || !claude.Capabilities.Resume { - t.Fatalf("legacy claude_available fallback not inferred: %#v", claude) + env, _ = proto.NewEnvelope(proto.TypePermissionRequest, "run", proto.PermissionRequestPayload{RequestID: "permission", Tool: "test"}) + sess.dispatch(env) + if got, err := reg.LookupPermission("permission"); err != nil || got != sess { + t.Fatalf("declared interaction identity was not registered: %v", err) } - if claude.Capabilities.Steering { - t.Fatal("legacy daemon must not advertise steering") + if got := <-sub.Events; got.ID != "run" { + t.Fatal("run correlation lost") } } diff --git a/internal/agentdaemon/gateway/subscription.go b/internal/agentdaemon/gateway/subscription.go index 71bc9c1fc..4dd79189e 100644 --- a/internal/agentdaemon/gateway/subscription.go +++ b/internal/agentdaemon/gateway/subscription.go @@ -11,12 +11,11 @@ import ( var ErrSubscriberOverflow = errors.New("execution subscriber buffer overflow") type Subscription struct { - Events <-chan proto.Envelope - ch chan proto.Envelope - mu sync.Mutex - err error - closed bool - durable bool + Events <-chan proto.Envelope + ch chan proto.Envelope + mu sync.Mutex + err error + closed bool } func (s *Subscription) Err() error { @@ -32,30 +31,14 @@ func (s *Subscription) closeLocked(err error) { } } -// Subscribe retains the product's best-effort stream behavior. -func (s *Session) Subscribe(runID string) (<-chan proto.Envelope, error) { - sub, err := s.subscribe(runID, false) - if err != nil { - return nil, err - } - return sub.Events, nil -} - -// SubscribeDurable fails the subscription on overflow instead of losing events silently. +// SubscribeDurable reports transport loss and overflow separately from native +// execution events. Callers must inspect Err after Events closes. func (s *Session) SubscribeDurable(runID string) (*Subscription, error) { - return s.subscribe(runID, true) -} - -func (s *Session) subscribe(runID string, durable bool) (*Subscription, error) { if runID == "" { return nil, fmt.Errorf("agentdaemon gateway: Subscribe requires non-empty runID") } - capacity := 32 - if durable { - capacity = 256 - } - ch := make(chan proto.Envelope, capacity) - sub := &Subscription{Events: ch, ch: ch, durable: durable} + ch := make(chan proto.Envelope, 256) + sub := &Subscription{Events: ch, ch: ch} s.subsMu.Lock() defer s.subsMu.Unlock() if s.IsClosed() { @@ -83,15 +66,12 @@ func (s *Session) Unsubscribe(runID string) { s.reg.DetachRun(runID) } -func (s *Session) closeSubscription(runID string, sub *Subscription, reason string) { +func (s *Session) closeSubscription(sub *Subscription) { sub.mu.Lock() defer sub.mu.Unlock() if sub.closed { return } - if !sub.durable { - s.deliverSynthetic(runID, sub.ch, reason) - } sub.closeLocked(ErrSessionClosed) } @@ -110,14 +90,7 @@ func (s *Session) dispatchToSubscriber(env proto.Envelope) { sub.closeLocked(nil) } default: - if sub.durable { - sub.closeLocked(ErrSubscriberOverflow) - } else { - s.log("agentdaemon gateway: subscriber buffer full for run %s, dropping %s", env.ID, env.Type) - if env.Type == proto.TypeDone { - sub.closeLocked(nil) - } - } + sub.closeLocked(ErrSubscriberOverflow) } if sub.closed { delete(s.subs, env.ID) diff --git a/internal/agentdaemon/gateway/subscription_test.go b/internal/agentdaemon/gateway/subscription_test.go index 47ad15473..3c79343ae 100644 --- a/internal/agentdaemon/gateway/subscription_test.go +++ b/internal/agentdaemon/gateway/subscription_test.go @@ -33,28 +33,6 @@ func TestDurableSubscriptionOverflowIsExplicitAndIsolated(t *testing.T) { } } -func TestProductSubscriptionRetainsBestEffortBuffer(t *testing.T) { - s := NewSession(newFakeConn(), "device", "tenant", proto.Version, NewRegistry(), nil) - defer s.Close("test finished") - events, _ := s.Subscribe("product") - for range 33 { - s.dispatchToSubscriber(proto.Envelope{ID: "product", Type: proto.TypeDelta}) - } - if len(events) != 32 { - t.Fatal("product buffer changed") - } - for range 32 { - <-events - } - s.dispatchToSubscriber(proto.Envelope{ID: "product", Type: proto.TypeDone}) - if event := <-events; event.Type != proto.TypeDone { - t.Fatal(event.Type) - } - if _, ok := <-events; ok { - t.Fatal("terminal stream not closed") - } -} - func TestSubscriptionCloseAndDispatchAreSerialized(t *testing.T) { for range 100 { s := NewSession(newFakeConn(), "device", "tenant", proto.Version, NewRegistry(), nil) diff --git a/internal/agentdaemon/proto/envelope.go b/internal/agentdaemon/proto/envelope.go index 73eca4200..da98ad3df 100644 --- a/internal/agentdaemon/proto/envelope.go +++ b/internal/agentdaemon/proto/envelope.go @@ -1,6 +1,7 @@ // Package proto defines the JSON wire format spoken by oac-daemon over // the reverse WebSocket tunnel to the OpenAgentCore server. Both ends import -// this package; adding an event means editing one file here and both +// this package. See docs/runtime-protocol.md for the integration contract. +// Adding an event means updating the shared definition and both // sides at once. // // Topology: daemon dials OUT (firewall-friendly). Every frame is one @@ -13,8 +14,11 @@ // Envelope.ID correlation: // - prompt_request / prompt_cancel: ID = RunID. // - delta / tool_call / usage / error / done: ID = originating RunID. -// - permission_request: ID = daemon-minted "perm_<8hex>"; the matching -// downstream permission_decision echoes it back. +// - permission_request: ID = RunID; payload.request_id is the interaction ID. +// - permission_decision / permission_cancel: ID = interaction ID. +// - execution_prepare / execution_start / execution_release and +// preparation_status: ID = preparation request ID, never RunID. +// - runtime_prepare / runtime_prepare_result: ID = connection-local transfer ID. // - heartbeats carry no ID. package proto @@ -42,7 +46,7 @@ type Envelope struct { // ("00-{32hex trace_id}-{16hex span_id}-{2hex flags}") for the // logical request. Server-issued envelopes carry the gateway's // ctx carrier; daemon-issued envelopes carry the daemon's. Omitted - // when no trace is in scope (heartbeats, legacy clients). + // when no trace is in scope (for example, heartbeats). // Receivers MUST tolerate missing/unparseable values — both mean // "mint a fresh trace locally", never reject. Trace string `json:"trace,omitempty"` diff --git a/internal/agentdaemon/proto/envelope_test.go b/internal/agentdaemon/proto/envelope_test.go index c7388daca..d485d8f7d 100644 --- a/internal/agentdaemon/proto/envelope_test.go +++ b/internal/agentdaemon/proto/envelope_test.go @@ -88,12 +88,17 @@ func TestVersionCompatible(t *testing.T) { ok bool }{ {Version, true}, // exact match - {"0.7.99", true}, // patch drift OK + {"0.8.99", false}, // patch drift NOT OK {"0.6.99", false}, // minor drift NOT OK {"1.0.0", false}, // major drift NOT OK {"", false}, // missing {"garbage", false}, // unparseable - {"0.3", false}, // truncated + {"0.8", false}, // truncated + {"0.8.", false}, + {"0.8.invalid", false}, + {Version + "-dev", false}, + {Version + "+build", false}, + {" " + Version, false}, } for _, tc := range cases { if got := VersionCompatible(tc.client); got != tc.ok { diff --git a/internal/agentdaemon/proto/inbound.go b/internal/agentdaemon/proto/inbound.go index 1060f6515..ed0bedb55 100644 --- a/internal/agentdaemon/proto/inbound.go +++ b/internal/agentdaemon/proto/inbound.go @@ -31,7 +31,7 @@ const ( TypeToolCall = "tool_call" // TypePermissionRequest carries an agent's request for human - // approval. Envelope.ID = "perm_<8hex>" minted by the daemon. + // approval. Envelope.ID is the RunID; payload.request_id identifies the decision. TypePermissionRequest = "permission_request" // TypePermissionCancel signals the agent withdrew an earlier @@ -43,7 +43,7 @@ const ( // answers from a closed list before the agent can continue. Used // to intercept Claude Code's built-in AskUserQuestion tool so the // daemon doesn't deadlock waiting for a tool_result no one will - // send. Envelope.ID = "ask_<8hex>" minted by the daemon. + // send. Envelope.ID is the RunID; payload.ask_id identifies the decision. TypePromptForUserChoice = "prompt_for_user_choice" // TypeInteractionDecisionAck confirms that the daemon-side agent @@ -108,10 +108,8 @@ type ToolCallPayload struct { // approval. RequestID is the daemon-minted handle used to route a later // decision. It lives in the payload because Envelope.ID is the run ID used // by the server gateway to deliver the request to the active run subscriber. -// Readers still accept legacy frames that omit RequestID and put the request -// handle in Envelope.ID. type PermissionRequestPayload struct { - RequestID string `json:"request_id,omitempty"` + RequestID string `json:"request_id"` Tool string `json:"tool"` Title string `json:"title"` Detail string `json:"detail,omitempty"` @@ -161,41 +159,11 @@ type PromptForUserChoiceQuestion struct { // the originating Claude Code tool_use id; empty when the call came // through the control_request channel (CCRequestID then identifies the // daemon-side waiter instead but it doesn't ride on the wire). -// -// The legacy single-question fields (Question / Header / MultiSelect / -// Options) stay on the wire so older server/db snapshots can still be -// decoded. New code writes Questions; readers must call -// EffectiveQuestions to get a unified view across both shapes. type PromptForUserChoicePayload struct { AskID string `json:"ask_id"` - Questions []PromptForUserChoiceQuestion `json:"questions,omitempty"` + Questions []PromptForUserChoiceQuestion `json:"questions"` ToolUseID string `json:"tool_use_id,omitempty"` AutoResolutionMs *uint64 `json:"auto_resolution_ms,omitempty"` - - // Legacy single-question fields — read-only on the new path. Empty - // when Questions is populated. - Question string `json:"question,omitempty"` - Header string `json:"header,omitempty"` - MultiSelect bool `json:"multi_select,omitempty"` - Options []PromptForUserChoiceOption `json:"options,omitempty"` -} - -// EffectiveQuestions returns the question list a consumer should -// render. Prefers the new Questions slice; falls back to the legacy -// single-question fields so old payloads still work after a restart. -func (p PromptForUserChoicePayload) EffectiveQuestions() []PromptForUserChoiceQuestion { - if len(p.Questions) > 0 { - return p.Questions - } - if p.Question == "" && len(p.Options) == 0 { - return nil - } - return []PromptForUserChoiceQuestion{{ - Header: p.Header, - Question: p.Question, - MultiSelect: p.MultiSelect, - Options: p.Options, - }} } // TokenUsage is a complete cumulative measurement for the current execution. @@ -269,7 +237,6 @@ type AgentKindCapabilities struct { ToolObservations bool `json:"tool_observations,omitempty"` EnvironmentNone bool `json:"environment_none,omitempty"` LocalEnvironment bool `json:"local_environment,omitempty"` - LocalEnvironmentNetworkPolicy bool `json:"local_environment_network_policy,omitempty"` Preparation bool `json:"preparation,omitempty"` WorkspaceReadPreparation bool `json:"workspace_read_preparation,omitempty"` WorkspaceOutputExport bool `json:"workspace_output_export,omitempty"` @@ -302,14 +269,11 @@ type SupportedAgentKind struct { Capabilities AgentKindCapabilities `json:"capabilities,omitempty"` } -// HeartbeatPayload is the daemon's liveness ping. supported_agent_kinds -// is preferred over the legacy claude_available flag; old daemons may -// still send only claude_available, and the server infers claude_code -// support. +// HeartbeatPayload advertises only explicit engine descriptors. Missing +// supported_agent_kinds establishes no engine availability or capabilities. type HeartbeatPayload struct { Timestamp int64 `json:"ts"` ActiveRequests int `json:"active_requests"` DaemonVersion string `json:"daemon_version,omitempty"` - ClaudeAvailable bool `json:"claude_available,omitempty"` SupportedAgentKinds []SupportedAgentKind `json:"supported_agent_kinds,omitempty"` } diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go index 8644ad91e..9725840e6 100644 --- a/internal/agentdaemon/proto/version.go +++ b/internal/agentdaemon/proto/version.go @@ -1,40 +1,11 @@ package proto -// Protocol version. Bump on any existing-payload-shape change (additive -// optional fields don't need a bump). Daemon sends this in the WS -// upgrade query (`version=`) and in the bootstrap HTTP -// response; mismatches fail closed at WS upgrade. -const ( - Version = "0.7.0" -) +// Version identifies the complete Core–Runtime wire contract. Change it when +// removing or changing a payload or its semantics; deploy both endpoints together. +const Version = "0.8.0" -// VersionCompatible returns true when clientVersion's "X.Y" prefix -// exactly matches Version's. Inputs without a patch segment (e.g. -// "0.1", "1") are rejected so a half-set version string never -// spuriously matches. +// VersionCompatible accepts only this contract. Patch drift, prerelease suffixes +// and malformed versions do not select an implicit compatibility path. func VersionCompatible(clientVersion string) bool { - if clientVersion == "" { - return false - } - clientMM, ok := majorMinor(clientVersion) - if !ok { - return false - } - serverMM, _ := majorMinor(Version) - return clientMM == serverMM -} - -// majorMinor returns the "X.Y" prefix of a semver-shaped version. -// Second return is false when the input has no patch segment. -func majorMinor(v string) (string, bool) { - dots := 0 - for i := 0; i < len(v); i++ { - if v[i] == '.' { - dots++ - if dots == 2 { - return v[:i], true - } - } - } - return "", false + return clientVersion == Version } diff --git a/services/agents-api/internal/execution/runtime_protocol_test.go b/services/agents-api/internal/execution/runtime_protocol_test.go new file mode 100644 index 000000000..02b65a99c --- /dev/null +++ b/services/agents-api/internal/execution/runtime_protocol_test.go @@ -0,0 +1,54 @@ +package execution + +import ( + "context" + "encoding/json" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// Public Turn failure can describe lost orchestration, independently of native +// failure. Late observations must not replace its unknown-effect reason or +// manufacture a native terminal result. +func TestRuntimeProtocolUnknownEffectRetainsObservationFailure(t *testing.T) { + for _, reason := range []string{"delivery_unknown", "event_stream_incomplete"} { + t.Run(reason, func(t *testing.T) { + result := Result{ErrorCode: reason, AppliedThrough: 1} + writer := &recoveringWriter{} + j := journal{store: writer, next: 1} + events := make(chan proto.Envelope, 1) + usage, err := proto.NewEnvelope(proto.TypeUsage, "run", proto.Usage{InputTokens: 7}) + if err != nil { + t.Fatal(err) + } + events <- usage + close(events) + if err := j.drain(events, &result); err != nil { + t.Fatal(err) + } + // A cancellation waiter deadline is not an application receipt. + if err := recordCancellation(context.Background(), &j, cancellationResult{err: context.DeadlineExceeded}, &result); err != nil { + t.Fatal(err) + } + if err := j.flush(context.Background()); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var persisted Result + if err := json.Unmarshal(raw, &persisted); err != nil { + t.Fatal(err) + } + if persisted.ErrorCode != reason || persisted.EngineErrorCode != "" || persisted.EngineHTTPStatus != nil || + persisted.Done.SourceCompletedAtMS != nil || persisted.Done.Usage.InputTokens != 7 || persisted.AppliedThrough != 1 { + t.Fatalf("unknown result changed into native terminal evidence: %+v", persisted) + } + if len(writer.events) != 1 || writer.events[0].Kind != proto.TypeUsage { + t.Fatalf("unknown delivery fabricated completion or cancellation: %+v", writer.events) + } + }) + } +} diff --git a/services/agents-api/internal/execution/support.go b/services/agents-api/internal/execution/support.go index 9982f8f81..37f68a25f 100644 --- a/services/agents-api/internal/execution/support.go +++ b/services/agents-api/internal/execution/support.go @@ -130,9 +130,6 @@ func (p Policy) engineCapabilities(peer *gateway.Session, engine string, snapsho if !caps.Preparation || !caps.LocalEnvironment || !caps.WorkspaceReadPreparation || !caps.WorkspaceOutputExport { return fail("device must advertise local preparation, workspace reads and output export") } - if !caps.LocalEnvironmentNetworkPolicy { - return fail("device must advertise local_environment_network_policy") - } } if snapshot.Environment != nil && snapshot.Environment.Type == "none" && !caps.EnvironmentNone { return fail("device must advertise environment_none") diff --git a/services/agents-api/internal/store/environment_worker_helpers_test.go b/services/agents-api/internal/store/environment_worker_helpers_test.go index 168df067a..a5163e502 100644 --- a/services/agents-api/internal/store/environment_worker_helpers_test.go +++ b/services/agents-api/internal/store/environment_worker_helpers_test.go @@ -26,7 +26,7 @@ func enableWorkerEnvironment(t *testing.T, h *dispatchHarness) { } func workerEnvironmentCapabilities() proto.AgentKindCapabilities { - return proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, EnvironmentNone: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, Preparation: true, LocalEnvironment: true, LocalEnvironmentNetworkPolicy: true, WorkspaceReadPreparation: true, WorkspaceOutputExport: true} + return proto.AgentKindCapabilities{Streaming: true, Steering: true, DurableTurns: true, DurableInputReceipts: true, EnvironmentNone: true, WebSearchControl: true, TextVerbosity: true, ExecutionControls: true, SubagentControl: true, ToolObservations: true, Preparation: true, LocalEnvironment: true, WorkspaceReadPreparation: true, WorkspaceOutputExport: true} } func workerEnvironmentReservation(t *testing.T, h *dispatchHarness) store.EnvironmentInputReservation { diff --git a/services/agents-api/internal/store/local_environment_worker_test.go b/services/agents-api/internal/store/local_environment_worker_test.go index 948a8fc95..b5dfc3381 100644 --- a/services/agents-api/internal/store/local_environment_worker_test.go +++ b/services/agents-api/internal/store/local_environment_worker_test.go @@ -26,7 +26,6 @@ func localWorker(t *testing.T, scoped, execute bool) (*dispatchHarness, *executi } caps := proto.AgentKindCapabilities{LocalEnvironment: true, Preparation: true, WorkspaceReadPreparation: true} if execute { - caps.LocalEnvironmentNetworkPolicy = true caps.WorkspaceOutputExport = true caps.Streaming, caps.Steering, caps.DurableTurns, caps.DurableInputReceipts = true, true, true, true caps.WebSearchControl, caps.TextVerbosity, caps.ExecutionControls = true, true, true From 1b272f8a96e6aca1ee1f36854fe9c19384f6caed Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 17:57:45 +0000 Subject: [PATCH 34/78] Allow the existing Session key in Runtime contract fixtures --- scripts/name-allowlist.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/name-allowlist.json b/scripts/name-allowlist.json index 37c3f3d0d..33861441c 100644 --- a/scripts/name-allowlist.json +++ b/scripts/name-allowlist.json @@ -853,5 +853,10 @@ "path": "apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go", "regex": "\"agents-api-\"", "reason": "AgentStateKey retains the existing daemon/native-session resume identity; capability snapshots bind to the Session UUID carried in that identity." + }, + { + "path": "apps/parsar-daemon/internal/contracttest/wire_test.go", + "regex": "agents-api-session", + "reason": "The controlled Runtime fixture uses the existing Core Session state-key prefix required by execution admission." } ] From 21d67b94360c9089baeb173cbbc8a69cbfa677e0 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:00:09 +0000 Subject: [PATCH 35/78] feat(runtime): add native daemon platforms and unify bypass execution --- .github/workflows/native-daemon.yml | 46 +++ CONTRIBUTING.md | 14 +- Makefile | 1 - .../internal/agent/claudesdk/executor.go | 1 - .../internal/agent/claudesdk/local.go | 12 +- .../internal/agent/claudesdk/local_test.go | 10 +- .../agent/claudesdk/mcp_environment_test.go | 7 +- .../agent/claudesdk/preparation_test.go | 2 +- .../internal/agent/claudesdk/provider.go | 2 +- .../internal/agent/claudesdk/workspace.go | 134 +++---- .../agent/claudesdk/workspace_launch_test.go | 110 +----- .../claudesdk/workspace_live_linux_test.go | 2 +- .../agent/claudesdk/workspace_test.go | 18 +- .../internal/agent/clirunner/process.go | 3 +- .../agent/clirunner/process_group_other.go | 2 +- ...nux_test.go => process_group_unix_test.go} | 22 +- .../agent/clirunner/process_group_windows.go | 168 +++++++++ .../clirunner/process_group_windows_test.go | 141 ++++++++ .../clirunner/process_observe_darwin_test.go | 23 ++ .../clirunner/process_observe_linux_test.go | 30 ++ .../agent/codex/capability_root_test.go | 13 - .../internal/agent/codex/environment.go | 6 - .../internal/agent/codex/environment_local.go | 13 +- .../internal/agent/codex/executor.go | 2 +- .../internal/agent/codex/managed_network.go | 113 ------ .../agent/codex/managed_network_live_test.go | 111 ------ .../agent/codex/managed_network_test.go | 71 ---- .../agent/codex/mcp_environment_test.go | 8 +- .../internal/agent/codex/native_hooks.go | 39 +++ .../internal/agent/codex/options.go | 1 - .../agent/codex/permission_profile.go | 50 +-- .../agent/codex/permission_profile_test.go | 106 ++---- .../internal/agent/codex/preparation.go | 12 +- .../agent/codex/preparation_router_test.go | 3 - .../internal/agent/codex/session.go | 19 +- .../internal/agent/codex/session_plan.go | 47 +-- .../agent/codex/session_policy_test.go | 13 +- .../internal/agent/codex/subagent_profile.go | 11 +- .../agent/codex/subagent_profile_test.go | 4 +- .../internal/agent/codex/tool_environment.go | 97 ------ .../agent/codex/tool_environment_test.go | 108 ------ .../internal/agent/mcode/environment_mcp.go | 2 +- .../agent/mcode/environment_mcp_test.go | 7 +- .../internal/agent/mcode/execution.go | 12 +- .../internal/agent/mcode/execution_test.go | 9 +- .../internal/agent/mcode/executor.go | 1 - .../internal/agent/mcode/preparation_test.go | 4 +- .../internal/agent/mcode/workspace.go | 52 ++- .../agent/mcode/workspace_network_test.go | 40 +-- apps/parsar-daemon/internal/auth/store.go | 23 +- .../parsar-daemon/internal/auth/store_test.go | 9 +- apps/parsar-daemon/internal/cli/claude_sdk.go | 15 +- apps/parsar-daemon/internal/cli/connect.go | 17 +- .../internal/cli/connect_environment.go | 4 +- .../cli/connect_environment_binding.go | 94 ++--- .../cli/connect_environment_binding_test.go | 21 +- .../internal/cli/connect_environment_test.go | 14 +- .../internal/cli/connect_suspend_test.go | 2 + .../internal/cli/mcode_workspace.go | 19 +- .../internal/cli/native_install.go | 140 ++++++++ .../internal/cli/native_install_test.go | 59 ++++ apps/parsar-daemon/internal/cli/root.go | 4 +- apps/parsar-daemon/internal/cli/root_test.go | 2 + .../parsar-daemon/internal/cli/runtime_mcp.go | 19 +- .../internal/cli/runtime_mcp_other.go | 5 - .../internal/cli/runtime_mcp_test.go | 8 +- ...ntime_mcp_linux.go => runtime_mcp_unix.go} | 2 +- .../internal/cli/runtime_mcp_windows.go | 18 + apps/parsar-daemon/internal/cli/stop.go | 14 +- .../internal/cli/suspend_control.go | 2 + .../internal/cli/suspend_process_other.go | 34 +- apps/parsar-daemon/internal/daemonize/fork.go | 51 ++- .../internal/daemonize/fork_test.go | 15 +- .../internal/daemonize/fork_unix.go | 13 + .../internal/daemonize/fork_windows.go | 64 ++++ .../internal/daemonize/helpers_test.go | 112 ++---- .../internal/daemonize/logfile.go | 16 +- .../internal/daemonize/logfile_test.go | 21 +- .../internal/daemonize/notify_unix.go | 16 + .../internal/daemonize/notify_windows.go | 70 ++++ .../internal/daemonize/pidfile.go | 157 ++++----- .../internal/daemonize/pidfile_test.go | 198 ++++------- .../internal/daemonize/process_darwin.go | 52 +++ .../internal/daemonize/process_linux.go | 68 ++++ .../internal/daemonize/process_windows.go | 81 +++++ .../internal/dispatch/local_directory_test.go | 35 +- .../dispatch/native_file_results_test.go | 53 +++ .../dispatch/workspace_export_test.go | 76 ++-- .../internal/dispatch/workspace_write_test.go | 78 ++--- .../internal/localworkspace/binding.go | 69 +--- .../internal/localworkspace/binding_test.go | 21 +- .../internal/localworkspace/capabilities.go | 45 +-- .../capability_preparation_test.go | 23 +- .../internal/localworkspace/directory.go | 27 +- .../localworkspace/directory_native_test.go | 62 ++-- .../internal/localworkspace/export.go | 64 +--- .../internal/localworkspace/export_test.go | 173 ++++----- .../internal/localworkspace/initialization.go | 19 +- .../internal/localworkspace/mcp.go | 10 +- .../internal/localworkspace/mcp_test.go | 7 +- .../internal/localworkspace/native_binding.go | 42 +++ .../internal/localworkspace/native_files.go | 329 ++++++++++++++++++ .../localworkspace/snapshot_marker.go | 69 +--- .../localworkspace/snapshot_marker_test.go | 40 +-- .../internal/localworkspace/write.go | 27 +- .../internal/localworkspace/write_test.go | 191 +++------- apps/parsar-daemon/internal/paths/paths.go | 11 +- .../internal/paths/paths_test.go | 17 +- docs/api/public-agent-api.md | 5 +- docs/design-principles.md | 16 + docs/getting-started/README.md | 1 + docs/getting-started/operations.md | 13 +- docs/getting-started/self-hosted.md | 209 +++++------ docs/self-hosted-native.md | 139 ++++++++ docs/web/protocol-coverage.md | 6 + internal/agentcapabilities/manifest.go | 7 +- internal/agentcapabilities/tree.go | 8 +- internal/agentdaemon/proto/environment.go | 2 + internal/runtimefs/runtimefs.go | 148 ++++++++ internal/runtimefs/runtimefs_test.go | 117 +++++++ internal/runtimefs/runtimefs_unix.go | 40 +++ internal/runtimefs/runtimefs_unix_test.go | 46 +++ internal/runtimefs/runtimefs_windows.go | 54 +++ internal/runtimefs/runtimefs_windows_test.go | 16 + packages/claude-sdk-adapter/SUBAGENTS.md | 28 +- .../claude-sdk-adapter/src/mcp_environment.ts | 27 +- .../claude-sdk-adapter/src/runtime_check.ts | 2 +- packages/claude-sdk-adapter/src/workspace.ts | 112 ++---- .../src/workspace_directories.ts | 31 +- .../tests/mcp_workspace.test.mjs | 35 +- .../tests/preparation.test.mjs | 6 +- .../tests/workspace.test.mjs | 142 ++------ .../tests/workspace_directories.test.mjs | 45 +-- .../tests/workspace_execution.test.mjs | 8 +- packages/mcode-harness/bridge.mjs | 3 +- packages/mcode-harness/build-sandbox.mjs | 18 - packages/mcode-harness/check.mjs | 8 +- packages/mcode-harness/launch.mjs | 24 +- packages/mcode-harness/launch.test.mjs | 20 ++ packages/mcode-harness/sandbox-entry.ts | 1 - packages/mcode-harness/tool-executor.mjs | 4 +- packages/mcode-harness/worker.ts | 16 - scripts/build-agents-runtime.sh | 2 - scripts/build-claude-runtime.sh | 1 - scripts/build-mcode-harness.sh | 2 - scripts/core-distribution-manifest.py | 5 - services/agents-api/deploy/claude/Dockerfile | 1 - .../agents-api/deploy/claude/shell-prefix.py | 29 -- .../deploy/claude/shell_prefix_test.py | 33 -- services/agents-api/deploy/codex/Dockerfile | 7 +- .../agents-api/deploy/codex/requirements.toml | 42 --- services/agents-api/deploy/codex/tool-env.py | 29 -- .../agents-api/deploy/runtime/initialize.py | 229 +++--------- .../deploy/runtime/initialize_receipt_test.py | 219 +++++------- .../deploy/runtime/initialize_stdio_test.py | 150 -------- .../deploy/runtime/initialize_test.py | 56 +-- .../agents-api/internal/execution/support.go | 3 - 157 files changed, 3389 insertions(+), 3445 deletions(-) create mode 100644 .github/workflows/native-daemon.yml rename apps/parsar-daemon/internal/agent/clirunner/{process_group_linux_test.go => process_group_unix_test.go} (91%) create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process_group_windows_test.go create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process_observe_darwin_test.go create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process_observe_linux_test.go delete mode 100644 apps/parsar-daemon/internal/agent/codex/capability_root_test.go delete mode 100644 apps/parsar-daemon/internal/agent/codex/managed_network.go delete mode 100644 apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go delete mode 100644 apps/parsar-daemon/internal/agent/codex/managed_network_test.go create mode 100644 apps/parsar-daemon/internal/agent/codex/native_hooks.go delete mode 100644 apps/parsar-daemon/internal/agent/codex/tool_environment.go delete mode 100644 apps/parsar-daemon/internal/agent/codex/tool_environment_test.go create mode 100644 apps/parsar-daemon/internal/cli/native_install.go create mode 100644 apps/parsar-daemon/internal/cli/native_install_test.go delete mode 100644 apps/parsar-daemon/internal/cli/runtime_mcp_other.go rename apps/parsar-daemon/internal/cli/{runtime_mcp_linux.go => runtime_mcp_unix.go} (96%) create mode 100644 apps/parsar-daemon/internal/cli/runtime_mcp_windows.go create mode 100644 apps/parsar-daemon/internal/daemonize/fork_unix.go create mode 100644 apps/parsar-daemon/internal/daemonize/fork_windows.go create mode 100644 apps/parsar-daemon/internal/daemonize/notify_unix.go create mode 100644 apps/parsar-daemon/internal/daemonize/notify_windows.go create mode 100644 apps/parsar-daemon/internal/daemonize/process_darwin.go create mode 100644 apps/parsar-daemon/internal/daemonize/process_linux.go create mode 100644 apps/parsar-daemon/internal/daemonize/process_windows.go create mode 100644 apps/parsar-daemon/internal/dispatch/native_file_results_test.go create mode 100644 apps/parsar-daemon/internal/localworkspace/native_binding.go create mode 100644 apps/parsar-daemon/internal/localworkspace/native_files.go create mode 100644 docs/self-hosted-native.md create mode 100644 internal/runtimefs/runtimefs.go create mode 100644 internal/runtimefs/runtimefs_test.go create mode 100644 internal/runtimefs/runtimefs_unix.go create mode 100644 internal/runtimefs/runtimefs_unix_test.go create mode 100644 internal/runtimefs/runtimefs_windows.go create mode 100644 internal/runtimefs/runtimefs_windows_test.go delete mode 100644 packages/mcode-harness/build-sandbox.mjs create mode 100644 packages/mcode-harness/launch.test.mjs delete mode 100644 packages/mcode-harness/sandbox-entry.ts delete mode 100644 services/agents-api/deploy/claude/shell-prefix.py delete mode 100644 services/agents-api/deploy/claude/shell_prefix_test.py delete mode 100644 services/agents-api/deploy/codex/requirements.toml delete mode 100644 services/agents-api/deploy/codex/tool-env.py delete mode 100644 services/agents-api/deploy/runtime/initialize_stdio_test.py diff --git a/.github/workflows/native-daemon.yml b/.github/workflows/native-daemon.yml new file mode 100644 index 000000000..1c93ddd2b --- /dev/null +++ b/.github/workflows/native-daemon.yml @@ -0,0 +1,46 @@ +name: native-daemon + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + native: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-14, windows-2022] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Build the native daemon + run: go build -o "$RUNNER_TEMP/oac-daemon${{ runner.os == 'Windows' && '.exe' || '' }}" ./apps/parsar-daemon/cmd/parsar-daemon + - name: Native filesystem, authentication and process lifecycle + run: >- + go test -race -count=1 + ./internal/runtimefs + ./apps/parsar-daemon/internal/auth + ./apps/parsar-daemon/internal/paths + ./apps/parsar-daemon/internal/daemonize + ./apps/parsar-daemon/internal/agent/clirunner + - name: Native capability snapshots, files and installation + run: >- + go test -race -count=1 + ./apps/parsar-daemon/internal/localworkspace + ./apps/parsar-daemon/internal/cli + -run 'TestNative|TestPreparationFreezesLocalContentsAcrossReconnect|TestSnapshotMarker' + - uses: actions/upload-artifact@v6 + with: + name: oac-daemon-${{ runner.os }}-${{ runner.arch }} + path: ${{ runner.temp }}/oac-daemon* + if-no-files-found: error diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bcc4d162..36658674e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,9 +34,17 @@ local directories. Core resolves configuration, versions and resources; Runtime uses one parser and `installed.json` snapshot to supply Skill paths and MCP declarations to adapters. Preparation must complete before execution; reconnect reuses installed contents, and new Sessions freeze a new -configuration snapshot. The protocol is platform-neutral; the implementation uses the existing packaged -Linux Runtime layout for both environment origins. Windows/macOS installation and -isolation remain separate work. +configuration snapshot. The protocol is platform-neutral. Self-hosted daemons run natively on Linux, +Windows and macOS; managed Providers remain Linux-only. The daemon executes with +its launching user's permissions. It does not sandbox tools, files or networks, +including on Linux. Managed isolation belongs to the outer Docker/E2B Environment, +created by Core through a Provider. Harness adapters use bypass execution on all +platforms. Runtime authentication, process cleanup and state consistency remain +required; none constitutes isolation from tools running as the same user. +Installation layout may differ, but Core preparation and execution cannot branch +on operating system or Environment source. Windows acceptance requires native CI +builds and automated tests; cross-compilation alone is insufficient. Record the +absence of manual Windows acceptance until an actual machine is tested. - Keep component boundaries explicit through shared interfaces and versioned protocols. Register implementations behind those interfaces. Adding an diff --git a/Makefile b/Makefile index d72d18bdd..0fc278baf 100644 --- a/Makefile +++ b/Makefile @@ -73,7 +73,6 @@ node-deps: pnpm install --frozen-lockfile check-claude-sdk: node-deps - python3 services/agents-api/deploy/claude/shell_prefix_test.py pnpm --filter @parsar/claude-sdk-adapter test $(MAKE) build-claude-sdk-runtime diff --git a/apps/parsar-daemon/internal/agent/claudesdk/executor.go b/apps/parsar-daemon/internal/agent/claudesdk/executor.go index 0abc0ca47..beef6fb3e 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/executor.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/executor.go @@ -29,7 +29,6 @@ func NewExecutorFactory(config Config) agent.ExecutorFactory { config.Env = slices.Clone(config.Env) if config.Workspace != nil { workspace := *config.Workspace - workspace.ProtectedDirs = slices.Clone(workspace.ProtectedDirs) config.Workspace = &workspace } checked := &runtimeCheckCache{} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local.go b/apps/parsar-daemon/internal/agent/claudesdk/local.go index a4e8d1888..1a0bb2ab8 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/local.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/local.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" ) @@ -14,11 +15,12 @@ import ( func ConfigureLocal(config Config, root, workspace string, network agentnetwork.Policy, staging string) (Config, error) { config.StateDir = filepath.Join(root, "runtime", "claude-sdk", "history") config.Workspace = &WorkspaceConfig{ - Directory: workspace, PublicDirectory: "/workspace", NetworkAccess: network.Access, AllowedDomains: network.Hosts(), - HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"), - ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"), - ProtectedDirs: []string{filepath.Join(root, "daemon"), staging}, - DependencyPath: "/usr/local/bin:/usr/bin:/bin", + Directory: workspace, PublicDirectory: workspace, NetworkAccess: network.Access, AllowedDomains: network.Hosts(), + HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"), + ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"), + } + if binding, err := localworkspace.Load(); err == nil && binding != nil { + config.Workspace.ExecutionMode = binding.ExecutionMode() } if network.Validate() != nil { return Config{}, fmt.Errorf("claudesdk: dedicated Runtime requires an explicit network policy") diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local_test.go b/apps/parsar-daemon/internal/agent/claudesdk/local_test.go index 51c7ca7b0..819fdac36 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/local_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/local_test.go @@ -36,9 +36,10 @@ func TestLocalWorkspaceBindingNetworkAndRequiredHistory(t *testing.T) { t.Fatal(err) } config.Workspace.PublicDirectory = alias - if _, _, err := prepare(config, req); err == nil { - t.Fatal("accepted mutable workspace alias") + if _, _, err := prepare(config, req); err != nil { + t.Fatal("same workspace alias rejected", err) } + } func TestRestrictedWorkspacePolicyUsesExactBoundAuthority(t *testing.T) { @@ -47,9 +48,8 @@ func TestRestrictedWorkspacePolicyUsesExactBoundAuthority(t *testing.T) { config.Workspace.AllowedDomains = []string{"Example.com", "api.example.com", "example.com"} req := workspaceRequest() req.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "restricted", AllowedDomains: []string{"api.example.com", "EXAMPLE.COM"}} - start, _, err := prepare(config, req) - if err != nil || !slices.Equal(start.Workspace.AllowedDomains, []string{"api.example.com", "example.com"}) { - t.Fatal("native policy lost exact bound domains", start, err) + if _, _, err := prepare(config, req); err == nil { + t.Fatal("Runtime must not promise inner network isolation") } for _, domains := range [][]string{{"example.com"}, {"example.org"}, nil} { req.LocalEnvironment.AllowedDomains = domains diff --git a/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go b/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go index e3310414b..6cd293b29 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/mcp_environment_test.go @@ -2,6 +2,7 @@ package claudesdk import ( "encoding/json" + "os" "strings" "testing" @@ -27,7 +28,8 @@ func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) t.Fatal("environment declarations changed authority") } stdio := start.Workspace.MCP[0] - if stdio.Command != "/usr/bin/python3" || len(stdio.Args) != 8 || stdio.Args[4] != "/private/runtime/capabilities" || stdio.Args[5] != "/private/runtime/workspace" || stdio.Args[6] != "plugins/local" || stdio.Args[7] != "local" { + executable, _ := os.Executable() + if stdio.Command != executable || len(stdio.Args) != 4 || stdio.Args[0] != "runtime-mcp-exec" || stdio.Args[1] != "/private/runtime/capabilities" || stdio.Args[2] != "plugins/local" || stdio.Args[3] != "local" { t.Fatal("stdio bypassed the shared installed entry") } raw, _ := json.Marshal(start) @@ -42,9 +44,6 @@ func TestEnvironmentMCPUsesInstalledLauncherAndSelectedCredential(t *testing.T) if entry == reference+"="+token { found = true } - if strings.Contains(entry, "unselected-native-token") { - t.Fatal("inherited native credential") - } } if !found || !strings.HasPrefix(reference, "OAC_RUNTIME_MCP_BEARER_") || len(start.declaredMCP()) != 2 { t.Fatal("selected credential or observation declarations missing") diff --git a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go index a5e1406b6..4ca35e982 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/preparation_test.go @@ -68,7 +68,7 @@ func TestPreparationWaitsForReceiptAndRetainsConfiguration(t *testing.T) { } config.Env[0] = "ANTHROPIC_AUTH_TOKEN=changed" config.Workspace.Directory = "/changed" - config.Workspace.ProtectedDirs[0] = "/changed" + config.Workspace.Directory = "/changed" req.AgentOptions["model"] = "changed" req.AgentSessionID = "changed" out := make(chan proto.Envelope, 16) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/provider.go b/apps/parsar-daemon/internal/agent/claudesdk/provider.go index 45f6b3621..308d45bca 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/provider.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/provider.go @@ -7,7 +7,7 @@ import ( ) // Provider options are transient operator input, never public Agent fields or -// native tool environment. The workspace sandbox removes these variables. +// native process environment. Isolation is the outer environment's responsibility. func providerEnvironment(value any) ([]string, error) { fail := func() ([]string, error) { return nil, fmt.Errorf("claudesdk: invalid provider configuration") } options, ok := value.(map[string]any) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index 42356a754..195581069 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" @@ -16,37 +15,34 @@ import ( // WorkspaceConfig binds one trusted private placement. It does not create an // isolation boundary or authorize a public Environment. The operator must place // the entire factory inside the qualified outer mount/process boundary first. -// State directories must already exist, be canonical and be mutually disjoint. +// State directories must already exist. // PublicDirectory may name a second mount of the same workspace inode. type WorkspaceConfig struct { + ExecutionMode string Directory string PublicDirectory string NetworkAccess string AllowedDomains []string HomeDir string ScratchDir string - ProtectedDirs []string - DependencyPath string } type workspaceProfile struct { - CapabilityRoot string `json:"capability_root,omitempty"` - MCP []environmentMCPServer `json:"mcp,omitempty"` - Skills []agentcapabilities.InstalledSkill `json:"skills,omitempty"` - ToolEnvironment bool `json:"tool_environment,omitempty"` - SystemPackages bool `json:"system_packages,omitempty"` - Home string `json:"home"` - State string `json:"state"` - Scratch string `json:"scratch"` - ProtectedDirs []string `json:"protected_dirs"` - DependencyPath string `json:"dependency_path"` - EnvNames []string `json:"env_names"` - NetworkAccess string `json:"network_access,omitempty"` - AllowedDomains []string `json:"allowed_domains,omitempty"` + ToolEnv map[string]string `json:"tool_env,omitempty"` + ExecutionMode string `json:"execution_mode,omitempty"` + CapabilityRoot string `json:"capability_root,omitempty"` + MCP []environmentMCPServer `json:"mcp,omitempty"` + Skills []agentcapabilities.InstalledSkill `json:"skills,omitempty"` + Home string `json:"home"` + State string `json:"state"` + Scratch string `json:"scratch"` + EnvNames []string `json:"env_names"` + NetworkAccess string `json:"network_access,omitempty"` + AllowedDomains []string `json:"allowed_domains,omitempty"` } func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { - if req.DisableExecutionEnvironment || req.MCPHTTPServers != nil { + if req.DisableExecutionEnvironment || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && req.LocalEnvironment.ExecutionMode != config.Workspace.ExecutionMode) { return nil, nil, fmt.Errorf("claudesdk: workspace profile does not support the requested execution combination") } if req.WorkDir != "" && req.WorkDir != config.Workspace.Directory { @@ -60,12 +56,16 @@ func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspace return nil, nil, err } if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { - if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { + binding, loadErr := localworkspace.Load() + if loadErr != nil || binding == nil { + return nil, nil, fmt.Errorf("claudesdk: native Runtime binding unavailable") + } + profile.ToolEnv, err = binding.ReadToolEnvironment() + if err != nil { return nil, nil, err } - profile.ToolEnvironment = true - profile.SystemPackages = req.LocalEnvironment.SystemPackages } + if req.LocalEnvironment != nil { profile.Skills = req.LocalEnvironment.Skills profile.CapabilityRoot = req.LocalEnvironment.CapabilityRoot @@ -92,83 +92,36 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { return nil, nil, fmt.Errorf("claudesdk: invalid trusted workspace configuration") } w := config.Workspace - if w == nil || !filepath.IsAbs(config.Node) || !filepath.IsAbs(config.Entrypoint) { - return fail() - } - if (w.NetworkAccess != "" || len(w.AllowedDomains) > 0) && (agentnetwork.Policy{Access: w.NetworkAccess, AllowedDomains: w.AllowedDomains}).Validate() != nil { + if w == nil || (w.ExecutionMode != "" && w.ExecutionMode != "native") || (w.NetworkAccess != "" && w.NetworkAccess != "enabled") || len(w.AllowedDomains) != 0 { return fail() } - if w.PublicDirectory != "" { - actual, err := os.Stat(w.Directory) - alias, aliasErr := os.Stat(w.PublicDirectory) - if !canonicalWorkspaceDir(w.Directory) || !canonicalWorkspaceDir(w.PublicDirectory) || err != nil || aliasErr != nil || !os.SameFile(actual, alias) { + for _, path := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { + info, err := os.Stat(path) + if !filepath.IsAbs(path) || err != nil || !info.Mode().IsRegular() { return fail() } } - runtimeDir := filepath.Dir(filepath.Dir(config.Entrypoint)) - if config.Entrypoint != filepath.Join(runtimeDir, "dist", "main.js") || !canonicalWorkspaceDir(runtimeDir) { - return fail() - } - // Keep the exact paths used by execution and readiness outside mutable roots. - // A symlinked entrypoint must not select an unchecked sibling companion. - codePaths := []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} - for _, path := range codePaths { - resolved, err := filepath.EvalSymlinks(path) - info, statErr := os.Stat(path) - if err != nil || resolved != path || statErr != nil || !info.Mode().IsRegular() { + for _, path := range []string{workspaceCwd(w), config.StateDir, w.HomeDir, w.ScratchDir} { + if !canonicalWorkspaceDir(path) { return fail() } } - roots := append([]string{workspaceCwd(w), config.StateDir, w.HomeDir, w.ScratchDir}, w.ProtectedDirs...) - for i, dir := range roots { - if !canonicalWorkspaceDir(dir) || pathContains(runtimeDir, dir) || pathContains(dir, runtimeDir) { + if w.PublicDirectory != "" { + actual, err := os.Stat(w.Directory) + alias, aliasErr := os.Stat(w.PublicDirectory) + if err != nil || aliasErr != nil || !os.SameFile(actual, alias) { return fail() } - for _, previous := range roots[:i] { - if pathContains(previous, dir) || pathContains(dir, previous) { - return fail() - } - } - for _, executable := range codePaths { - if pathContains(dir, executable) { - return fail() - } - } - } - managed, err := paths.Root() - if err != nil { - return fail() - } - managed, err = filepath.EvalSymlinks(managed) - if err != nil || managed == config.StateDir || !pathContains(managed, config.StateDir) { - return fail() } - if w.DependencyPath == "" { - return fail() - } - var dependencies []string - for _, dir := range filepath.SplitList(w.DependencyPath) { - info, err := os.Stat(dir) - if !workspacePathSyntax(dir) || err != nil || !info.IsDir() { - return fail() - } - resolved, err := filepath.EvalSymlinks(dir) - if err != nil { - return fail() + profile := &workspaceProfile{ExecutionMode: w.ExecutionMode, Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, EnvNames: []string{}, NetworkAccess: w.NetworkAccess, AllowedDomains: []string{}} + env := []string{} + for _, entry := range os.Environ() { + name, _, _ := strings.Cut(entry, "=") + if name != "ANTHROPIC_API_KEY" && name != "ANTHROPIC_AUTH_TOKEN" && name != "CLAUDE_CODE_OAUTH_TOKEN" && name != "CLAUDE_CONFIG_DIR" && name != "HOME" && name != "TMPDIR" { + env = append(env, entry) } - for _, root := range roots { - if pathContains(root, dir) || pathContains(dir, root) || pathContains(root, resolved) || pathContains(resolved, root) { - return fail() - } - } - dependencies = append(dependencies, resolved) - } - dependencyPath := strings.Join(dependencies, string(os.PathListSeparator)) - profile := &workspaceProfile{Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, - ProtectedDirs: append([]string{}, w.ProtectedDirs...), DependencyPath: dependencyPath, EnvNames: []string{}, NetworkAccess: w.NetworkAccess, AllowedDomains: (agentnetwork.Policy{Access: w.NetworkAccess, AllowedDomains: w.AllowedDomains}).Hosts()} - env := []string{"PATH=" + dependencyPath, "HOME=" + w.HomeDir, "TMPDIR=" + w.ScratchDir, - "CLAUDE_CONFIG_DIR=" + config.StateDir, "DISABLE_TELEMETRY=1", "DISABLE_ERROR_REPORTING=1", - "DISABLE_AUTOUPDATER=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1"} + } + env = append(env, "HOME="+w.HomeDir, "TMPDIR="+w.ScratchDir, "CLAUDE_CONFIG_DIR="+config.StateDir, "DISABLE_TELEMETRY=1", "DISABLE_ERROR_REPORTING=1", "DISABLE_AUTOUPDATER=1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1", "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1") seen := map[string]bool{} for _, entry := range config.Env { name, _, ok := strings.Cut(entry, "=") @@ -197,19 +150,12 @@ func canonicalWorkspaceDir(dir string) bool { if !workspacePathSyntax(dir) { return false } - resolved, err := filepath.EvalSymlinks(dir) - if err != nil || resolved != dir { - return false - } info, err := os.Stat(dir) return err == nil && info.IsDir() } - func workspacePathSyntax(dir string) bool { - return filepath.IsAbs(dir) && filepath.Clean(dir) == dir && dir != string(filepath.Separator) && - !strings.ContainsAny(dir, "*?[]{}():\\") && strings.IndexFunc(dir, func(r rune) bool { return r < 32 || r == 127 }) == -1 + return filepath.IsAbs(dir) && filepath.Clean(dir) == dir && strings.IndexFunc(dir, func(r rune) bool { return r < 32 || r == 127 }) == -1 } - func pathContains(parent, child string) bool { rel, err := filepath.Rel(parent, child) return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go index 5a3c4ad98..d24d0211c 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_launch_test.go @@ -11,14 +11,14 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -func TestWorkspaceLaunchAndReadinessExcludeParentEnvironment(t *testing.T) { +func TestWorkspaceLaunchAndReadinessInheritsUserEnvironment(t *testing.T) { config := workspaceFixture(t) t.Setenv("OAC_TEST_PARENT_SECRET", "must-not-inherit") t.Setenv("ANTHROPIC_API_KEY", "unselected") // An owned process fixture verifies both real subprocess launch paths; it is // not a native sandbox or provider acceptance test. script := `#!/bin/sh -test -z "${OAC_TEST_PARENT_SECRET+x}" || exit 21 +test "$OAC_TEST_PARENT_SECRET" = must-not-inherit || exit 21 test -z "${ANTHROPIC_API_KEY+x}" || exit 22 test "$ANTHROPIC_AUTH_TOKEN" = selected-provider-fixture || exit 23 test "$TMPDIR" != "$CLAUDE_CONFIG_DIR/tmp" || exit 24 @@ -71,109 +71,3 @@ esac t.Fatal("old packaged bridge started execution") } } - -func TestWorkspaceRejectsMutableRuntimeAliasesBeforeReadiness(t *testing.T) { - for _, name := range []string{"node", "entrypoint", "scratch-entrypoint", "companion", "dependencies"} { - t.Run(name, func(t *testing.T) { - config := workspaceFixture(t) - marker := filepath.Join(config.Workspace.Directory, "unexpected-launch") - if err := os.WriteFile(config.Node, []byte("#!/bin/sh\nprintf started > '"+marker+"'\n"), 0o700); err != nil { - t.Fatal(err) - } - link, target := "", "" - switch name { - case "node": - link, target = filepath.Join(config.Workspace.Directory, "node"), config.Node - config.Node = link - case "entrypoint", "scratch-entrypoint": - dir := config.Workspace.Directory - if name == "scratch-entrypoint" { - dir = config.Workspace.ScratchDir - } - link, target = filepath.Join(dir, "main.js"), config.Entrypoint - config.Entrypoint = link - if err := os.WriteFile(filepath.Join(dir, "runtime_check.js"), []byte("untrusted companion"), 0o700); err != nil { - t.Fatal(err) - } - case "companion": - link, target = filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js"), filepath.Join(config.Workspace.Directory, "companion.js") - if err := os.Remove(link); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(target, []byte("untrusted companion"), 0o700); err != nil { - t.Fatal(err) - } - case "dependencies": - link, target = filepath.Join(config.Workspace.Directory, "deps"), config.Workspace.DependencyPath - config.Workspace.DependencyPath = link - } - if err := os.Symlink(target, link); err != nil { - t.Fatal(err) - } - if _, err := CheckRuntime(t.Context(), config); err == nil { - t.Fatal("mutable runtime alias passed readiness") - } - if _, err := os.Stat(marker); !os.IsNotExist(err) { - t.Fatal("invalid binding launched a credential-bearing child") - } - }) - } -} - -func TestWorkspaceUsesCanonicalDependencyPaths(t *testing.T) { - config := workspaceFixture(t) - canonical := config.Workspace.DependencyPath - alias := filepath.Join(filepath.Dir(config.StateDir), "dependency-alias") - if err := os.Symlink(canonical, alias); err != nil { - t.Fatal(err) - } - config.Workspace.DependencyPath = alias - start, env, err := prepare(config, workspaceRequest()) - if err != nil { - t.Fatal(err) - } - if start.Workspace.DependencyPath != canonical || env[0] != "PATH="+canonical { - t.Fatal("trusted launch retained a mutable dependency alias") - } -} - -func TestWorkspaceRejectsPackagedRuntimeOverlapBeforeReadiness(t *testing.T) { - for _, name := range []string{"workspace", "state", "home", "scratch", "protected", "runtime-root", "runtime-parent", "unexpected-layout"} { - t.Run(name, func(t *testing.T) { - config := workspaceFixture(t) - marker := filepath.Join(filepath.Dir(config.StateDir), "unexpected-launch") - if err := os.WriteFile(config.Node, []byte("#!/bin/sh\nprintf started > '"+marker+"'\n"), 0o700); err != nil { - t.Fatal(err) - } - runtimeDir := filepath.Dir(filepath.Dir(config.Entrypoint)) - dependencies := filepath.Join(runtimeDir, "node_modules") - switch name { - case "workspace": - config.Workspace.Directory = dependencies - case "state": - config.StateDir = dependencies - case "home": - config.Workspace.HomeDir = dependencies - case "scratch": - config.Workspace.ScratchDir = dependencies - case "protected": - config.Workspace.ProtectedDirs = []string{dependencies} - case "runtime-root": - config.Workspace.Directory = runtimeDir - case "runtime-parent": - config.Workspace.Directory = filepath.Dir(runtimeDir) - case "unexpected-layout": - config.Entrypoint = filepath.Join(filepath.Dir(config.Entrypoint), "other.js") - if err := os.WriteFile(config.Entrypoint, nil, 0o700); err != nil { - t.Fatal(err) - } - } - if _, err := CheckRuntime(t.Context(), config); err == nil { - t.Fatal("invalid runtime binding passed readiness") - } - if _, err := os.Stat(marker); !os.IsNotExist(err) { - t.Fatal("invalid runtime binding launched a credential-bearing child") - } - }) - } -} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go index c2a27cc19..c55a0211e 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_live_linux_test.go @@ -57,7 +57,7 @@ func testLiveClaudeWorkspace(t *testing.T, explicitPreparation bool) { } config := Config{Node: placement.Node, Entrypoint: placement.Entrypoint, StateDir: filepath.Join(root, "state"), Workspace: &WorkspaceConfig{Directory: filepath.Join(root, "workspace"), HomeDir: filepath.Join(root, "home"), - ScratchDir: scratch, ProtectedDirs: []string{filepath.Dir(placement.KeyFile)}, DependencyPath: placement.DependencyPath}, + ScratchDir: scratch}, Env: []string{"ANTHROPIC_BASE_URL=https://api.minimax.cn/anthropic", "ANTHROPIC_API_KEY=", "ANTHROPIC_AUTH_TOKEN=" + strings.TrimSpace(string(key)), "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1", "ANTHROPIC_DEFAULT_SONNET_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_OPUS_MODEL=MiniMax-M3", "ANTHROPIC_DEFAULT_HAIKU_MODEL=MiniMax-M3"}} if placement.Proxy != "" { diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go index ef39f675f..04e2fcfa2 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace_test.go @@ -25,7 +25,7 @@ func workspaceFixture(t *testing.T) Config { config := Config{Node: filepath.Join(root, "bin", "node"), Entrypoint: filepath.Join(root, "runtime", "dist", "main.js"), StateDir: filepath.Join(root, "state"), Env: []string{"ANTHROPIC_AUTH_TOKEN=selected-provider-fixture", "ANTHROPIC_BASE_URL=https://example.invalid"}, Workspace: &WorkspaceConfig{Directory: filepath.Join(root, "workspace"), HomeDir: filepath.Join(root, "home"), - ScratchDir: filepath.Join(root, "scratch"), ProtectedDirs: []string{filepath.Join(root, "secrets")}, DependencyPath: filepath.Join(root, "bin")}} + ScratchDir: filepath.Join(root, "scratch")}} for _, name := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { if err := os.WriteFile(name, nil, 0o700); err != nil { t.Fatal(err) @@ -59,8 +59,8 @@ func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { name, value, _ := strings.Cut(item, "=") values[name] = value } - if _, ok := values["OAC_TEST_PARENT_SECRET"]; ok { - t.Fatal("inherited parent credential") + if values["OAC_TEST_PARENT_SECRET"] != "parent-only" { + t.Fatal("lost user environment") } if _, ok := values["ANTHROPIC_API_KEY"]; ok { t.Fatal("inherited unselected provider credential") @@ -76,7 +76,7 @@ func TestWorkspaceTrustedBindingAndEnvironment(t *testing.T) { } func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { - for _, name := range []string{"none", "work-dir", "mcp", "caller-policy", "relative", "missing", "overlap", "symlink", "rule-pattern", "ambient-setting", "duplicate-env", "bad-env", "path-empty-component", "path-workspace", "code-in-workspace"} { + for _, name := range []string{"none", "work-dir", "mcp", "caller-policy", "relative", "missing", "ambient-setting", "duplicate-env", "bad-env"} { t.Run(name, func(t *testing.T) { config := workspaceFixture(t) req := workspaceRequest() @@ -112,15 +112,7 @@ func TestWorkspaceRejectsConflictsBeforeSideEffects(t *testing.T) { config.Env = append(config.Env, "ANTHROPIC_AUTH_TOKEN=second") case "bad-env": config.Env = append(config.Env, "ANTHROPIC_API_KEY=bad\x00value") - case "path-empty-component": - config.Workspace.DependencyPath += ":" - case "path-workspace": - config.Workspace.DependencyPath = config.Workspace.Directory - case "code-in-workspace": - config.Node = filepath.Join(config.Workspace.Directory, "node") - if err := os.WriteFile(config.Node, nil, 0o700); err != nil { - t.Fatal(err) - } + } if _, _, err := prepare(config, req); err == nil { t.Fatal("invalid binding or request accepted") diff --git a/apps/parsar-daemon/internal/agent/clirunner/process.go b/apps/parsar-daemon/internal/agent/clirunner/process.go index 384c2ae20..e693df7f5 100644 --- a/apps/parsar-daemon/internal/agent/clirunner/process.go +++ b/apps/parsar-daemon/internal/agent/clirunner/process.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os/exec" + "runtime" "sync" "syscall" "time" @@ -50,7 +51,7 @@ func Start(opts StartOptions) (*Process, error) { opts.KillTimeout = 3 * time.Second } - if opts.OwnProcessGroup { + if opts.OwnProcessGroup || runtime.GOOS == "windows" { return startProcessGroup(opts) } diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go index 8160c61a4..d5763742b 100644 --- a/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_other.go @@ -1,4 +1,4 @@ -//go:build !unix +//go:build !unix && !windows package clirunner diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_linux_test.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_unix_test.go similarity index 91% rename from apps/parsar-daemon/internal/agent/clirunner/process_group_linux_test.go rename to apps/parsar-daemon/internal/agent/clirunner/process_group_unix_test.go index d1e246aba..8a3cbf162 100644 --- a/apps/parsar-daemon/internal/agent/clirunner/process_group_linux_test.go +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_unix_test.go @@ -1,4 +1,4 @@ -//go:build linux +//go:build linux || darwin package clirunner @@ -12,7 +12,6 @@ import ( "os/signal" "path/filepath" "strconv" - "strings" "syscall" "testing" "time" @@ -163,25 +162,6 @@ func waitChild(t *testing.T, dir string) int { return 0 } -func waitExited(t *testing.T, pid int) { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - for time.Now().Before(deadline) { - value, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) - if os.IsNotExist(err) { - return - } - if err == nil { - fields := strings.Fields(string(value)[strings.LastIndex(string(value), ")")+1:]) - if len(fields) > 0 && fields[0] == "Z" { - return - } - } - time.Sleep(5 * time.Millisecond) - } - t.Fatalf("child %d is still running", pid) -} - func TestOwnedGroupHelper(t *testing.T) { if os.Getenv("GO_WANT_OWNED_GROUP") != "1" { return diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go new file mode 100644 index 000000000..796079db9 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go @@ -0,0 +1,168 @@ +//go:build windows + +package clirunner + +import ( + "context" + "fmt" + "os" + "os/exec" + "sync" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +// The child cannot execute user code until it belongs to the non-inheritable Job. +// Closing the daemon's last Job handle also kills children after a daemon crash. +func startProcessGroup(opts StartOptions) (*Process, error) { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return nil, err + } + limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + limits.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if _, err = windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil { + windows.CloseHandle(job) + return nil, err + } + ctx, cancel := context.WithCancel(opts.Parent) + cmd := exec.CommandContext(ctx, opts.Binary, opts.Args...) + cmd.Dir = opts.Dir + if len(opts.Env) > 0 { + cmd.Env = append([]string{}, opts.Env...) + } + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED | windows.CREATE_NEW_PROCESS_GROUP} + group := &ownedJob{handle: job} + cmd.Cancel = group.cancel + p := &Process{Cmd: cmd, ctx: ctx, cancel: cancel, done: make(chan struct{}), cancelProcess: group.cancel} + stdout, stdoutWriter, err := os.Pipe() + if err != nil { + cancel() + windows.CloseHandle(job) + return nil, err + } + stderr, stderrWriter, err := os.Pipe() + if err != nil { + cancel() + windows.CloseHandle(job) + stdout.Close() + stdoutWriter.Close() + return nil, err + } + p.Stdout, p.Stderr = stdout, stderr + cmd.Stdout, cmd.Stderr = stdoutWriter, stderrWriter + if opts.NeedStdin { + p.Stdin, err = cmd.StdinPipe() + } + if err == nil { + err = cmd.Start() + } + stdoutWriter.Close() + stderrWriter.Close() + if err == nil { + group.mu.Lock() + if group.cancelled { + err = context.Canceled + } + process, openErr := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(cmd.Process.Pid)) + if openErr != nil { + err = openErr + } + if err == nil { + err = windows.AssignProcessToJobObject(job, process) + } + if openErr == nil { + windows.CloseHandle(process) + } + if err == nil { + err = resumeInitialThread(uint32(cmd.Process.Pid)) + } + group.mu.Unlock() + } + if err != nil { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + cancel() + _ = group.cancel() + group.finish() + closePipe(p.Stdin) + stdout.Close() + stderr.Close() + return nil, fmt.Errorf("clirunner: owned Windows process: %w", err) + } + var waitErr error + p.waitProcess = func() error { <-p.done; stdout.Close(); stderr.Close(); return waitErr } + go func() { + waitErr = cmd.Wait() + // A successful leader exit cannot leave detached descendants behind. + _ = group.cancel() + group.finish() + close(p.done) + }() + return p, nil +} + +func resumeInitialThread(pid uint32) error { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0) + if err != nil { + return err + } + defer windows.CloseHandle(snapshot) + entry := windows.ThreadEntry32{Size: uint32(unsafe.Sizeof(windows.ThreadEntry32{}))} + for err = windows.Thread32First(snapshot, &entry); err == nil; err = windows.Thread32Next(snapshot, &entry) { + if entry.OwnerProcessID != pid { + continue + } + thread, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID) + if err != nil { + return err + } + _, err = windows.ResumeThread(thread) + windows.CloseHandle(thread) + return err + } + return fmt.Errorf("clirunner: suspended process thread unavailable: %w", err) +} + +type ownedJob struct { + mu sync.Mutex + handle windows.Handle + finished bool + cancelled bool +} + +func (g *ownedJob) cancel() error { + g.mu.Lock() + defer g.mu.Unlock() + if g.finished { + return os.ErrProcessDone + } + g.cancelled = true + return windows.TerminateJobObject(g.handle, 1) +} + +// Querying the Job, rather than waiting only for its leader, confirms cleanup. +// An observation failure retains ownership and blocks reconnect. +func (g *ownedJob) finish() { + type accounting struct { + UserTime, KernelTime, PeriodUserTime, PeriodKernelTime int64 + PageFaults, TotalProcesses, ActiveProcesses, TerminatedProcesses uint32 + } + for { + var info accounting + err := windows.QueryInformationJobObject(g.handle, windows.JobObjectBasicAccountingInformation, uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)), nil) + if err == nil && info.ActiveProcesses == 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + g.mu.Lock() + defer g.mu.Unlock() + g.finished = true + windows.CloseHandle(g.handle) +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_windows_test.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows_test.go new file mode 100644 index 000000000..b2d442b54 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows_test.go @@ -0,0 +1,141 @@ +//go:build windows + +package clirunner + +import ( + "context" + "fmt" + "golang.org/x/sys/windows" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + "time" +) + +func TestWindowsOwnedTree(t *testing.T) { + for _, mode := range []string{"cancel", "parent-cancel", "leader-exit"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + p, err := Start(StartOptions{Parent: ctx, Binary: os.Args[0], Args: []string{"-test.run=^TestWindowsTreeHelper$", "--", "leader", mode, dir}, Env: append(os.Environ(), "OAC_TREE_HELPER=1"), OwnProcessGroup: true}) + if err != nil { + t.Fatal(err) + } + defer func() { p.Cancel(); _ = p.Wait() }() + go io.Copy(io.Discard, p.Stdout) + go io.Copy(io.Discard, p.Stderr) + child := waitWindowsPID(t, filepath.Join(dir, "child")) + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(child)) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(handle) + switch mode { + case "cancel": + p.Cancel() + p.Cancel() + case "parent-cancel": + cancel() + case "leader-exit": + os.WriteFile(filepath.Join(dir, "exit"), nil, 0600) + } + done := make(chan struct{}) + go func() { _ = p.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("owned tree did not settle") + } + if status, err := windows.WaitForSingleObject(handle, 0); err != nil || status != windows.WAIT_OBJECT_0 { + t.Fatal("descendant survived confirmed cleanup", status, err) + } + if _, err := os.Stat(filepath.Join(dir, "late")); !os.IsNotExist(err) { + t.Fatal("descendant wrote after cleanup") + } + }) + } +} + +func TestWindowsJobClosesAfterOwnerCrash(t *testing.T) { + dir := t.TempDir() + owner := exec.Command(os.Args[0], "-test.run=^TestWindowsTreeHelper$", "--", "owner", "cancel", dir) + owner.Env = append(os.Environ(), "OAC_TREE_HELPER=1") + if err := owner.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = owner.Process.Kill(); _ = owner.Wait() }() + child := waitWindowsPID(t, filepath.Join(dir, "child")) + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(child)) + if err != nil { + t.Fatal(err) + } + defer windows.CloseHandle(handle) + if err = owner.Process.Kill(); err != nil { + t.Fatal(err) + } + if status, err := windows.WaitForSingleObject(handle, 5000); err != nil || status != windows.WAIT_OBJECT_0 { + t.Fatal("Job descendant survived owner crash", status, err) + } +} + +func waitWindowsPID(t *testing.T, path string) int { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + raw, _ := os.ReadFile(path) + pid, err := strconv.Atoi(string(raw)) + if err == nil && pid > 0 { + return pid + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("child did not start") + return 0 +} + +func TestWindowsTreeHelper(t *testing.T) { + if os.Getenv("OAC_TREE_HELPER") != "1" { + return + } + args := os.Args + for len(args) > 0 && args[0] != "--" { + args = args[1:] + } + if len(args) != 4 { + os.Exit(2) + } + role, mode, dir := args[1], args[2], args[3] + switch role { + case "child": + _ = os.WriteFile(filepath.Join(dir, "child"), []byte(strconv.Itoa(os.Getpid())), 0600) + time.Sleep(10 * time.Second) + _ = os.WriteFile(filepath.Join(dir, "late"), nil, 0600) + case "leader": + c := exec.Command(os.Args[0], "-test.run=^TestWindowsTreeHelper$", "--", "child", mode, dir) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + if err := c.Start(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(3) + } + for { + if _, err := os.Stat(filepath.Join(dir, "exit")); err == nil { + os.Exit(0) + } + time.Sleep(10 * time.Millisecond) + } + case "owner": + p, err := Start(StartOptions{Parent: context.Background(), Binary: os.Args[0], Args: []string{"-test.run=^TestWindowsTreeHelper$", "--", "leader", mode, dir}, Env: os.Environ(), OwnProcessGroup: true}) + if err != nil { + os.Exit(3) + } + _ = p.Wait() + default: + os.Exit(2) + } + os.Exit(0) +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_observe_darwin_test.go b/apps/parsar-daemon/internal/agent/clirunner/process_observe_darwin_test.go new file mode 100644 index 000000000..3b2a25796 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_observe_darwin_test.go @@ -0,0 +1,23 @@ +//go:build darwin + +package clirunner + +import ( + "errors" + "golang.org/x/sys/unix" + "testing" + "time" +) + +func waitExited(t *testing.T, pid int) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + info, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if errors.Is(unix.Kill(pid, 0), unix.ESRCH) || errors.Is(err, unix.ESRCH) || err == nil && (info.Proc.P_pid == 0 || info.Proc.P_stat == 5) { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("child %d is still running", pid) +} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_observe_linux_test.go b/apps/parsar-daemon/internal/agent/clirunner/process_observe_linux_test.go new file mode 100644 index 000000000..9d8818b38 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_observe_linux_test.go @@ -0,0 +1,30 @@ +//go:build linux + +package clirunner + +import ( + "fmt" + "os" + "strings" + "testing" + "time" +) + +func waitExited(t *testing.T, pid int) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + value, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if os.IsNotExist(err) { + return + } + if err == nil { + fields := strings.Fields(string(value)[strings.LastIndex(string(value), ")")+1:]) + if len(fields) > 0 && fields[0] == "Z" { + return + } + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("child %d is still running", pid) +} diff --git a/apps/parsar-daemon/internal/agent/codex/capability_root_test.go b/apps/parsar-daemon/internal/agent/codex/capability_root_test.go deleted file mode 100644 index 36201a246..000000000 --- a/apps/parsar-daemon/internal/agent/codex/capability_root_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package codex - -import ( - "testing" -) - -func TestCapabilityRootIsOneQuotedReadOnlyNativeKey(t *testing.T) { - var plan SessionPlan - configureCapabilityRoot(&plan, "managed-workspace-enabled", "/tmp/root.with.dot/quote\"inside") - if len(plan.ExtraConfig) != 1 || plan.ExtraConfig[0][0] != "permissions.\"managed-workspace-enabled\".filesystem.\"/tmp/root.with.dot/quote\\\"inside\"" || plan.ExtraConfig[0][1] != "\"read\"" { - t.Fatal("root escaped its read-only key", plan.ExtraConfig) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/environment.go b/apps/parsar-daemon/internal/agent/codex/environment.go index 2eace2ad1..61f85d962 100644 --- a/apps/parsar-daemon/internal/agent/codex/environment.go +++ b/apps/parsar-daemon/internal/agent/codex/environment.go @@ -39,12 +39,6 @@ func nativeEnvironmentStatus(ctx context.Context, rpc *JSONRPCClient, id string) return result.Status, nil } -func configureRestrictedShellEnvironment(plan *SessionPlan) { - plan.ExtraConfig = append(plan.ExtraConfig, - [2]string{"shell_environment_policy.inherit", `"core"`}, - [2]string{"shell_environment_policy.ignore_default_excludes", "false"}) -} - // Native still recognizes the retired transport variables. Reject them before // setup so inherited or operator options cannot select a separate executor. // The explicit none selector remains part of native execution isolation. diff --git a/apps/parsar-daemon/internal/agent/codex/environment_local.go b/apps/parsar-daemon/internal/agent/codex/environment_local.go index 557a42714..d097cd758 100644 --- a/apps/parsar-daemon/internal/agent/codex/environment_local.go +++ b/apps/parsar-daemon/internal/agent/codex/environment_local.go @@ -9,19 +9,12 @@ import ( // SupportsLocalEnvironment checks deployment prerequisites, not public admission. func SupportsLocalEnvironment(version string) bool { - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" || !SupportsNativeSessionRecovery(version) || os.Getenv("OAC_RUNTIME_CODEX_PERMISSION_PROFILE") == "" || os.Getenv("OAC_RUNTIME_CODEX_HARNESS_BIN") != "" { + if !SupportsNativeSessionRecovery(version) || os.Getenv("OAC_RUNTIME_CODEX_HARNESS_BIN") != "" || (runtime.GOOS != "linux" && runtime.GOOS != "darwin" && runtime.GOOS != "windows") { return false } binding, err := localworkspace.Load() return err == nil && binding != nil } -// SupportsLocalNetworkPolicy describes the qualified adapter and bound policy; -// actual native preparation still validates the managed requirements. -func SupportsLocalNetworkPolicy(version string) bool { - if !SupportsLocalEnvironment(version) || os.Getenv("OAC_RUNTIME_CODEX_PERMISSION_PROFILE") != "managed-workspace" { - return false - } - binding, err := localworkspace.Load() - return err == nil && binding != nil && binding.NetworkPolicy().Validate() == nil -} +// Network isolation belongs to the outer environment, never this adapter. +func SupportsLocalNetworkPolicy(version string) bool { return false } diff --git a/apps/parsar-daemon/internal/agent/codex/executor.go b/apps/parsar-daemon/internal/agent/codex/executor.go index 2e437c20e..b64ba3756 100644 --- a/apps/parsar-daemon/internal/agent/codex/executor.go +++ b/apps/parsar-daemon/internal/agent/codex/executor.go @@ -68,7 +68,7 @@ func (e *Executor) StartTurn(ctx context.Context, runID string, input proto.Mess } functions := &functionCalls{definitions: base.functions.definitions, names: base.functions.names, pending: map[string]*pendingFunction{}} turnCtx, cancel := context.WithCancel(base.cancelCtx) - s := &Session{executor: e, nativeHome: base.nativeHome, toolEnvironment: base.toolEnvironment, + s := &Session{executor: e, nativeHome: base.nativeHome, functions: functions, observeMessages: base.observeMessages, observeTools: base.observeTools, observeToolObservations: base.observeToolObservations, observeSubagentIdentities: base.observeSubagentIdentities, cfg: base.cfg, rpc: base.rpc, cancelCtx: turnCtx, cancelFn: cancel, diff --git a/apps/parsar-daemon/internal/agent/codex/managed_network.go b/apps/parsar-daemon/internal/agent/codex/managed_network.go deleted file mode 100644 index 2fad0c583..000000000 --- a/apps/parsar-daemon/internal/agent/codex/managed_network.go +++ /dev/null @@ -1,113 +0,0 @@ -package codex - -import ( - "bytes" - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/BurntSushi/toml" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" -) - -const nativeManagedRequirements = "/etc/codex/requirements.toml" - -// Preserve the image's native filesystem, approval and hook requirements. This -// adapter adds the exact network ceiling; Core never supplies native TOML. -func managedNetworkRequirements(base []byte, policy agentnetwork.Policy) ([]byte, error) { - if policy.Access != "restricted" || policy.Validate() != nil { - return nil, errors.New("codex: invalid restricted network policy") - } - var config map[string]any - if _, err := toml.Decode(string(base), &config); err != nil { - return nil, err - } - if _, exists := config["experimental_network"]; exists { - return nil, errors.New("codex: image already defines managed network requirements") - } - domains := make(map[string]string) - for _, host := range policy.Hosts() { - domains[host] = "allow" - } - var addition bytes.Buffer - err := toml.NewEncoder(&addition).Encode(map[string]any{"experimental_network": map[string]any{ - "enabled": true, "managed_allowed_domains_only": true, - "allow_upstream_proxy": false, "dangerously_allow_all_unix_sockets": false, - "allow_local_binding": false, "domains": domains, - }}) - if err != nil { - return nil, err - } - result := append(append([]byte{}, base...), '\n') - return append(result, addition.Bytes()...), nil -} - -func prepareManagedNetwork(plan *SessionPlan, policy agentnetwork.Policy) error { - var home string - for _, entry := range plan.Env { - if value, found := strings.CutPrefix(entry, "CODEX_HOME="); found { - home = value - } - } - if !filepath.IsAbs(home) { - return errors.New("codex: managed network requires private Session state") - } - base, err := os.ReadFile(nativeManagedRequirements) - if err != nil { - return err - } - contents, err := managedNetworkRequirements(base, policy) - if err != nil { - return err - } - file, err := os.CreateTemp(home, ".managed-network-*.toml") - if err != nil { - return err - } - defer os.Remove(file.Name()) - if _, err = file.Write(contents); err == nil { - err = file.Chmod(0400) - } - closeErr := file.Close() - if err != nil { - return err - } - if closeErr != nil { - return closeErr - } - path := filepath.Join(home, "managed-network.toml") - if err = os.Rename(file.Name(), path); err != nil { - return err - } - // Retain this non-secret file with Session state. Rebuild it from the frozen - // policy on preparation; cleanup never races a process still holding its mount. - plan.managedRequirements = path - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.network_proxy", "true"}) - return nil -} - -// The existing RPC client owns the sole child, its pipes and teardown. The PID -// namespace terminates native descendants when that owned process is killed. -func configureManagedNetworkProcess(cfg *JSONRPCConfig, requirements string) error { - if requirements == "" { - return nil - } - if !filepath.IsAbs(requirements) { - return errors.New("codex: managed requirements path must be absolute") - } - binary, err := exec.LookPath(cfg.Binary) - if err != nil { - return err - } - binary, err = filepath.Abs(binary) - if err != nil { - return err - } - cfg.Binary = "/usr/bin/bwrap" - cfg.ExtraArgs = append([]string{"--die-with-parent", "--unshare-pid", "--bind", "/", "/", - "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--ro-bind", requirements, - nativeManagedRequirements, binary}, cfg.ExtraArgs...) - return nil -} diff --git a/apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go b/apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go deleted file mode 100644 index 0ff3dbdd0..000000000 --- a/apps/parsar-daemon/internal/agent/codex/managed_network_live_test.go +++ /dev/null @@ -1,111 +0,0 @@ -//go:build linux - -package codex - -import ( - "bytes" - "context" - "os" - "path/filepath" - stdstrconv "strconv" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" -) - -// Run inside the qualified Docker Runtime. This checks the real native process -// and managed requirements, not model execution or public protocol acceptance. -func TestManagedNetworkNativeLifecycle(t *testing.T) { - if os.Getenv("OAC_TEST_CODEX_MANAGED_NETWORK_LIVE") != "1" { - t.Skip("requires the qualified Docker Runtime and native Codex") - } - base, err := os.ReadFile(nativeManagedRequirements) - if err != nil { - t.Fatal(err) - } - for _, mode := range []string{"close", "owner-cancel"} { - t.Run(mode, func(t *testing.T) { - home := t.TempDir() - plan := SessionPlan{Env: []string{"CODEX_HOME=" + home}} - policy := agentnetwork.Policy{Access: "restricted", AllowedDomains: []string{"Example.com"}} - if err := prepareManagedNetwork(&plan, policy); err != nil { - t.Fatal(err) - } - cfg := JSONRPCConfig{Binary: "/usr/local/bin/codex", Env: append(os.Environ(), plan.Env...)} - for _, entry := range plan.ExtraConfig { - cfg.ExtraArgs = append(cfg.ExtraArgs, "-c", entry[0]+"="+entry[1]) - } - if err := configureManagedNetworkProcess(&cfg, plan.managedRequirements); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - client := NewJSONRPCClient(cfg) - defer client.Close() - if _, err := client.Start(ctx, InitializeParams{ - ClientInfo: InitializeClientInfo{Name: "network-lifecycle-test", Version: "1"}, - Capabilities: &InitializeCapabilities{ExperimentalAPI: true}, - }); err != nil { - t.Fatal(err) - } - requirements, err := client.Request(ctx, "configRequirements/read", nil) - if err != nil || !bytes.Contains(requirements, []byte("example.com")) { - t.Fatalf("native policy was not applied: %s: %v", requirements, err) - } - owned := networkNativeProcesses(t, home) - if len(owned) == 0 { - t.Fatal("native child was not observed") - } - if mode == "owner-cancel" { - cancel() - } - if err := client.Close(); err != nil { - t.Fatal(err) - } - deadline := time.Now().Add(5 * time.Second) - for { - survivors := networkNativeProcesses(t, home) - if len(survivors) == 0 { - break - } - if time.Now().After(deadline) { - t.Fatalf("native processes survived %s: %v", mode, survivors) - } - time.Sleep(20 * time.Millisecond) - } - if client.Alive() || client.cmd.ProcessState == nil { - t.Fatal("RPC owner did not settle") - } - }) - } - after, err := os.ReadFile(nativeManagedRequirements) - if err != nil || !bytes.Equal(base, after) { - t.Fatal("image requirements changed", err) - } -} - -func networkNativeProcesses(t *testing.T, home string) []string { - t.Helper() - entries, err := os.ReadDir("/proc") - if err != nil { - t.Fatal(err) - } - var found []string - for _, entry := range entries { - pid, err := stdstrconv.Atoi(entry.Name()) - if err != nil || pid == os.Getpid() { - continue - } - env, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "environ")) - if err == nil { - for _, value := range strings.Split(string(env), "\x00") { - if value == "CODEX_HOME="+home { - found = append(found, entry.Name()) - } - } - } - } - return found -} diff --git a/apps/parsar-daemon/internal/agent/codex/managed_network_test.go b/apps/parsar-daemon/internal/agent/codex/managed_network_test.go deleted file mode 100644 index 6348af028..000000000 --- a/apps/parsar-daemon/internal/agent/codex/managed_network_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package codex - -import ( - "os" - "path/filepath" - "reflect" - "testing" - - "github.com/BurntSushi/toml" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" -) - -func TestManagedNetworkPreservesImageRequirements(t *testing.T) { - base, err := os.ReadFile("../../../../../services/agents-api/deploy/codex/requirements.toml") - if err != nil { - t.Fatal(err) - } - policy := agentnetwork.Policy{Access: "restricted", AllowedDomains: []string{"Example.com", "api.example.com", "example.com"}} - result, err := managedNetworkRequirements(base, policy) - if err != nil { - t.Fatal(err) - } - var original, merged map[string]any - if _, err = toml.Decode(string(base), &original); err != nil { - t.Fatal(err) - } - if _, err = toml.Decode(string(result), &merged); err != nil { - t.Fatal(err) - } - network := merged["experimental_network"].(map[string]any) - delete(merged, "experimental_network") - if !reflect.DeepEqual(merged, original) { - t.Fatal("native filesystem/approval/hooks changed") - } - for _, flag := range []string{"enabled", "managed_allowed_domains_only"} { - if network[flag] != true { - t.Fatal("missing ceiling", flag) - } - } - for _, flag := range []string{"allow_upstream_proxy", "dangerously_allow_all_unix_sockets", "allow_local_binding"} { - if network[flag] != false { - t.Fatal("network escape enabled", flag) - } - } - want := map[string]any{"example.com": "allow", "api.example.com": "allow"} - if !reflect.DeepEqual(network["domains"], want) { - t.Fatal("exact domains changed", network["domains"]) - } - if _, err := managedNetworkRequirements(result, policy); err == nil { - t.Fatal("image ceiling overwritten") - } -} - -func TestManagedNetworkWrapsTheExistingRPCCommand(t *testing.T) { - binary, err := os.Executable() - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "managed-network.toml") - cfg := JSONRPCConfig{Binary: binary, ExtraArgs: []string{"-c", "features.network_proxy=true"}, Env: []string{"HOME=/private"}, Cwd: "/workspace"} - if err := configureManagedNetworkProcess(&cfg, path); err != nil { - t.Fatal(err) - } - if cfg.Binary != "/usr/bin/bwrap" || cfg.Cwd != "/workspace" || !reflect.DeepEqual(cfg.Env, []string{"HOME=/private"}) { - t.Fatal("RPC ownership/configuration changed") - } - want := []string{"--die-with-parent", "--unshare-pid", "--bind", "/", "/", "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--ro-bind", path, nativeManagedRequirements, binary, "-c", "features.network_proxy=true"} - if !reflect.DeepEqual(cfg.ExtraArgs, want) { - t.Fatal(cfg.ExtraArgs) - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go b/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go index 37f368b81..d583078ca 100644 --- a/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go +++ b/apps/parsar-daemon/internal/agent/codex/mcp_environment_test.go @@ -2,6 +2,7 @@ package codex import ( "encoding/json" + "os" "slices" "strings" "testing" @@ -20,8 +21,9 @@ func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing. if err != nil || len(servers) != 2 || len(env) != 2 { t.Fatal("environment declarations were not projected", err) } - if servers["local"].Command != "/usr/bin/python3" || !servers["local"].ApproveTools || - !slices.Equal(servers["local"].Args, []string{"-I", "-S", "/usr/local/bin/oac-runtime-initialize", "stdio", "/private/runtime/capabilities", "/private/runtime/workspace", "plugins/0", "local"}) { + executable, _ := os.Executable() + if servers["local"].Command != executable || !servers["local"].ApproveTools || + !slices.Equal(servers["local"].Args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/0", "local"}) { t.Fatal("native stdio bypasses the packaged launcher") } remote := servers["remote"] @@ -41,7 +43,7 @@ func TestEnvironmentMCPProjectsIsolatedStdioAndPrivateHTTPReferences(t *testing. if !matchesMCPConfig(raw, servers) { t.Fatal("qualified native projection rejected") } - corrupt := strings.Replace(string(raw), "oac-runtime-initialize", "untrusted-launcher", 1) + corrupt := strings.Replace(string(raw), "runtime-mcp-exec", "untrusted-launcher", 1) if matchesMCPConfig(json.RawMessage(corrupt), servers) { t.Fatal("different native launcher accepted") } diff --git a/apps/parsar-daemon/internal/agent/codex/native_hooks.go b/apps/parsar-daemon/internal/agent/codex/native_hooks.go new file mode 100644 index 000000000..962ca1969 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/native_hooks.go @@ -0,0 +1,39 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "time" +) + +type nativeToolHook struct { + EventName string `json:"eventName"` + Command string `json:"command"` + Matcher string `json:"matcher"` + Enabled bool `json:"enabled"` + IsManaged bool `json:"isManaged"` + Async bool `json:"async"` + SourcePath string `json:"sourcePath"` + TrustStatus string `json:"trustStatus"` +} + +func readNativeToolHooks(ctx context.Context, rpc *JSONRPCClient, cwd string) ([]nativeToolHook, error) { + operation, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + raw, err := rpc.Request(operation, "hooks/list", map[string]any{"cwds": []string{cwd}}) + if err != nil { + return nil, errors.New("codex: initialized tool hook unavailable") + } + var response struct { + Data []struct { + CWD string `json:"cwd"` + Hooks []nativeToolHook `json:"hooks"` + Errors []json.RawMessage `json:"errors"` + } `json:"data"` + } + if json.Unmarshal(raw, &response) != nil || len(response.Data) != 1 || response.Data[0].CWD != cwd || len(response.Data[0].Errors) != 0 { + return nil, errors.New("codex: initialized tool hook configuration unavailable") + } + return response.Data[0].Hooks, nil +} diff --git a/apps/parsar-daemon/internal/agent/codex/options.go b/apps/parsar-daemon/internal/agent/codex/options.go index 001a9c837..e46e553af 100644 --- a/apps/parsar-daemon/internal/agent/codex/options.go +++ b/apps/parsar-daemon/internal/agent/codex/options.go @@ -15,7 +15,6 @@ import ( // SessionPlan holds the resolved per-prompt launch plan derived from // the daemon's PromptRequestPayload. type SessionPlan struct { - managedRequirements string // Cwd is the validated working directory passed to codex (and to // the spawned app-server). Empty when the caller provided no work_dir. diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile.go b/apps/parsar-daemon/internal/agent/codex/permission_profile.go index 953442a22..a5d7a76d9 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile.go +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile.go @@ -1,52 +1,14 @@ package codex import ( - "errors" - "strings" - + "fmt" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" ) -// The deployment selects a native named profile; request options cannot select it. -// Native managed requirements must constrain its definition and allowed profiles. -// This selection does not establish workspace authority or public admission. -func validatePermissionProfile(req proto.PromptRequestPayload, profile string) error { - if req.LocalEnvironment != nil && profile == "" { - return errors.New("codex: local Environment requires deployment-managed permissions") - } - if profile == "" { - return nil - } - if strings.TrimSpace(profile) != profile || strings.HasPrefix(profile, ":") { - return errors.New("codex: deployment permissions require a named native profile") - } - if req.DisableExecutionEnvironment || req.WorkspaceReadOnly { - return errors.New("codex: deployment permission profile requires local execution") - } - return nil -} - -// managedPermissionProfile maps a validated deployment binding to native policy. -// Public or prompt options cannot choose a native profile or widen that binding. -func managedPermissionProfile(req proto.PromptRequestPayload, cfg sessionConfig) (string, error) { - profile := cfg.permissionProfile - if cfg.runtimeNetworkError != nil { - return "", cfg.runtimeNetworkError - } - if cfg.runtimeNetwork.Access != "" { - if req.LocalEnvironment == nil || !cfg.runtimeNetwork.Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || profile != "managed-workspace" { - return "", errors.New("codex: Runtime network policy mismatch") - } - switch cfg.runtimeNetwork.Access { - case "disabled": - case "enabled", "restricted": - profile = "managed-workspace-enabled" - default: - return "", errors.New("codex: unsupported Runtime network policy") - } - } else if req.LocalEnvironment != nil { - return "", errors.New("codex: Runtime has no bound network policy") +// Runtime execution uses the current user; isolation belongs to its outer host. +func runtimePermissionProfile(req proto.PromptRequestPayload) (string, error) { + if req.LocalEnvironment != nil && (req.DisableExecutionEnvironment || req.WorkspaceReadOnly || req.LocalEnvironment.NetworkAccess != "enabled" || len(req.LocalEnvironment.AllowedDomains) != 0) { + return "", fmt.Errorf("codex: Runtime execution requires unrestricted host access") } - return profile, validatePermissionProfile(req, profile) + return "", nil } diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go index 585c0179b..56e80fd55 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go @@ -1,92 +1,32 @@ package codex import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "os" - "path/filepath" - "testing" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "testing" ) -func TestPermissionProfileRejectsIncompatiblePreparationBeforeState(t *testing.T) { - for _, tc := range []struct { - name string - profile string - req proto.PromptRequestPayload - }{ - {"builtin", ":danger-full-access", proto.PromptRequestPayload{}}, - {"whitespace", " ", proto.PromptRequestPayload{}}, - {"none", "managed-workspace", proto.PromptRequestPayload{DisableExecutionEnvironment: true}}, - {"read-owner", "managed-workspace", proto.PromptRequestPayload{WorkspaceReadOnly: true}}, - } { - t.Run(tc.name, func(t *testing.T) { - root := filepath.Join(t.TempDir(), "uncreated") - t.Setenv("OAC_RUNTIME_HOME", root) - if _, _, err := prepareSessionPlan(context.Background(), tc.req, sessionConfig{permissionProfile: tc.profile}); err == nil { - t.Fatal("incompatible profile accepted") - } - if _, err := os.Stat(root); !os.IsNotExist(err) { - t.Fatal("rejection created state", err) - } - }) - } -} - -func TestPermissionProfileSelectsNativeStartupConfig(t *testing.T) { +func TestRuntimeUsesHostPermissions(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - t.Setenv("OAC_RUNTIME_CODEX_PERMISSION_PROFILE", "managed-workspace") - plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true}, defaultSessionConfig()) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - config := map[string]string{} - for _, kv := range plan.ExtraConfig { - config[kv[0]] = kv[1] - } - if config["default_permissions"] != `"managed-workspace"` || plan.Sandbox != "" || plan.Permissions != "managed-workspace" { - t.Fatal("native managed selection missing") - } - if config["shell_environment_policy.inherit"] != `"core"` || config["shell_environment_policy.ignore_default_excludes"] != "false" { - t.Fatal("shell can inherit model credentials") - } - if config["features.shell_snapshot"] != "false" { - t.Fatal("managed shell depends on inaccessible private snapshots") - } -} - -func TestManagedNetworkPolicySelectsNativeProfileAndRejectsMismatchBeforeState(t *testing.T) { - for _, mode := range []string{"enabled", "disabled"} { - t.Run(mode, func(t *testing.T) { - root := filepath.Join(t.TempDir(), "uncreated") - t.Setenv("OAC_RUNTIME_HOME", root) - req := proto.PromptRequestPayload{AgentStateKey: "session", LocalEnvironment: &proto.LocalEnvironment{ID: "environment", NetworkAccess: mode}} - cfg := sessionConfig{permissionProfile: "managed-workspace", runtimeNetwork: agentnetwork.Policy{Access: mode}} - wrong := req - wrong.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "restricted"} - if _, _, err := prepareSessionPlan(t.Context(), wrong, cfg); err == nil { - t.Fatal("policy mismatch accepted") - } - if _, err := os.Stat(root); !os.IsNotExist(err) { - t.Fatal("policy rejection created native state") - } - plan, _, err := prepareSessionPlan(t.Context(), req, cfg) - if err != nil { - t.Fatal(err) - } - defer plan.Cleanup() - expected := "managed-workspace" - if mode == "enabled" { - expected += "-enabled" - } - if plan.Permissions != expected || plan.Sandbox != "" { - t.Fatal("wrong native profile", plan.Permissions) - } - if _, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{permissionProfile: "managed-workspace"}); err == nil { - t.Fatal("unbound Runtime accepted explicit policy") - } - }) + for _, mode := range []string{"", "native"} { + req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{ExecutionMode: mode, NetworkAccess: "enabled"}} + plan, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) + if err != nil { + t.Fatal(err) + } + if plan.Sandbox != "danger-full-access" || plan.Permissions != "" || plan.ApprovalPolicy.String != "never" { + t.Fatal("Runtime must bypass inner sandbox", plan) + } + for _, kv := range plan.ExtraConfig { + if kv[0] == "default_permissions" { + t.Fatal("obsolete permission wrapper", kv) + } + } + plan.Cleanup() + for _, network := range []string{"disabled", "restricted"} { + req.LocalEnvironment.NetworkAccess = network + if _, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}); err == nil { + t.Fatal("unsupported network admitted") + } + } } } diff --git a/apps/parsar-daemon/internal/agent/codex/preparation.go b/apps/parsar-daemon/internal/agent/codex/preparation.go index 2d143f97b..5f7578418 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation.go @@ -83,17 +83,11 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg for _, kv := range plan.ExtraConfig { rpcCfg.ExtraArgs = append(rpcCfg.ExtraArgs, "-c", kv[0]+"="+kv[1]) } - if err := configureManagedNetworkProcess(&rpcCfg, plan.managedRequirements); err != nil { - cancelFn() - plan.Cleanup() - return nil, err - } rpc := NewJSONRPCClient(rpcCfg) s := &Session{ nativeHome: nativeHomeFromPlan(plan), - toolEnvironment: req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment, functions: functions, observeMessages: req.ObserveMessages, observeTools: req.ObserveTools, @@ -138,11 +132,7 @@ func newPreparation(parent context.Context, req proto.PromptRequestPayload, cfg return p.preparationFailed(err) } } - if s.toolEnvironment { - if err := verifyToolEnvironmentHook(cancelCtx, rpc, plan.Cwd); err != nil { - return p.preparationFailed(err) - } - } + if plan.mcpServers != nil { if err := verifyMCPConfig(cancelCtx, rpc, plan); err != nil { return p.preparationFailed(err) diff --git a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go index b3410f785..fbf5dcbbe 100644 --- a/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go +++ b/apps/parsar-daemon/internal/agent/codex/preparation_router_test.go @@ -5,7 +5,6 @@ import ( "errors" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" "github.com/google/uuid" "os" "path/filepath" @@ -58,8 +57,6 @@ func TestPreparationRouterRetainsActualNativeChild(t *testing.T) { req.AgentStateKey = "agents-api-" + session req.DisableExecutionEnvironment = false req.LocalEnvironment = &proto.LocalEnvironment{ID: environment, WorkspaceDirectory: "/workspace", NetworkAccess: "enabled", CapabilitySources: &agentcapabilities.Input{}} - cfg.permissionProfile = "managed-workspace" - cfg.runtimeNetwork = agentnetwork.Policy{Access: "enabled"} registry := agent.NewRegistry() registry.RegisterKind(proto.SupportedAgentKind{Kind: "codex", Available: true, Capabilities: proto.AgentKindCapabilities{LocalEnvironment: true, FunctionTools: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { return nil, errors.New("ordinary Factory must not run") diff --git a/apps/parsar-daemon/internal/agent/codex/session.go b/apps/parsar-daemon/internal/agent/codex/session.go index 4d1e95ea6..aa38c1c96 100644 --- a/apps/parsar-daemon/internal/agent/codex/session.go +++ b/apps/parsar-daemon/internal/agent/codex/session.go @@ -5,16 +5,13 @@ import ( "encoding/json" "fmt" "log/slog" - "os" "strings" "sync" "sync/atomic" "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" - "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" ) @@ -26,20 +23,14 @@ const terminalSendTimeout = 2 * time.Second // sessionConfig is the cross-cutting knob bag — production callers go // through Factory which uses defaults. type sessionConfig struct { - codexBinary string - permissionProfile string - runtimeNetwork agentnetwork.Policy - runtimeNetworkError error - logger *slog.Logger - killTimeout time.Duration + codexBinary string + logger *slog.Logger + killTimeout time.Duration } func defaultSessionConfig() sessionConfig { - policy, err := localworkspace.RuntimeNetworkPolicy() return sessionConfig{ - codexBinary: defaultBinary(), - permissionProfile: os.Getenv("OAC_RUNTIME_CODEX_PERMISSION_PROFILE"), - runtimeNetwork: policy, runtimeNetworkError: err, + codexBinary: defaultBinary(), logger: obslog.Bg(), killTimeout: rpcKillTimeout, } @@ -73,7 +64,6 @@ type Session struct { operationMu sync.Mutex operations sync.WaitGroup operationsClosed bool - toolEnvironment bool nativeHome string subagents *subagentObservations observeSubagentIdentities bool @@ -160,7 +150,6 @@ func (s *Session) registerHandlers() { rpc.OnNotification("item/reasoning/summaryTextDelta", s.onReasoningDelta) rpc.OnNotification("thread/tokenUsage/updated", s.onUsageUpdated) rpc.OnNotification("error", s.onErrorNotif) - rpc.OnNotification("hook/completed", s.onToolEnvironmentHook) s.onServerRequest("item/commandExecution/requestApproval", s.handleCodexCommandApproval) s.onServerRequest("item/fileChange/requestApproval", s.handleCodexFileApproval) diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go index b0550f894..8b7c1bfb8 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -12,7 +12,7 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg if err := validateNativeTransportEnvironment(req); err != nil { return SessionPlan{}, nil, err } - profile, err := managedPermissionProfile(req, cfg) + _, err := runtimePermissionProfile(req) if err != nil { return SessionPlan{}, nil, err } @@ -29,32 +29,26 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg return SessionPlan{}, nil, err } disableProgrammaticTools(&plan, req.ExecutionControls) - if profile != "" { - plan.Sandbox = "" - plan.Permissions = profile - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"default_permissions", tomlQuoteString(profile)}) - configureRestrictedShellEnvironment(&plan) - if req.LocalEnvironment != nil && req.LocalEnvironment.CapabilityRoot != "" { - configureCapabilityRoot(&plan, profile, req.LocalEnvironment.CapabilityRoot) - } - // Native login-shell snapshots live outside the managed tool filesystem. - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.shell_snapshot", "false"}) + if req.LocalEnvironment != nil { + plan.Sandbox = "danger-full-access" + plan.Permissions = "" + plan.ApprovalPolicy = AskForApproval{String: "never"} } if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { - if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { + binding, err := localworkspace.Load() + if err != nil || binding == nil { + plan.Cleanup() + return SessionPlan{}, nil, fmt.Errorf("codex: native Runtime binding unavailable") + } + values, err := binding.ReadToolEnvironment() + if err != nil { plan.Cleanup() return SessionPlan{}, nil, err } - plan.Env = append(plan.Env, "OAC_RUNTIME_TOOL_ENV=1") - if req.LocalEnvironment.SystemPackages { - if err := prepareSystemToolAnchor(); err != nil { - plan.Cleanup() - return SessionPlan{}, nil, err - } - plan.Env = append(plan.Env, "OAC_RUNTIME_SYSTEM_PACKAGES=1") + for key, value := range values { + plan.Env = append(plan.Env, key+"="+value) } - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"features.hooks", "true"}) } if req.DisableSubagents { @@ -105,17 +99,6 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg plan.Env = append(plan.Env, mcpBearerEnv...) plan.Env = append(plan.Env, environmentMCPEnv...) - if cfg.runtimeNetwork.Access == "restricted" { - if err := prepareManagedNetwork(&plan, cfg.runtimeNetwork); err != nil { - plan.Cleanup() - return SessionPlan{}, nil, err - } - } - return plan, skillRoots, nil -} -// The Runtime supplies this resolved root after validating its binding. Quote the -// path as one TOML key; neither public options nor source directories select it. -func configureCapabilityRoot(plan *SessionPlan, profile, root string) { - plan.ExtraConfig = append(plan.ExtraConfig, [2]string{"permissions." + tomlQuoteString(profile) + ".filesystem." + tomlQuoteString(root), tomlQuoteString("read")}) + return plan, skillRoots, nil } diff --git a/apps/parsar-daemon/internal/agent/codex/session_policy_test.go b/apps/parsar-daemon/internal/agent/codex/session_policy_test.go index 9156aca5a..869b47ecf 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_policy_test.go +++ b/apps/parsar-daemon/internal/agent/codex/session_policy_test.go @@ -16,7 +16,7 @@ func TestThreadRequestsApplyDeploymentPolicy(t *testing.T) { for _, method := range []string{"thread/start", "thread/resume"} { t.Run(method, func(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{AgentStateKey: "conv/agent/codex", DisableSubagents: true, AgentOptions: map[string]any{"permissions": ":danger-full-access"}}, sessionConfig{permissionProfile: profile}) + plan, _, err := prepareSessionPlan(context.Background(), proto.PromptRequestPayload{AgentStateKey: "conv/agent/codex", DisableSubagents: true, AgentOptions: map[string]any{"permissions": ":danger-full-access"}}, sessionConfig{}) if err != nil { t.Fatal(err) } @@ -48,15 +48,10 @@ func TestThreadRequestsApplyDeploymentPolicy(t *testing.T) { if request.Params["approvalPolicy"] != "never" { t.Fatal("approval policy changed") } - if profile == "" { - if request.Params["sandbox"] != "danger-full-access" || request.Params["permissions"] != nil { - t.Fatalf("default policy changed: %+v", request.Params) - } - } else { - if request.Params["sandbox"] != nil || request.Params["permissions"] != profile { - t.Fatalf("managed policy lost: %+v", request.Params) - } + if request.Params["sandbox"] != "danger-full-access" || request.Params["permissions"] != nil { + t.Fatalf("bypass policy changed: %+v", request.Params) } + if method == "thread/resume" && request.Params["threadId"] != "old-thread" { t.Fatalf("resume lost thread ID: %+v", request.Params) } diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_profile.go b/apps/parsar-daemon/internal/agent/codex/subagent_profile.go index 905027d35..648e97071 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_profile.go +++ b/apps/parsar-daemon/internal/agent/codex/subagent_profile.go @@ -15,9 +15,6 @@ func configureSubagentObservations(plan *SessionPlan, req proto.PromptRequestPay if !req.ObserveSubagentIdentities || req.DisableSubagents { return nil } - if req.LocalEnvironment != nil && req.LocalEnvironment.ToolEnvironment { - return errors.New("codex: multi_agent with initialized tool environment requires verified child hook failure handling") - } for _, feature := range []string{"hooks", "plugins", "code_mode", "code_mode_only", "code_mode_prewarm", "multi_agent_v2"} { plan.EnableFeatures = slices.DeleteFunc(plan.EnableFeatures, func(value string) bool { return value == feature }) if !slices.Contains(plan.DisableFeatures, feature) { @@ -44,12 +41,8 @@ func verifySubagentObservationProfile(ctx context.Context, rpc *JSONRPCClient, c if err != nil { return err } - // Packaged managed requirements may force hooks on. Only the immutable - // Bash pre-hook is admissible; managed-only discovery must survive reloads. - for _, hook := range hooks { - if !hook.isRuntimeToolEnvironmentHook() { - return errors.New("codex: subagent observation requires all hooks except the packaged Bash pre-hook disabled") - } + if len(hooks) != 0 { + return errors.New("codex: subagent observation requires hooks disabled") } raw, err := rpc.Request(ctx, "configRequirements/read", nil) diff --git a/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go b/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go index 62613cbd7..fb903f2c3 100644 --- a/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go +++ b/apps/parsar-daemon/internal/agent/codex/subagent_profile_test.go @@ -26,8 +26,8 @@ func TestSubagentProfileOverridesUnsafeNativeFeatures(t *testing.T) { func TestSubagentProfileDoesNotDisableRequiredToolEnvironment(t *testing.T) { request := proto.PromptRequestPayload{ObserveSubagentIdentities: true, LocalEnvironment: &proto.LocalEnvironment{ToolEnvironment: true}} - if err := configureSubagentObservations(&SessionPlan{}, request); err == nil { - t.Fatal("incompatible hook profile accepted") + if err := configureSubagentObservations(&SessionPlan{}, request); err != nil { + t.Fatal("ordinary environment must not require hooks", err) } request.DisableSubagents = true if err := configureSubagentObservations(&SessionPlan{}, request); err != nil { diff --git a/apps/parsar-daemon/internal/agent/codex/tool_environment.go b/apps/parsar-daemon/internal/agent/codex/tool_environment.go deleted file mode 100644 index 7af5efbd7..000000000 --- a/apps/parsar-daemon/internal/agent/codex/tool_environment.go +++ /dev/null @@ -1,97 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "time" -) - -const toolEnvironmentHookSource = "/etc/codex/runtime-hooks" -const toolEnvironmentHookCommand = "/usr/bin/python3 -I -S /etc/codex/tool-env.py" - -func prepareSystemToolAnchor() error { - const anchor = "/tmp/oac-tool-root" - if err := os.Mkdir(anchor, 0500); err != nil && !errors.Is(err, os.ErrExist) { - return errors.New("codex: system tool temporary anchor unavailable") - } - actual, err := filepath.EvalSymlinks(anchor) - entries, readErr := os.ReadDir(anchor) - if err != nil || actual != anchor || readErr != nil || len(entries) != 0 { - return errors.New("codex: system tool temporary anchor is not an empty canonical directory") - } - return nil -} - -type nativeToolHook struct { - EventName string `json:"eventName"` - Command string `json:"command"` - Matcher string `json:"matcher"` - Enabled bool `json:"enabled"` - IsManaged bool `json:"isManaged"` - Async bool `json:"async"` - SourcePath string `json:"sourcePath"` - TrustStatus string `json:"trustStatus"` -} - -func (hook nativeToolHook) isRuntimeToolEnvironmentHook() bool { - return hook.EventName == "preToolUse" && hook.Command == toolEnvironmentHookCommand && hook.Matcher == "^Bash$" && hook.Enabled && hook.IsManaged && !hook.Async && hook.SourcePath == toolEnvironmentHookSource && hook.TrustStatus == "managed" -} - -func readNativeToolHooks(ctx context.Context, rpc *JSONRPCClient, cwd string) ([]nativeToolHook, error) { - operation, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - raw, err := rpc.Request(operation, "hooks/list", map[string]any{"cwds": []string{cwd}}) - if err != nil { - return nil, errors.New("codex: initialized tool hook unavailable") - } - var response struct { - Data []struct { - CWD string `json:"cwd"` - Hooks []nativeToolHook `json:"hooks"` - Errors []json.RawMessage `json:"errors"` - } `json:"data"` - } - if json.Unmarshal(raw, &response) != nil || len(response.Data) != 1 || response.Data[0].CWD != cwd || len(response.Data[0].Errors) != 0 { - return nil, errors.New("codex: initialized tool hook configuration unavailable") - } - return response.Data[0].Hooks, nil -} - -func verifyToolEnvironmentHook(ctx context.Context, rpc *JSONRPCClient, cwd string) error { - hooks, err := readNativeToolHooks(ctx, rpc, cwd) - if err != nil { - return err - } - for _, hook := range hooks { - if hook.isRuntimeToolEnvironmentHook() { - return nil - } - } - return errors.New("codex: required initialized tool hook missing") -} - -func (s *Session) onToolEnvironmentHook(raw json.RawMessage) { - if !s.toolEnvironment { - return - } - var notification struct { - ThreadID string `json:"threadId"` - TurnID string `json:"turnId"` - Run struct { - SourcePath string `json:"sourcePath"` - Status string `json:"status"` - } `json:"run"` - } - if json.Unmarshal(raw, ¬ification) != nil || notification.Run.SourcePath != toolEnvironmentHookSource || !s.isRootTurn(notification.ThreadID, notification.TurnID) { - return - } - if notification.Run.Status == "failed" { - // Native hook process failures can run the original command before this - // notification. Stop subsequent work and report failure, never success. - s.emitTerminal("codex: initialized tool configuration failed; prior command effects may exist", true) - s.cancelFn() - } -} diff --git a/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go b/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go deleted file mode 100644 index 376e27ed7..000000000 --- a/apps/parsar-daemon/internal/agent/codex/tool_environment_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package codex - -import ( - "context" - "encoding/json" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestInitializedToolHookReadiness(t *testing.T) { - for _, mode := range []string{"ready", "empty", "disabled", "unmanaged", "async", "wrong command", "post hook", "wrong matcher", "wrong source", "untrusted", "errors"} { - t.Run(mode, func(t *testing.T) { - client, server, cleanup := NewTestClient() - defer cleanup() - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - defer cancel() - done := make(chan error, 1) - go func() { done <- verifyToolEnvironmentHook(ctx, client.JSONRPCClient, "/workspace") }() - var request struct { - ID, Method string - Params struct { - CWDs []string `json:"cwds"` - } - } - if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { - t.Fatal(err) - } - if request.Method != "hooks/list" || len(request.Params.CWDs) != 1 || request.Params.CWDs[0] != "/workspace" { - t.Fatal("wrong hook lookup") - } - hook := map[string]any{"eventName": "preToolUse", "command": toolEnvironmentHookCommand, "matcher": "^Bash$", "enabled": true, "isManaged": true, "async": false, "sourcePath": toolEnvironmentHookSource, "trustStatus": "managed"} - entry := map[string]any{"cwd": "/workspace", "hooks": []any{hook}, "errors": []any{}} - switch mode { - case "empty": - entry["hooks"] = []any{} - case "post hook": - hook["eventName"] = "postToolUse" - case "wrong matcher": - hook["matcher"] = ".*" - case "wrong source": - hook["sourcePath"] = "/workspace/hooks" - case "untrusted": - hook["trustStatus"] = "untrusted" - case "disabled": - hook["enabled"] = false - case "unmanaged": - hook["isManaged"] = false - case "async": - hook["async"] = true - case "wrong command": - hook["command"] = "/workspace/untrusted" - case "errors": - entry["errors"] = []any{"cannot load hook"} - } - if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"data": []any{entry}}}); err != nil { - t.Fatal(err) - } - if err := <-done; (err == nil) != (mode == "ready") { - t.Fatal("hook admission", mode, err) - } - go func() { done <- verifySubagentObservationProfile(ctx, client.JSONRPCClient, "/workspace") }() - if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { - t.Fatal(err) - } - if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"data": []any{entry}}}); err != nil { - t.Fatal(err) - } - if mode == "ready" || mode == "empty" { - if err := json.NewDecoder(server.FromClient).Decode(&request); err != nil { - t.Fatal(err) - } - if request.Method != "configRequirements/read" { - t.Fatal(request.Method) - } - if err := json.NewEncoder(server.ToClient).Encode(map[string]any{"id": request.ID, "result": map[string]any{"requirements": map[string]any{"allowManagedHooksOnly": true, "featureRequirements": map[string]bool{"hooks": true}}}}); err != nil { - t.Fatal(err) - } - } - - if err := <-done; (err == nil) != (mode == "ready" || mode == "empty") { - t.Fatal("subagent hook admission", mode, err) - } - - }) - } -} - -func TestInitializedToolHookFailureStopsRunWithoutSuccess(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - out := make(chan proto.Envelope, 4) - s := &Session{toolEnvironment: true, runID: "run", cancelCtx: ctx, cancelFn: cancel, out: out, cfg: defaultSessionConfig(), bufs: NewItemBuffers()} - s.setThreadID("thread") - s.beginRootTurn("thread", "turn") - s.onToolEnvironmentHook(json.RawMessage(`{"threadId":"foreign","turnId":"turn","run":{"sourcePath":"/etc/codex/runtime-hooks","status":"failed"}}`)) - if s.terminal.Load() { - t.Fatal("foreign hook ended run") - } - s.onToolEnvironmentHook(json.RawMessage(`{"threadId":"thread","turnId":"turn","run":{"sourcePath":"/etc/codex/runtime-hooks","status":"failed"}}`)) - if !s.terminal.Load() || ctx.Err() == nil { - t.Fatal("failed hook did not stop execution") - } - if len(out) != 2 || (<-out).Type != proto.TypeError || (<-out).Type != proto.TypeDone { - t.Fatal("failed hook reported wrong terminal events") - } -} diff --git a/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go b/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go index c69396f3d..7fc33a999 100644 --- a/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go +++ b/apps/parsar-daemon/internal/agent/mcode/environment_mcp.go @@ -31,7 +31,7 @@ func environmentMCP(local *proto.LocalEnvironment) ([]map[string]any, error) { } command, args := localworkspace.MCPStdioCommand(item) // The fixed launcher resolves the declaration and selected user variables - // inside the sandbox. Native process variables are never tool input. + // from the installed snapshot in the shared Runtime launcher. servers = append(servers, map[string]any{"name": name, "command": command, "args": args, "env": []map[string]string{}}) } return servers, nil diff --git a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go index 3b78994a6..3b22c5517 100644 --- a/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/environment_mcp_test.go @@ -53,9 +53,10 @@ func TestEnvironmentMCPUsesFixedLauncherForNewAndLoadedSessions(t *testing.T) { t.Fatal("environment MCP displaced workspace tools") } server := params.MCP[1] - if server.Name != "proof.server" || server.Command != "/usr/bin/python3" || server.Env == nil || len(server.Env) != 0 || - !reflect.DeepEqual(server.Args, []string{"-I", "-S", "/usr/local/bin/oac-runtime-initialize", "stdio", "/private/runtime/capabilities", "/private/runtime/workspace", "plugins/fixture", "proof.server"}) { - t.Fatal("ACP declaration bypassed the fixed isolated launcher") + executable, _ := os.Executable() + if server.Name != "proof.server" || server.Command != executable || server.Env == nil || len(server.Env) != 0 || + !reflect.DeepEqual(server.Args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/fixture", "proof.server"}) { + t.Fatal("ACP declaration bypassed the shared Runtime launcher") } if (params.SessionID != "") != resume || strings.Contains(string(raw), "must-not") || strings.Contains(string(raw), "private-argument") { t.Fatal("native attachment changed identity or exposed private inputs") diff --git a/apps/parsar-daemon/internal/agent/mcode/execution.go b/apps/parsar-daemon/internal/agent/mcode/execution.go index 6a3eb80c4..4d2987310 100644 --- a/apps/parsar-daemon/internal/agent/mcode/execution.go +++ b/apps/parsar-daemon/internal/agent/mcode/execution.go @@ -48,16 +48,8 @@ func configureTextExecution(config map[string]any) { config["beta"] = map[string]bool{"browserUseTooling": false, "mcodeTools": false, "threadGoal": false} } -// Only process and model-network essentials cross into the native child. -func executionEnvironment() []string { - var env []string - for _, key := range []string{"PATH", "LANG", "LC_ALL", "TMPDIR", "TMP", "TEMP", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS"} { - if value, ok := os.LookupEnv(key); ok { - env = append(env, key+"="+value) - } - } - return env -} +// Harness children use the daemon user's ordinary environment. +func executionEnvironment() []string { return os.Environ() } // ACP commands are only recognized for a single text block. Public input must // remain user text; ordinary product Sessions retain their native command behavior. diff --git a/apps/parsar-daemon/internal/agent/mcode/execution_test.go b/apps/parsar-daemon/internal/agent/mcode/execution_test.go index 3e704750e..083897f94 100644 --- a/apps/parsar-daemon/internal/agent/mcode/execution_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/execution_test.go @@ -17,7 +17,7 @@ func executionRequest(t *testing.T) proto.PromptRequestPayload { return r } -func TestExecutionOptionsExcludeAmbientAuthority(t *testing.T) { +func TestExecutionOptionsInheritUserEnvironment(t *testing.T) { r := executionRequest(t) t.Setenv("OAC_TEST_SECRET_CANARY", "secret") t.Setenv("NODE_OPTIONS", "--import=untrusted") @@ -25,11 +25,10 @@ func TestExecutionOptionsExcludeAmbientAuthority(t *testing.T) { if err != nil { t.Fatal(err) } - for _, e := range opts.Env { - if strings.HasPrefix(e, "OAC_TEST_SECRET_CANARY=") || strings.HasPrefix(e, "NODE_OPTIONS=") { - t.Fatal("ambient authority inherited") - } + if !strings.Contains(strings.Join(opts.Env, "\n"), "OAC_TEST_SECRET_CANARY=secret") { + t.Fatal("user environment lost") } + data, err := os.ReadFile(filepath.Join(opts.DataDir, "config.yaml")) if err != nil { t.Fatal(err) diff --git a/apps/parsar-daemon/internal/agent/mcode/executor.go b/apps/parsar-daemon/internal/agent/mcode/executor.go index 632bb6a40..ed1b42c69 100644 --- a/apps/parsar-daemon/internal/agent/mcode/executor.go +++ b/apps/parsar-daemon/internal/agent/mcode/executor.go @@ -32,7 +32,6 @@ func NewExecutorFactory(config *WorkspaceConfig) agent.ExecutorFactory { var frozen *WorkspaceConfig if config != nil { value := *config - value.ProtectedDirs = append([]string(nil), config.ProtectedDirs...) value.AllowedDomains = append([]string(nil), config.AllowedDomains...) frozen = &value } diff --git a/apps/parsar-daemon/internal/agent/mcode/preparation_test.go b/apps/parsar-daemon/internal/agent/mcode/preparation_test.go index 65ea1a87e..4fe282689 100644 --- a/apps/parsar-daemon/internal/agent/mcode/preparation_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/preparation_test.go @@ -18,7 +18,7 @@ func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload r := executionRequest(t) r.RunID, r.Input, r.ConversationID = "", nil, "" r.DisableExecutionEnvironment = false - r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "disabled"} + r.LocalEnvironment = &proto.LocalEnvironment{ID: "environment", NetworkAccess: "enabled"} r.WorkDir = t.TempDir() record := filepath.Join(t.TempDir(), "calls") exe, err := os.Executable() @@ -31,7 +31,7 @@ func workspaceFixture(t *testing.T) (WorkspaceConfig, proto.PromptRequestPayload if err := os.WriteFile(binary, []byte(script), 0700); err != nil { t.Fatal(err) } - return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.WorkDir, Network: "disabled", Scratch: t.TempDir(), ProtectedDirs: []string{os.Getenv("OAC_RUNTIME_HOME")}}, r, record + return WorkspaceConfig{Binary: binary, Node: "/usr/bin/node", Bridge: "/opt/bridge.mjs", Directory: r.WorkDir, Network: "enabled", Scratch: t.TempDir()}, r, record } func TestPreparedWorkspaceHasOneInputAndOutputOwner(t *testing.T) { diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index 79ed46902..78a72e57d 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -6,8 +6,7 @@ import ( "fmt" "os" "path/filepath" - "slices" - "strings" + "runtime" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -16,15 +15,21 @@ import ( // WorkspaceConfig is frozen deployment input, separate from public Agent options. type WorkspaceConfig struct { + ExecutionMode string Binary, Node, Bridge, Directory, Network, Scratch string - ProtectedDirs []string AllowedDomains []string } func ConfigureLocal(binary, node, bridge, root, workspace string, network agentnetwork.Policy, staging string) (WorkspaceConfig, error) { c := WorkspaceConfig{Binary: binary, Node: node, Bridge: bridge, Directory: workspace, Network: network.Access, AllowedDomains: network.Hosts(), - Scratch: filepath.Join(root, "runtime", "mcode-tools", "scratch"), - ProtectedDirs: []string{filepath.Join(root, "daemon"), filepath.Join(root, "runtime", "mcode"), filepath.Dir(workspace), staging}} + Scratch: filepath.Join(root, "runtime", "mcode-tools", "scratch")} + binding, bindingErr := localworkspace.Load() + if bindingErr == nil && binding != nil { + c.ExecutionMode = binding.ExecutionMode() + } + if runtime.GOOS == "windows" || network.Access != "enabled" || len(network.AllowedDomains) != 0 { + return c, fmt.Errorf("mcode: native execution requires Linux or macOS and unrestricted host access") + } if network.Validate() != nil { return c, fmt.Errorf("mcode: explicit workspace network policy is required") } @@ -34,16 +39,16 @@ func ConfigureLocal(binary, node, bridge, root, workspace string, network agentn } } for _, path := range []string{binary, node, bridge} { - resolved, err := filepath.EvalSymlinks(path) - if err != nil || resolved != path || strings.HasPrefix(path, workspace+"/") || strings.HasPrefix(path, "/workspace/") { - return c, fmt.Errorf("mcode: runtime programs must be canonical paths outside the workspace") + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return c, fmt.Errorf("mcode: runtime program unavailable") } } bound, err := os.Stat(workspace) - public, publicErr := os.Stat("/workspace") - if err != nil || publicErr != nil || !bound.IsDir() || !os.SameFile(bound, public) { - return c, fmt.Errorf("mcode: public workspace alias does not match the Runtime binding") + if err != nil || !bound.IsDir() { + return c, fmt.Errorf("mcode: workspace directory unavailable") } + if err := os.MkdirAll(c.Scratch, 0700); err != nil { return c, err } @@ -51,6 +56,12 @@ func ConfigureLocal(binary, node, bridge, root, workspace string, network agentn } func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.PromptRequestPayload) (launchOptions, error) { + if req.LocalEnvironment != nil && req.LocalEnvironment.ExecutionMode != c.ExecutionMode { + return launchOptions{}, fmt.Errorf("mcode: execution mode mismatch") + } + if c.Network != "enabled" || len(c.AllowedDomains) != 0 { + return launchOptions{}, fmt.Errorf("mcode: Runtime does not implement network isolation") + } if !req.StrictResume || req.LocalEnvironment == nil || req.WorkDir != c.Directory || req.DisableExecutionEnvironment || !(agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Equal(agentnetwork.Policy{Access: req.LocalEnvironment.NetworkAccess, AllowedDomains: req.LocalEnvironment.AllowedDomains}) || req.WorkspaceReadOnly { return launchOptions{}, fmt.Errorf("mcode: execution does not match the dedicated workspace") } @@ -103,18 +114,21 @@ func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.P if err = os.WriteFile(filepath.Join(opts.DataDir, "config.yaml"), raw, 0600); err != nil { return opts, err } - profile := map[string]any{"capabilityRoot": req.LocalEnvironment.CapabilityRoot, "workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "protectedDirs": slices.Clone(c.ProtectedDirs), "skills": len(req.LocalEnvironment.Skills) > 0} + profile := map[string]any{"capabilityRoot": req.LocalEnvironment.CapabilityRoot, "workspace": "/workspace", "scratch": c.Scratch, "network": c.Network, "allowedDomains": (agentnetwork.Policy{Access: c.Network, AllowedDomains: c.AllowedDomains}).Hosts(), "skills": len(req.LocalEnvironment.Skills) > 0} + + profile["workspace"] = c.Directory if req.LocalEnvironment.ToolEnvironment { - if err := localworkspace.VerifyToolEnvironment(req.LocalEnvironment.SystemPackages); err != nil { + binding, err := localworkspace.Load() + if err != nil || binding == nil { + return opts, fmt.Errorf("mcode: Runtime binding unavailable") + } + values, err := binding.ReadToolEnvironment() + if err != nil { return opts, err } - profile["toolEnvironment"] = true - profile["systemPackages"] = req.LocalEnvironment.SystemPackages - // Initialization exposes only user env/packages; private staging and - // daemon/native history remain explicitly denied. - protected := slices.Clone(c.ProtectedDirs) - profile["protectedDirs"] = slices.DeleteFunc(protected, func(path string) bool { return path == "/environment" }) + profile["toolEnv"] = values } + raw, err = json.Marshal(profile) if err != nil { return opts, err diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go b/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go index 199e6e857..a42d387cd 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace_network_test.go @@ -1,38 +1,14 @@ package mcode -import ( - "encoding/json" - "os" - "path/filepath" - "slices" - "testing" -) +import "testing" -func TestRestrictedWorkspacePolicyUsesExactBoundAuthority(t *testing.T) { - config, req, _ := workspaceFixture(t) - config.Network = "restricted" - config.AllowedDomains = []string{"Example.com", "api.example.com", "example.com"} - req.LocalEnvironment.NetworkAccess = "restricted" - req.LocalEnvironment.AllowedDomains = []string{"api.example.com", "EXAMPLE.COM"} - opts, err := prepareWorkspaceOptions(t.Context(), config, req) - if err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(filepath.Join(opts.DataDir, "workspace-profile.json")) - if err != nil { - t.Fatal(err) - } - var profile struct { - Network string `json:"network"` - Domains []string `json:"allowedDomains"` - } - if err := json.Unmarshal(raw, &profile); err != nil || profile.Network != "restricted" || !slices.Equal(profile.Domains, []string{"api.example.com", "example.com"}) { - t.Fatal("native policy lost bound authority", profile, err) - } - for _, domains := range [][]string{{"example.com"}, {"example.org"}, nil} { - req.LocalEnvironment.AllowedDomains = domains - if _, err := prepareWorkspaceOptions(t.Context(), config, req); err == nil { - t.Fatal("different policy entered bound Runtime", domains) +func TestWorkspaceRejectsInnerNetworkIsolation(t *testing.T) { + for _, access := range []string{"disabled", "restricted"} { + c, req, _ := workspaceFixture(t) + c.Network = access + req.LocalEnvironment.NetworkAccess = access + if _, err := prepareWorkspaceOptions(t.Context(), c, req); err == nil { + t.Fatal("unsupported network isolation accepted") } } } diff --git a/apps/parsar-daemon/internal/auth/store.go b/apps/parsar-daemon/internal/auth/store.go index 910e63305..1dea39040 100644 --- a/apps/parsar-daemon/internal/auth/store.go +++ b/apps/parsar-daemon/internal/auth/store.go @@ -11,10 +11,10 @@ import ( "errors" "fmt" "os" - "path/filepath" "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) // Profile is the on-disk representation of one paired credential. @@ -68,21 +68,22 @@ func Save(profile string, p Profile) error { if err != nil { return err } - authFile := filepath.Join(dir, "auth.json") - tmp := authFile + ".tmp" raw, err := json.MarshalIndent(p, "", " ") if err != nil { - return fmt.Errorf("auth: marshal profile: %w", err) + return errors.New("auth: could not encode profile") } - // os.WriteFile with the final mode in one shot, so umask is - // irrelevant. - if err := os.WriteFile(tmp, raw, 0o600); err != nil { - return fmt.Errorf("auth: write tmp: %w", err) + if err = runtimefs.EnsurePrivateDir(dir); err != nil { + return errors.New("auth: private profile unavailable") } - if err := os.Rename(tmp, authFile); err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("auth: rename: %w", err) + held, err := os.OpenRoot(dir) + if err != nil { + return errors.New("auth: private profile unavailable") + } + defer held.Close() + if err = runtimefs.WritePrivateAtomic(held, "auth.json", raw); err != nil { + return errors.New("auth: private profile write failed") } + return nil } diff --git a/apps/parsar-daemon/internal/auth/store_test.go b/apps/parsar-daemon/internal/auth/store_test.go index 0f87ba2ae..f9d28e404 100644 --- a/apps/parsar-daemon/internal/auth/store_test.go +++ b/apps/parsar-daemon/internal/auth/store_test.go @@ -5,6 +5,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime" "testing" "time" @@ -60,9 +61,9 @@ func TestSaveSetsRestrictivePerms(t *testing.T) { if err != nil { t.Fatalf("stat auth.json: %v", err) } - // auth.json holds the long-lived runner_credential — anything - // but 0600 leaks it on a shared CI box. - if mode := info.Mode().Perm(); mode != 0o600 { + // Unix stores credentials with 0600; Windows uses normal account ACLs, + // which are not represented by Go permission bits. + if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o600 { t.Errorf("auth.json perm = %o, want 0600", mode) } } @@ -109,7 +110,7 @@ func TestSaveOverwritesAndHealsPerms(t *testing.T) { if err != nil { t.Fatalf("stat: %v", err) } - if mode := info.Mode().Perm(); mode != 0o600 { + if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o600 { t.Errorf("perm after re-save = %o, want 0600 (healing failed)", mode) } got, err := auth.Load("default") diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go index 746288935..88b0e461f 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -60,14 +60,11 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex return fail(err) } out.Config = claudesdk.Config{Node: node, Entrypoint: entrypoint, StateDir: filepath.Join(profileDir, "runtime", "claude-sdk")} - if mode := os.Getenv("OAC_RUNTIME_CLAUDE_SDK_WORKSPACE"); mode != "" { - if mode != "managed" { - return fail(fmt.Errorf("unsupported Claude SDK workspace profile")) - } - binding, err := localworkspace.Load() - if err != nil || binding == nil { - return fail(fmt.Errorf("Claude SDK workspace requires a dedicated local Runtime binding")) - } + binding, err := localworkspace.Load() + if err != nil { + return fail(err) + } + if binding != nil { root, err := paths.Root() if err != nil { return fail(err) @@ -94,7 +91,7 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex } caps := &out.Info.Capabilities caps.EnvironmentNone, caps.FunctionTools = false, info.SupportsWorkspaceFunctions() - caps.Preparation, caps.LocalEnvironment, caps.LocalEnvironmentNetworkPolicy = true, true, true + caps.Preparation, caps.LocalEnvironment = true, true caps.WorkspaceReadPreparation, caps.NativeSessionRecovery = true, true } out.Info.Available, out.Info.Version = true, info.SDK diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index 61f0ebe4a..ad2b67ad1 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -6,9 +6,7 @@ import ( "fmt" "log/slog" "os" - "os/signal" "strings" - "syscall" "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" @@ -259,22 +257,9 @@ func mainLoopRemote(rc *runContext, profile string, prof auth.Profile, agentCLIs Out: rc.stderr, }) - rootCtx, cancel := context.WithCancel(context.Background()) + rootCtx, cancel := daemonize.NotifyContext(context.Background()) defer cancel() - // Honour SIGINT / SIGTERM as graceful shutdown. - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - go func() { - select { - case sig := <-sigCh: - obslog.Bg().Info("received signal, shutting down", "signal", sig.String()) - cancel() - case <-rootCtx.Done(): - } - signal.Stop(sigCh) - }() - bootCtx, bootCancel := context.WithTimeout(rootCtx, bootstrapTimeout) var boot *transport.BootstrapResponse var err error diff --git a/apps/parsar-daemon/internal/cli/connect_environment.go b/apps/parsar-daemon/internal/cli/connect_environment.go index bfdb5a263..72ea39eb7 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment.go +++ b/apps/parsar-daemon/internal/cli/connect_environment.go @@ -11,9 +11,7 @@ import ( "net/http" "net/url" "os" - "os/signal" "strings" - "syscall" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/auth" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" @@ -207,7 +205,7 @@ func environmentRejection(err error, keyID, environment string, selfHosted bool) // restarts every exit, so an exit would loop; a parked Runtime still restarts // after a reboot, makes one enrollment request and parks again. func parkEnvironment(stderr io.Writer, message string) error { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + ctx, stop := daemonize.NotifyContext(context.Background()) defer stop() fmt.Fprintln(stderr, "oac-daemon: "+message) <-ctx.Done() diff --git a/apps/parsar-daemon/internal/cli/connect_environment_binding.go b/apps/parsar-daemon/internal/cli/connect_environment_binding.go index 3eb887058..9d79bed2f 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_binding.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_binding.go @@ -1,17 +1,15 @@ package cli import ( - "bytes" "encoding/json" "errors" - "io" "os" "path/filepath" - "syscall" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) // This receipt contains identity only; executor credentials remain in their file. @@ -40,27 +38,7 @@ func checkEnvironmentTarget(remote, environment string) error { } func readEnvironmentPrivateFile(path string) ([]byte, error) { - if !filepath.IsAbs(path) || filepath.Clean(path) != path { - return nil, errors.New("absolute private file required") - } - f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) - if err != nil { - return nil, err - } - defer f.Close() - info, err := f.Stat() - if err != nil { - return nil, err - } - 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("private owned regular file required") - } - raw, err := io.ReadAll(io.LimitReader(f, 16*1024+1)) - if err != nil || len(raw) > 16*1024 { - return nil, errors.New("private file exceeds limit") - } - return raw, nil + return runtimefs.ReadPrivatePath(path, 16*1024) } func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credentialFile string) error { @@ -68,30 +46,20 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti if err != nil || !filepath.IsAbs(root) { return errors.New("connect: absolute Runtime state directory required") } - if err = os.MkdirAll(root, 0700); err != nil { + if err = runtimefs.EnsurePrivateDir(root); err != nil { return errors.New("connect: Runtime state directory unavailable") } workspace := os.Getenv("OAC_RUNTIME_WORKSPACE") if workspace == "/" || agentcapabilities.ValidateLocalDirectories([]string{workspace}) != nil { return errors.New("connect: clean absolute Runtime workspace required") } - canonical, err := filepath.EvalSymlinks(workspace) info, statErr := os.Stat(workspace) - if err != nil || canonical != workspace || statErr != nil || !info.IsDir() { - return errors.New("connect: existing canonical Runtime workspace directory required") + if statErr != nil || !info.IsDir() { + return errors.New("connect: existing Runtime workspace required") } if bound.WorkspaceDirectory != "/workspace" && bound.WorkspaceDirectory != workspace { return errors.New("connect: enrollment does not match the Runtime workspace") } - initialization := localworkspace.InitializationDirectory - if resolved, resolveErr := filepath.EvalSymlinks(initialization); resolveErr == nil { - initialization = resolved - } else if !errors.Is(resolveErr, os.ErrNotExist) { - return errors.New("connect: protected initialization directory unavailable") - } - if environmentPathsOverlap(workspace, initialization) { - return errors.New("connect: Runtime workspace overlaps protected initialization") - } for key, value := range map[string]string{ "OAC_RUNTIME_ENVIRONMENT_ID": bound.EnvironmentID, "OAC_RUNTIME_SESSION_ID": bound.SessionID, @@ -104,30 +72,6 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti if domains := os.Getenv("OAC_RUNTIME_ALLOWED_DOMAINS"); domains != "" && domains != "[]" { return errors.New("connect: conflicting Runtime network domains") } - resolvedRoot, err := filepath.Abs(root) - if err != nil { - return errors.New("connect: Runtime state directory unavailable") - } - for _, path := range []string{resolvedRoot, credentialFile} { - resolved, e := filepath.EvalSymlinks(path) - if e != nil { - return errors.New("connect: private Runtime path unavailable") - } - for _, public := range []string{"/workspace", workspace} { - if environmentPathsOverlap(public, resolved) { - return errors.New("connect: private Runtime state cannot overlap the workspace") - } - } - } - private, err := filepath.EvalSymlinks(filepath.Join(root, "daemon")) - credentialPath, credentialErr := filepath.EvalSymlinks(credentialFile) - if err != nil || credentialErr != nil { - return errors.New("connect: protected daemon credential directory required") - } - relative, err := filepath.Rel(private, credentialPath) - if err != nil || !filepath.IsLocal(relative) || relative == "." { - return errors.New("connect: executor credential must be inside the protected daemon directory") - } capabilityDirectory := os.Getenv("OAC_RUNTIME_CAPABILITY_DIRECTORY") if capabilityDirectory == "" { capabilityDirectory = localworkspace.CapabilityDirectory @@ -146,7 +90,7 @@ func bindEnvironmentRuntime(remote string, bound environmentEnrollment, credenti } local, err := localworkspace.Load() if err != nil || local == nil { - return errors.New("connect: packaged Runtime binding or helpers unavailable") + return errors.New("connect: Runtime binding unavailable") } return nil } @@ -163,19 +107,20 @@ func environmentPathsOverlap(first, second string) bool { } func saveEnvironmentBinding(root string, want environmentBinding) error { - // All three native sandboxes protect this existing daemon state directory. + // Store the binding with the other daemon state. dir := filepath.Join(root, "daemon") - if err := os.MkdirAll(dir, 0700); err != nil { + if err := runtimefs.EnsurePrivateDir(dir); err != nil { return errors.New("connect: Runtime state directory unavailable") } for _, path := range []string{root, dir} { - info, e := os.Lstat(path) - if e != nil || !info.IsDir() || info.Mode().Perm()&0077 != 0 { - return errors.New("connect: Runtime state directory must be private") + held, err := os.OpenRoot(path) + if err != nil { + return errors.New("connect: Runtime state directory unavailable") } - st, ok := info.Sys().(*syscall.Stat_t) - if !ok || st.Uid != uint32(os.Getuid()) { - return errors.New("connect: Runtime state directory must be owned") + err = runtimefs.PrivateDirectory(held) + held.Close() + if err != nil { + return errors.New("connect: Runtime state directory must be private and owned") } } path := filepath.Join(dir, "environment.json") @@ -206,14 +151,19 @@ func saveEnvironmentBinding(root string, want environmentBinding) error { } } data, _ := json.Marshal(want) - f, e := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0600) + held, e := os.OpenRoot(dir) + if e != nil { + return errors.New("connect: Runtime binding directory unavailable") + } + defer held.Close() + f, e := runtimefs.OpenPrivate(held, "environment.json", os.O_WRONLY|os.O_CREATE|os.O_EXCL) if errors.Is(e, os.ErrExist) { return saveEnvironmentBinding(root, want) } if e != nil { return errors.New("connect: could not establish Runtime binding") } - _, e = io.Copy(f, bytes.NewReader(data)) + _, e = f.Write(data) if e == nil { e = f.Sync() } diff --git a/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go b/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go index b9223181e..aedd3ce06 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_binding_test.go @@ -75,7 +75,7 @@ func TestBindEnvironmentRuntimeUsesCanonicalOperatorWorkspace(t *testing.T) { } } -func TestBindEnvironmentRuntimeRejectsUnsafeOrMismatchedWorkspace(t *testing.T) { +func TestBindEnvironmentRuntimeChecksIdentityAndPathValidity(t *testing.T) { for _, kind := range []string{"relative", "unclean", "missing", "file", "symlink", "parent symlink", "workspace contains private", "workspace inside private", "foreign enrollment", "credential outside private", "identity conflict"} { t.Run(kind, func(t *testing.T) { root, workspace, credential, bound := environmentRuntimeFixture(t) @@ -125,12 +125,21 @@ func TestBindEnvironmentRuntimeRejectsUnsafeOrMismatchedWorkspace(t *testing.T) t.Setenv("OAC_RUNTIME_ENVIRONMENT_ID", uuid.NewString()) } t.Setenv("OAC_RUNTIME_WORKSPACE", selected) - if err := bindEnvironmentRuntime("wss://core/api/v1/agent-daemon/ws", bound, credential); err == nil { - t.Fatal("unsafe binding accepted") - } - if _, err := os.Lstat(filepath.Join(root, "daemon", "environment.json")); !os.IsNotExist(err) { - t.Fatal("invalid binding published identity", err) + allowed := kind == "symlink" || kind == "parent symlink" || kind == "workspace contains private" || kind == "workspace inside private" || kind == "credential outside private" + err := bindEnvironmentRuntime("wss://core/api/v1/agent-daemon/ws", bound, credential) + if allowed { + if err != nil { + t.Fatal("operator layout rejected", err) + } + } else { + if err == nil { + t.Fatal("invalid binding accepted") + } + if _, err := os.Stat(filepath.Join(root, "daemon", "environment.json")); !os.IsNotExist(err) { + t.Fatal("invalid binding published identity", err) + } } + }) } } diff --git a/apps/parsar-daemon/internal/cli/connect_environment_test.go b/apps/parsar-daemon/internal/cli/connect_environment_test.go index 7cefe7261..2415ff942 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_test.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_test.go @@ -138,14 +138,14 @@ func TestExecutorCredentialFile(t *testing.T) { if err := os.Symlink(path, link); err != nil { t.Fatal(err) } - if _, _, err := executorCredential(link, environment); err == nil { - t.Fatal("symlink accepted") + if _, _, err := executorCredential(link, environment); err != nil { + t.Fatal("operator symlink rejected", err) } if err := os.Chmod(path, 0644); err != nil { t.Fatal(err) } - if _, _, err := executorCredential(path, environment); err == nil { - t.Fatal("public credential accepted") + if _, _, err := executorCredential(path, environment); err != nil { + t.Fatal("operator permissions rejected", err) } } @@ -186,7 +186,7 @@ func TestEnvironmentBindingPreservesIdentityAndHistory(t *testing.T) { } } -func TestEnvironmentBindingAllowsPackagedFilesAndRejectsUnsafeReceipt(t *testing.T) { +func TestEnvironmentBindingAllowsOperatorFilePermissions(t *testing.T) { root := t.TempDir() if err := os.Chmod(root, 0700); err != nil { t.Fatal(err) @@ -202,8 +202,8 @@ func TestEnvironmentBindingAllowsPackagedFilesAndRejectsUnsafeReceipt(t *testing if err := os.Chmod(path, 0644); err != nil { t.Fatal(err) } - if err := saveEnvironmentBinding(root, want); err == nil { - t.Fatal("public receipt accepted") + if err := saveEnvironmentBinding(root, want); err != nil { + t.Fatal("operator permissions rejected", err) } } diff --git a/apps/parsar-daemon/internal/cli/connect_suspend_test.go b/apps/parsar-daemon/internal/cli/connect_suspend_test.go index 072c1fc78..80e1961c5 100644 --- a/apps/parsar-daemon/internal/cli/connect_suspend_test.go +++ b/apps/parsar-daemon/internal/cli/connect_suspend_test.go @@ -1,3 +1,5 @@ +//go:build linux + package cli import ( diff --git a/apps/parsar-daemon/internal/cli/mcode_workspace.go b/apps/parsar-daemon/internal/cli/mcode_workspace.go index 9e7d45f8d..50b19078e 100644 --- a/apps/parsar-daemon/internal/cli/mcode_workspace.go +++ b/apps/parsar-daemon/internal/cli/mcode_workspace.go @@ -14,21 +14,20 @@ import ( ) func discoverMCodeWorkspace(rc *runContext, discovery *agentCLIDiscovery) { - mode := os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE") - if mode == "" { - return - } fail := func(err error) { discovery.MCode.Available = false fmt.Fprintf(rc.stderr, "oac-daemon: mcode workspace unavailable: %v\n", err) } - if mode != "managed" || !discovery.MCode.Available || !mcode.SupportsExecution(discovery.MCode.Version) { - fail(fmt.Errorf("managed execution requires the qualified native version and opt-in")) + binding, err := localworkspace.Load() + if err != nil { + fail(err) + return + } + if binding == nil { return } - binding, err := localworkspace.Load() - if err != nil || binding == nil { - fail(fmt.Errorf("dedicated local Runtime binding required")) + if !discovery.MCode.Available || !mcode.SupportsExecution(discovery.MCode.Version) { + fail(fmt.Errorf("local execution requires the qualified native version")) return } root, err := paths.Root() @@ -71,6 +70,6 @@ func discoverMCodeWorkspace(rc *runContext, discovery *agentCLIDiscovery) { discovery.MCodeWorkspace = &c caps := &discovery.MCode.Capabilities caps.EnvironmentNone = false - caps.Preparation, caps.LocalEnvironment, caps.LocalEnvironmentNetworkPolicy = true, true, true + caps.Preparation, caps.LocalEnvironment = true, true caps.WorkspaceReadPreparation = true } diff --git a/apps/parsar-daemon/internal/cli/native_install.go b/apps/parsar-daemon/internal/cli/native_install.go new file mode 100644 index 000000000..2b55e8cf2 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_install.go @@ -0,0 +1,140 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" +) + +// Native installation is a user-owned configuration, not an OS service or sandbox. +// The same binary can be run by an operator's preferred service manager. +type nativeInstallation struct { + Version string `json:"version"` + Remote string `json:"remote_url"` + Environment string `json:"environment_id"` + Workspace string `json:"workspace_directory"` + Credential string `json:"credential_file"` + CapabilityDirectory string `json:"capability_directory"` + ToolEnvironmentFile string `json:"tool_environment_file,omitempty"` +} + +func nativeInstallationPath() (string, error) { + root, err := paths.Root() + if err != nil { + return "", err + } + return filepath.Join(root, "daemon", "installation.json"), nil +} + +func runInstall(rc *runContext, args []string) error { + flags := newFlagSet("install") + remote := flags.String("remote", "", "Environment remote_url from Core") + environment := flags.String("environment-id", "", "Environment ID from Core") + workspace := flags.String("workspace", "", "existing absolute workspace directory") + credential := flags.String("credential-file", "", "absolute executor credential JSON file") + capabilities := flags.String("capability-directory", "", "installed capability snapshot directory (defaults to Runtime state)") + toolEnv := flags.String("tool-env-file", "", "optional absolute JSON file with tool environment variables") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return errors.New("install: unexpected positional arguments") + } + if _, err := environmentBase(*remote); err != nil { + return err + } + if !environmentUUID(*environment) { + return errors.New("install: canonical Environment ID required") + } + root, err := paths.Root() + if err != nil { + return err + } + if *capabilities == "" { + *capabilities = filepath.Join(root, "capabilities") + } + for _, path := range []string{*workspace, *credential, *capabilities} { + if runtimefs.ValidateLocalPath(path) != nil { + return errors.New("install: clean absolute paths required") + } + } + info, err := os.Stat(*workspace) + if err != nil || !info.IsDir() { + return errors.New("install: existing workspace directory required") + } + if _, _, err = executorCredential(*credential, *environment); err != nil { + return err + } + if *toolEnv != "" && runtimefs.ValidateLocalPath(*toolEnv) != nil { + return errors.New("install: absolute tool environment file required") + } + config := nativeInstallation{Version: Version, Remote: *remote, Environment: *environment, Workspace: *workspace, Credential: *credential, CapabilityDirectory: *capabilities, ToolEnvironmentFile: *toolEnv} + path, err := nativeInstallationPath() + if err != nil { + return err + } + if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + held, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return err + } + defer held.Close() + unlock, err := runtimefs.LockDirectory(held) + if err != nil { + return err + } + defer unlock() + if _, err = held.Stat(filepath.Base(path)); err == nil { + return errors.New("install: installation already exists; in-place upgrades are unsupported, use its current binary or reinstall explicitly") + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + raw, _ := json.MarshalIndent(config, "", " ") + if err = runtimefs.WritePrivateAtomic(held, filepath.Base(path), raw); err != nil { + return err + } + fmt.Fprintln(rc.stdout, "Installed daemon configuration:", path) + fmt.Fprintln(rc.stdout, "Run oac-daemon start; use stop, status and logs with the same OAC_RUNTIME_HOME.") + return nil +} + +func runStart(rc *runContext, args []string) error { + flags := newFlagSet("start") + foreground := flags.Bool("foreground", false, "stay attached to this terminal") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return errors.New("start: unexpected positional arguments") + } + path, err := nativeInstallationPath() + if err != nil { + return err + } + raw, err := os.ReadFile(path) + var config nativeInstallation + if err != nil || decodeEnvironmentJSON(raw, &config) != nil { + return errors.New("start: installation configuration unavailable; run install first") + } + if config.Version != Version { + return errors.New("start: this installation version is unsupported; reinstall explicitly") + } + for key, value := range map[string]string{ + "OAC_RUNTIME_EXECUTION_MODE": "native", + "OAC_RUNTIME_WORKSPACE": config.Workspace, + "OAC_RUNTIME_CAPABILITY_DIRECTORY": config.CapabilityDirectory, + "OAC_RUNTIME_TOOL_ENV_FILE": config.ToolEnvironmentFile, + } { + if err = os.Setenv(key, value); err != nil { + return err + } + } + return runEnvironmentConnect(rc, paths.DefaultProfile, !*foreground, config.Remote, config.Environment, config.Credential, false) +} diff --git a/apps/parsar-daemon/internal/cli/native_install_test.go b/apps/parsar-daemon/internal/cli/native_install_test.go new file mode 100644 index 000000000..21d21403f --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_install_test.go @@ -0,0 +1,59 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" +) + +func TestNativeInstallationPersistsWithoutStartingOrDeleting(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + workspace := filepath.Join(root, "workspace") + if err := os.MkdirAll(workspace, 0700); err != nil { + t.Fatal(err) + } + key := filepath.Join(root, "credential.json") + environment := uuid.NewString() + body, _ := json.Marshal(map[string]string{"key_id": uuid.NewString(), "executor_token": "test-token", "environment_id": environment}) + if err := os.WriteFile(key, body, 0600); err != nil { + t.Fatal(err) + } + output := new(bytes.Buffer) + rc := &runContext{stdout: output, stderr: output} + args := []string{"--remote", "ws://localhost:12345/api/v1/agent-daemon/ws", "--environment-id", environment, "--workspace", workspace, "--credential-file", key} + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + path, _ := nativeInstallationPath() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var installed nativeInstallation + if json.Unmarshal(raw, &installed) != nil || installed.Environment != environment || installed.Workspace != workspace { + t.Fatal("installation did not persist its configuration") + } + if err := runInstall(rc, args); err == nil { + t.Fatal("silently replaced existing installation") + } + again, _ := os.ReadFile(path) + if !bytes.Equal(raw, again) { + t.Fatal("reinstall changed existing data") + } + if _, err := os.Stat(key); err != nil { + t.Fatal("credential file removed") + } + installed.Version = "unsupported-old-version" + invalid, _ := json.Marshal(installed) + if err := os.WriteFile(path, invalid, 0600); err != nil { + t.Fatal(err) + } + if err := runStart(rc, []string{"--foreground"}); err == nil { + t.Fatal("old installation accepted") + } +} diff --git a/apps/parsar-daemon/internal/cli/root.go b/apps/parsar-daemon/internal/cli/root.go index 46f72f553..441da1bc7 100644 --- a/apps/parsar-daemon/internal/cli/root.go +++ b/apps/parsar-daemon/internal/cli/root.go @@ -33,8 +33,10 @@ func defaultRunContext() *runContext { // commands lists subcommands in --help render order: the user's // likely flow connect → status → stop / logs → logout. var commands = []command{ + {name: "install", summary: "Configure a native self-hosted daemon", run: runInstall}, + {name: "start", summary: "Start the installed native daemon", run: runStart}, {name: "resume", summary: "Wake one planned hosted suspension", run: runResume}, - {name: "runtime-mcp-exec", summary: "Execute installed MCP inside the packaged Runtime sandbox", run: runRuntimeMCP}, + {name: "runtime-mcp-exec", summary: "Execute an installed MCP server", run: runRuntimeMCP}, {name: "placement", summary: "Enroll or retire an explicitly managed local execution placement", run: runPlacement}, {name: "connect", summary: "Pair, open the reverse WebSocket, and start serving prompts", run: runConnect}, {name: "status", summary: "Print the paired profile and daemon state", run: runStatus}, diff --git a/apps/parsar-daemon/internal/cli/root_test.go b/apps/parsar-daemon/internal/cli/root_test.go index 77f1be614..c27176d35 100644 --- a/apps/parsar-daemon/internal/cli/root_test.go +++ b/apps/parsar-daemon/internal/cli/root_test.go @@ -60,6 +60,8 @@ func TestSubcommandsAreRegistered(t *testing.T) { // Guards against dropping a subcommand off the commands slice — // the public CLI surface is the shipped contract. want := map[string]bool{ + "install": false, + "start": false, "resume": false, "runtime-mcp-exec": false, "placement": false, diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp.go b/apps/parsar-daemon/internal/cli/runtime_mcp.go index fcd030536..dd78bc0af 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp.go @@ -20,8 +20,7 @@ type mcpInvocation struct { env []string } -// runRuntimeMCP executes only inside the packaged initializer's sandbox. It -// never consumes MCP stdin, opens a daemon connection or owns a child process. +// runRuntimeMCP forwards stdio to the selected installed MCP server. func runRuntimeMCP(_ *runContext, args []string) error { if len(args) != 3 || args[0] == "/" || agentcapabilities.ValidateLocalDirectories([]string{args[0]}) != nil { return errRuntimeMCP @@ -58,14 +57,16 @@ func resolveMCPInvocation(manifest agentcapabilities.Manifest, installationRoot, if server.Type != "stdio" { return mcpInvocation{}, errRuntimeMCP } - // Defaults locate installed dependencies. Other user values require an - // explicit env_vars declaration; the native launcher's env is never read. - env := map[string]string{ - "PATH": "/environment/packages/npm/bin:/environment/packages/python/bin:/usr/local/bin:/usr/bin:/bin", - "PYTHONPATH": "/environment/packages/python", - "HOME": "/tmp", - "LANG": "C.UTF-8", + // MCP runs under the same user environment as the daemon. Explicit + // capability variables override inherited values. + env := map[string]string{} + for _, entry := range os.Environ() { + key, value, ok := strings.Cut(entry, "=") + if ok { + env[key] = value + } } + for _, key := range server.EnvVars { value, exists := values[key] if !exists || strings.ContainsRune(value, 0) { diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_other.go b/apps/parsar-daemon/internal/cli/runtime_mcp_other.go deleted file mode 100644 index e8fb291ad..000000000 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_other.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build !linux - -package cli - -func execRuntimeMCP(mcpInvocation) error { return errRuntimeMCP } diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_test.go b/apps/parsar-daemon/internal/cli/runtime_mcp_test.go index 960b517a5..ec8366c79 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_test.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_test.go @@ -8,7 +8,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentplugin" ) -func TestRuntimeMCPSelectsOnlyInstalledUserEnvironment(t *testing.T) { +func TestRuntimeMCPOverlaysExplicitVariablesOnUserEnvironment(t *testing.T) { manifest := agentcapabilities.Manifest{MCP: []agentcapabilities.InstalledMCP{{PackageRoot: "plugins/0", Server: agentplugin.MCPServer{ Name: "local", Type: "stdio", Command: "python3", Args: []string{"proof.py", "${LITERAL}"}, EnvVars: []string{"SELECTED"}, CWD: "./server", }}}} @@ -17,8 +17,8 @@ func TestRuntimeMCPSelectsOnlyInstalledUserEnvironment(t *testing.T) { got, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", map[string]string{"SELECTED": "user-value", "UNDECLARED": "not-injected"}) if err != nil || got.command != "python3" || got.cwd != "/private/runtime/capabilities/plugins/0/server" || !slices.Equal(got.args, []string{"python3", "proof.py", "${LITERAL}"}) || !slices.Contains(got.env, "SELECTED=user-value") || - slices.Contains(got.env, "UNDECLARED=not-injected") || slices.Contains(got.env, "NATIVE_ONLY=private-native-value") { - t.Fatalf("incorrect isolated invocation: %+v %v", got, err) + slices.Contains(got.env, "UNDECLARED=not-injected") || !slices.Contains(got.env, "NATIVE_ONLY=private-native-value") { + t.Fatalf("incorrect invocation configuration: %v", err) } if _, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", map[string]string{}); err == nil { t.Fatal("missing user value fell back to native environment") @@ -34,7 +34,7 @@ func TestRuntimeMCPKeepsAbsoluteCWDAndRejectsHTTP(t *testing.T) { }}}} got, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", nil) if err != nil || got.cwd != "/workspace" { - t.Fatalf("absolute cwd changed: %+v %v", got, err) + t.Fatalf("absolute cwd changed: %v", err) } manifest.MCP[0].Server.Type = "http" if _, err := resolveMCPInvocation(manifest, "/private/runtime/capabilities", "plugins/0", "local", nil); err == nil { diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_linux.go b/apps/parsar-daemon/internal/cli/runtime_mcp_unix.go similarity index 96% rename from apps/parsar-daemon/internal/cli/runtime_mcp_linux.go rename to apps/parsar-daemon/internal/cli/runtime_mcp_unix.go index 73396f8dc..9c7d1df9d 100644 --- a/apps/parsar-daemon/internal/cli/runtime_mcp_linux.go +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_unix.go @@ -1,4 +1,4 @@ -//go:build linux +//go:build !windows package cli diff --git a/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go b/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go new file mode 100644 index 000000000..a1f3f9c49 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/runtime_mcp_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package cli + +import ( + "os" + "os/exec" +) + +func execRuntimeMCP(invocation mcpInvocation) error { + command := exec.Command(invocation.command, invocation.args[1:]...) + command.Dir, command.Env = invocation.cwd, invocation.env + command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := command.Run(); err != nil { + return errRuntimeMCP + } + return nil +} diff --git a/apps/parsar-daemon/internal/cli/stop.go b/apps/parsar-daemon/internal/cli/stop.go index 125cb4c49..e58f32301 100644 --- a/apps/parsar-daemon/internal/cli/stop.go +++ b/apps/parsar-daemon/internal/cli/stop.go @@ -9,11 +9,8 @@ import ( "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" ) -// runStop sends SIGTERM to the pid in ~/.oac/daemon// -// connect.pid, escalates to SIGKILL after killTimeout, then removes -// the pidfile. Idempotent: missing / stale pidfile cleans up and -// exits 0 — the user's goal is "no background daemon" and that holds -// either way. +// runStop requests cleanup from the recorded daemon identity. A timeout keeps +// the ownership record; only confirmed exit permits removing it. func runStop(ctx *runContext, args []string) error { fs := newFlagSet("stop") profile := fs.String("profile", paths.DefaultProfile, "profile name to stop") @@ -44,13 +41,10 @@ func runStop(ctx *runContext, args []string) error { return fmt.Errorf("stop: read pidfile: %w", err) } - fmt.Fprintf(ctx.stdout, "oac-daemon: sending SIGTERM to pid=%d\n", pid) - if err := daemonize.SignalAndWait(pid, killTimeout); err != nil { + fmt.Fprintf(ctx.stdout, "oac-daemon: requesting stop for pid=%d\n", pid) + if err := daemonize.StopPIDFile(pidPath, killTimeout); err != nil { return fmt.Errorf("stop: signal: %w", err) } - if err := daemonize.RemovePIDFile(pidPath); err != nil { - return fmt.Errorf("stop: remove pidfile: %w", err) - } fmt.Fprintln(ctx.stdout, "oac-daemon: stopped") return nil } diff --git a/apps/parsar-daemon/internal/cli/suspend_control.go b/apps/parsar-daemon/internal/cli/suspend_control.go index 745ffa172..eedb7a545 100644 --- a/apps/parsar-daemon/internal/cli/suspend_control.go +++ b/apps/parsar-daemon/internal/cli/suspend_control.go @@ -1,3 +1,5 @@ +//go:build linux + package cli import ( diff --git a/apps/parsar-daemon/internal/cli/suspend_process_other.go b/apps/parsar-daemon/internal/cli/suspend_process_other.go index cdf1bc1f4..42073245d 100644 --- a/apps/parsar-daemon/internal/cli/suspend_process_other.go +++ b/apps/parsar-daemon/internal/cli/suspend_process_other.go @@ -2,11 +2,37 @@ package cli -import "errors" +import ( + "context" + "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "os" +) -func suspendProcessStart(int) (string, error) { - return "", errors.New("hosted suspension requires Linux") +const suspendControlEnv = "OAC_RUNTIME_DAEMON_SUSPEND_PID_FILE" + +type suspendIdentity struct { + EnvironmentID string + SuspendID string +} +type suspendControl struct { + identity suspendIdentity + signal chan os.Signal + lastResumed *proto.EnvironmentSuspendPayload +} + +func newSuspendControl() (*suspendControl, error) { + if os.Getenv(suspendControlEnv) != "" { + return nil, errors.New("hosted suspension requires Linux") + } + return nil, nil +} +func (c *suspendControl) Arm(proto.EnvironmentSuspendPayload) error { + return errors.New("hosted suspension requires Linux") } -func signalSuspendedProcess(suspendIdentity) error { +func (c *suspendControl) Wait(context.Context) error { return errors.New("hosted suspension requires Linux") } +func (c *suspendControl) Disarm() error { return errors.New("hosted suspension requires Linux") } +func (c *suspendControl) Close() {} +func runResume(*runContext, []string) error { return errors.New("hosted suspension requires Linux") } diff --git a/apps/parsar-daemon/internal/daemonize/fork.go b/apps/parsar-daemon/internal/daemonize/fork.go index 5e8380cb5..8803c7ee1 100644 --- a/apps/parsar-daemon/internal/daemonize/fork.go +++ b/apps/parsar-daemon/internal/daemonize/fork.go @@ -1,5 +1,5 @@ // Package daemonize gives `oac-daemon connect -b` a no-cgo way to -// detach from the controlling terminal on macOS + Linux. Strategy is +// detach from the controlling terminal on macOS, Linux and Windows. Strategy is // re-exec-the-binary rather than POSIX double-fork: the parent opens // connect.log + connect.pid, then starts a fresh copy of its own // argv with stdio redirected to the log file and a sentinel env var @@ -16,7 +16,7 @@ import ( "fmt" "os" "os/exec" - "syscall" + "strings" ) // BackgroundSentinelEnv is set by the parent on the child's @@ -38,8 +38,8 @@ type ReExecOptions struct { // /dev/null; stdout+stderr are appended to this file (0o600). LogPath string - // PIDPath is the absolute pidfile path. The parent writes the - // child's PID here before returning; existing files are replaced + // PIDPath is the absolute process-record path. The parent writes the + // child identity here before returning; existing files are replaced // atomically. PIDPath string @@ -63,7 +63,7 @@ func Spawn(argv []string, opts ReExecOptions) (int, error) { return 0, errors.New("daemonize.Spawn: LogPath and PIDPath required") } - logFile, err := os.OpenFile(opts.LogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + logFile, err := openPrivateLog(opts.LogPath) if err != nil { return 0, fmt.Errorf("daemonize: open log %s: %w", opts.LogPath, err) } @@ -96,29 +96,42 @@ func Spawn(argv []string, opts ReExecOptions) (int, error) { cmd.Stdin = devNull cmd.Stdout = logFile cmd.Stderr = logFile - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setsid: true, // new session → no controlling tty + cleanup, ready, err := configureBackground(cmd) + if err != nil { + return 0, err } + defer cleanup() if err := cmd.Start(); err != nil { return 0, fmt.Errorf("daemonize: start child: %w", err) } pid := cmd.Process.Pid - - // Release rather than Wait — don't take zombie reaping duties. - // Init takes over once the parent exits. - if err := cmd.Process.Release(); err != nil { - // Non-fatal; child is already running. Log to the caller's - // chain so it shows up in stderr but doesn't fail the spawn. - fmt.Fprintf(os.Stderr, "daemonize: warning: release child: %v\n", err) + if err := ready(); err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return 0, err + } + identity, err := identifyProcess(pid) + if err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return 0, err + } + for _, entry := range cmd.Env { + if strings.HasPrefix(entry, stopEventEnv+"=") { + identity.StopEvent = strings.TrimPrefix(entry, stopEventEnv+"=") + } } - if err := WritePIDFile(opts.PIDPath, pid); err != nil { - // Kill the child so we don't leave a daemon the user can't - // `stop` without ps-grepping. - _ = syscall.Kill(pid, syscall.SIGTERM) - return 0, fmt.Errorf("daemonize: write pidfile (child killed): %w", err) + if err := writeIdentity(opts.PIDPath, identity); err != nil { + // The parent still owns this exact process handle until publication succeeds. + _ = cmd.Process.Kill() + _ = cmd.Wait() + return 0, fmt.Errorf("daemonize: write process record: %w", err) + } + if err := cmd.Process.Release(); err != nil { + fmt.Fprintf(os.Stderr, "daemonize: release child handle: %v\n", err) } return pid, nil diff --git a/apps/parsar-daemon/internal/daemonize/fork_test.go b/apps/parsar-daemon/internal/daemonize/fork_test.go index 278c90a40..7ad546605 100644 --- a/apps/parsar-daemon/internal/daemonize/fork_test.go +++ b/apps/parsar-daemon/internal/daemonize/fork_test.go @@ -4,13 +4,12 @@ import ( "os" "path/filepath" "strings" - "syscall" "testing" "time" ) func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) logPath := filepath.Join(dir, "child.log") pidPath := filepath.Join(dir, "child.pid") @@ -29,7 +28,11 @@ func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { t.Fatalf("Spawn: %v", err) } defer func() { - _ = syscall.Kill(pid, syscall.SIGTERM) + p, _ := os.FindProcess(pid) + if p != nil { + _ = p.Kill() + _, _ = p.Wait() + } }() if pid <= 0 { @@ -63,9 +66,9 @@ func TestSpawnReExecsWithSentinelAndPIDFile(t *testing.T) { t.Errorf("log missing stderr marker; got %q", string(logBody)) } - // SignalAndWait should now drop the child cleanly. - if err := SignalAndWait(pid, 2*time.Second); err != nil { - t.Fatalf("SignalAndWait: %v", err) + // StopPIDFile should now drop the child cleanly. + if err := StopPIDFile(pidPath, 2*time.Second); err != nil { + t.Fatalf("StopPIDFile: %v", err) } } diff --git a/apps/parsar-daemon/internal/daemonize/fork_unix.go b/apps/parsar-daemon/internal/daemonize/fork_unix.go new file mode 100644 index 000000000..d729a25be --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/fork_unix.go @@ -0,0 +1,13 @@ +//go:build unix + +package daemonize + +import ( + "os/exec" + "syscall" +) + +func configureBackground(cmd *exec.Cmd) (func(), func() error, error) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + return func() {}, func() error { return nil }, nil +} diff --git a/apps/parsar-daemon/internal/daemonize/fork_windows.go b/apps/parsar-daemon/internal/daemonize/fork_windows.go new file mode 100644 index 000000000..6217b80bf --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/fork_windows.go @@ -0,0 +1,64 @@ +//go:build windows + +package daemonize + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "os/exec" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +func configureBackground(cmd *exec.Cmd) (func(), func() error, error) { + var nonce [24]byte + if _, err := rand.Read(nonce[:]); err != nil { + return nil, nil, err + } + name := `Local\OpenAgentCore-daemon-` + hex.EncodeToString(nonce[:]) + user, err := windows.GetCurrentProcessToken().GetTokenUser() + if err != nil { + return nil, nil, err + } + sd, err := windows.SecurityDescriptorFromString("D:P(A;;GA;;;" + user.User.Sid.String() + ")") + if err != nil { + return nil, nil, err + } + attr := windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd} + stopName, _ := windows.UTF16PtrFromString(name) + event, err := windows.CreateEvent(&attr, 1, 0, stopName) + if err != nil { + return nil, nil, err + } + readyName, _ := windows.UTF16PtrFromString(name + "-ready") + ready, err := windows.CreateEvent(&attr, 1, 0, readyName) + if err != nil { + windows.CloseHandle(event) + return nil, nil, err + } + clean := func() { windows.CloseHandle(event); windows.CloseHandle(ready) } + filtered := cmd.Env[:0] + for _, entry := range cmd.Env { + if !strings.HasPrefix(entry, stopEventEnv+"=") { + filtered = append(filtered, entry) + } + } + cmd.Env = append(filtered, stopEventEnv+"="+name) + // A short-lived installer must not own the detached daemon through its Job. + // If an enclosing Job forbids breakaway, Start fails instead of losing ownership. + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_BREAKAWAY_FROM_JOB} + return clean, func() error { + status, err := windows.WaitForSingleObject(ready, 60000) + if err != nil { + return err + } + if status != windows.WAIT_OBJECT_0 { + return errors.New("daemonize: background child did not establish its stop endpoint") + } + return nil + }, nil +} diff --git a/apps/parsar-daemon/internal/daemonize/helpers_test.go b/apps/parsar-daemon/internal/daemonize/helpers_test.go index 68bf9ea7c..6f32354cc 100644 --- a/apps/parsar-daemon/internal/daemonize/helpers_test.go +++ b/apps/parsar-daemon/internal/daemonize/helpers_test.go @@ -1,109 +1,47 @@ package daemonize import ( + "context" "fmt" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "os" - "os/exec" - "os/signal" - "strconv" - "syscall" + "path/filepath" "testing" "time" ) -// spawnTestChildEnv flips the test binary into "child" mode for fork -// tests: TestMain writes a marker, blocks until SIGTERM/SIGINT, exits. -// Lets Spawn re-exec the test binary as the child. const spawnTestChildEnv = "OAC_TEST_DAEMON_SPAWN_CHILD" func TestMain(m *testing.M) { - if os.Getenv(spawnTestChildEnv) == "1" { - runSpawnTestChild() + if mode := os.Getenv(spawnTestChildEnv); mode != "" { + _, firstStop := NotifyContext(context.Background()) + firstStop() + ctx, stop := NotifyContext(context.Background()) + defer stop() + fmt.Fprintln(os.Stdout, "spawn-test-child:stdout") + fmt.Fprintln(os.Stderr, "spawn-test-child:stderr") + if mode == "ignore" { + time.Sleep(15 * time.Second) + return + } + select { + case <-ctx.Done(): + case <-time.After(15 * time.Second): + } return } os.Exit(m.Run()) } -func runSpawnTestChild() { - fmt.Fprintln(os.Stdout, "spawn-test-child:stdout") - fmt.Fprintln(os.Stderr, "spawn-test-child:stderr") - - // Honour SIGTERM so the parent test can clean us up. Fallback - // deadline so a stranded child doesn't survive a crashing test. - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) - select { - case <-sigCh: - case <-time.After(15 * time.Second): - } - os.Exit(0) -} - -// startSleepingChild launches a child that sleeps for dur and exits. -// dur=0 → `sh -c true` for testing "signal a process that's already -// gone" paths. sh because we need a real kernel PID with /proc entry. -func startSleepingChild(t *testing.T, dur time.Duration) *exec.Cmd { - t.Helper() - var cmd *exec.Cmd - if dur <= 0 { - cmd = exec.Command("sh", "-c", "true") - } else { - // Convert to whole seconds when possible, sub-second via sleep - // arg with decimal (Linux/macOS sh both accept "sleep 0.05"). - secs := dur.Seconds() - cmd = exec.Command("sh", "-c", "sleep "+strconv.FormatFloat(secs, 'f', 3, 64)) - } - if err := cmd.Start(); err != nil { - t.Fatalf("startSleepingChild: %v", err) - } - return cmd -} - -// startTrapChild launches a child that ignores SIGTERM, forcing the -// SIGKILL escalation path. Waits for READY on stdout so a fast caller -// doesn't race the trap install. -func startTrapChild(t *testing.T) *exec.Cmd { +func privateTempDir(t *testing.T) string { t.Helper() - cmd := exec.Command("sh", "-c", "trap '' TERM; echo READY; sleep 30") - stdout, err := cmd.StdoutPipe() + parent, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { - t.Fatalf("startTrapChild StdoutPipe: %v", err) + t.Fatal(err) } - if err := cmd.Start(); err != nil { - t.Fatalf("startTrapChild Start: %v", err) - } - // Read up to READY with a hard deadline so a broken shell can't - // hang the test. - readyCh := make(chan error, 1) - go func() { - buf := make([]byte, 16) - var got []byte - for { - n, err := stdout.Read(buf) - if n > 0 { - got = append(got, buf[:n]...) - if len(got) >= 5 && string(got[:5]) == "READY" { - readyCh <- nil - return - } - } - if err != nil { - readyCh <- err - return - } - } - }() - select { - case err := <-readyCh: - if err != nil { - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() - t.Fatalf("startTrapChild waiting READY: %v", err) - } - case <-time.After(2 * time.Second): - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() - t.Fatalf("startTrapChild: timed out waiting for READY") + dir := filepath.Join(parent, "private") + if err := runtimefs.EnsurePrivateDir(dir); err != nil { + t.Fatal(err) } - return cmd + return dir } diff --git a/apps/parsar-daemon/internal/daemonize/logfile.go b/apps/parsar-daemon/internal/daemonize/logfile.go index 65e25c061..c83de6f6a 100644 --- a/apps/parsar-daemon/internal/daemonize/logfile.go +++ b/apps/parsar-daemon/internal/daemonize/logfile.go @@ -4,6 +4,7 @@ import ( "bufio" "errors" "fmt" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "io" "os" "path/filepath" @@ -137,11 +138,11 @@ func lastLinesOffset(f *os.File, n int) (int64, error) { // instead of "no such file". func EnsureLogFile(path string) error { if dir := filepath.Dir(path); dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0o700); err != nil { + if err := runtimefs.EnsurePrivateDir(dir); err != nil { return fmt.Errorf("daemonize.EnsureLogFile: %w", err) } } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + f, err := openPrivateLog(path) if err != nil { return fmt.Errorf("daemonize.EnsureLogFile: %w", err) } @@ -152,7 +153,7 @@ func EnsureLogFile(path string) error { // trailing newline if missing. Returns errors despite the name — // kept short because it's used in startup hot paths. func MustWriteLine(path string, line string) error { - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + f, err := openPrivateLog(path) if err != nil { return err } @@ -168,3 +169,12 @@ func MustWriteLine(path string, line string) error { } return bw.Flush() } + +func openPrivateLog(path string) (*os.File, error) { + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return nil, err + } + defer root.Close() + return runtimefs.OpenPrivate(root, filepath.Base(path), os.O_APPEND|os.O_CREATE|os.O_WRONLY) +} diff --git a/apps/parsar-daemon/internal/daemonize/logfile_test.go b/apps/parsar-daemon/internal/daemonize/logfile_test.go index 3b5f5ce0b..a13bdca48 100644 --- a/apps/parsar-daemon/internal/daemonize/logfile_test.go +++ b/apps/parsar-daemon/internal/daemonize/logfile_test.go @@ -4,6 +4,7 @@ import ( "bytes" "os" "path/filepath" + "runtime" "strings" "sync" "testing" @@ -11,7 +12,7 @@ import ( ) func TestTailReturnsLastNLinesAndExitsWithoutFollow(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "tail.log") var b strings.Builder for i := 1; i <= 10; i++ { @@ -35,7 +36,7 @@ func TestTailReturnsLastNLinesAndExitsWithoutFollow(t *testing.T) { } func TestTailLastLinesZeroPrintsNothingHistorical(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "tail.log") if err := os.WriteFile(path, []byte("a\nb\nc\n"), 0o600); err != nil { t.Fatalf("seed: %v", err) @@ -51,7 +52,7 @@ func TestTailLastLinesZeroPrintsNothingHistorical(t *testing.T) { } func TestTailLastLinesGreaterThanFilePrintsAll(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "tail.log") if err := os.WriteFile(path, []byte("a\nb\nc\n"), 0o600); err != nil { t.Fatalf("seed: %v", err) @@ -66,7 +67,7 @@ func TestTailLastLinesGreaterThanFilePrintsAll(t *testing.T) { } func TestTailFollowPicksUpAppendedBytes(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "tail.log") if err := os.WriteFile(path, []byte("initial\n"), 0o600); err != nil { t.Fatalf("seed: %v", err) @@ -128,14 +129,14 @@ func TestTailFollowPicksUpAppendedBytes(t *testing.T) { func TestTailErrorsOnMissingFile(t *testing.T) { var buf bytes.Buffer - err := Tail(filepath.Join(t.TempDir(), "nope.log"), TailOptions{LastLines: 1}, nil, &buf) + err := Tail(filepath.Join(privateTempDir(t), "nope.log"), TailOptions{LastLines: 1}, nil, &buf) if err == nil { t.Fatalf("Tail on missing file succeeded") } } func TestEnsureLogFileCreatesEmpty0600(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "fresh.log") if err := EnsureLogFile(path); err != nil { t.Fatalf("EnsureLogFile: %v", err) @@ -147,13 +148,13 @@ func TestEnsureLogFileCreatesEmpty0600(t *testing.T) { if info.Size() != 0 { t.Errorf("size = %d, want 0", info.Size()) } - if info.Mode().Perm() != 0o600 { + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { t.Errorf("mode = %o, want 0600", info.Mode().Perm()) } } func TestEnsureLogFileIdempotentOnExisting(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "exists.log") if err := os.WriteFile(path, []byte("preexisting\n"), 0o600); err != nil { t.Fatalf("seed: %v", err) @@ -171,7 +172,7 @@ func TestEnsureLogFileCreatesMissingParentDir(t *testing.T) { // Regression: first-ever `oac-daemon connect -b` on a host without // ~/.oac/daemon// used to fail with ENOENT — // O_CREATE only creates the file leaf. - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "missing", "deeper", "fresh.log") if err := EnsureLogFile(path); err != nil { t.Fatalf("EnsureLogFile on missing parent: %v", err) @@ -182,7 +183,7 @@ func TestEnsureLogFileCreatesMissingParentDir(t *testing.T) { } func TestMustWriteLineAppendsTrailingNewline(t *testing.T) { - dir := t.TempDir() + dir := privateTempDir(t) path := filepath.Join(dir, "ml.log") if err := MustWriteLine(path, "no-newline"); err != nil { t.Fatalf("MustWriteLine: %v", err) diff --git a/apps/parsar-daemon/internal/daemonize/notify_unix.go b/apps/parsar-daemon/internal/daemonize/notify_unix.go new file mode 100644 index 000000000..0e63ee4f6 --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/notify_unix.go @@ -0,0 +1,16 @@ +//go:build unix + +package daemonize + +import ( + "context" + "os" + "os/signal" + "syscall" +) + +const stopEventEnv = "OAC_RUNTIME_DAEMON_STOP_EVENT" + +func NotifyContext(parent context.Context) (context.Context, context.CancelFunc) { + return signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM) +} diff --git a/apps/parsar-daemon/internal/daemonize/notify_windows.go b/apps/parsar-daemon/internal/daemonize/notify_windows.go new file mode 100644 index 000000000..cff5d173e --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/notify_windows.go @@ -0,0 +1,70 @@ +//go:build windows + +package daemonize + +import ( + "context" + "os" + "os/signal" + "sync" + + "golang.org/x/sys/windows" +) + +const stopEventEnv = "OAC_RUNTIME_DAEMON_STOP_EVENT" + +// This process-lifetime handle survives transitions from serving to parked. +// Windows releases it on process exit, including abrupt termination. +var backgroundStop struct { + once sync.Once + handle windows.Handle + err error +} + +func openBackgroundStop(name string) (windows.Handle, error) { + backgroundStop.once.Do(func() { + var stopName *uint16 + stopName, backgroundStop.err = windows.UTF16PtrFromString(name) + if backgroundStop.err != nil { + return + } + backgroundStop.handle, backgroundStop.err = windows.OpenEvent(windows.SYNCHRONIZE, false, stopName) + if backgroundStop.err != nil { + return + } + readyName, _ := windows.UTF16PtrFromString(name + "-ready") + ready, err := windows.OpenEvent(windows.EVENT_MODIFY_STATE, false, readyName) + if err != nil { + backgroundStop.err = err + return + } + backgroundStop.err = windows.SetEvent(ready) + windows.CloseHandle(ready) + }) + return backgroundStop.handle, backgroundStop.err +} + +// NotifyContext serves both foreground Ctrl-C and an owned background stop event. +// The ready event is signalled only after the child holds its own stop handle. +func NotifyContext(parent context.Context) (context.Context, context.CancelFunc) { + ctx, cancel := signal.NotifyContext(parent, os.Interrupt) + name := os.Getenv(stopEventEnv) + if name == "" { + return ctx, cancel + } + event, err := openBackgroundStop(name) + if err != nil { + cancel() + return ctx, cancel + } + go func() { + for ctx.Err() == nil { + status, err := windows.WaitForSingleObject(event, 100) + if err != nil || status == windows.WAIT_OBJECT_0 { + cancel() + return + } + } + }() + return ctx, cancel +} diff --git a/apps/parsar-daemon/internal/daemonize/pidfile.go b/apps/parsar-daemon/internal/daemonize/pidfile.go index b9d9665b5..0a5f548e3 100644 --- a/apps/parsar-daemon/internal/daemonize/pidfile.go +++ b/apps/parsar-daemon/internal/daemonize/pidfile.go @@ -1,123 +1,102 @@ package daemonize import ( + "encoding/json" "errors" "fmt" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "os" - "strconv" - "strings" - "syscall" + "path/filepath" "time" ) -// ErrStaleOrCorrupt is returned by ReadPIDFile when the file exists -// but its contents don't look like a live PID. Wraps the underlying -// parse error or syscall.ESRCH for errors.Is. -var ErrStaleOrCorrupt = errors.New("daemonize: pidfile stale or corrupt") - -// ErrNotRunning is returned by IsAlive when the PID has no process. +var ErrStaleOrCorrupt = errors.New("daemonize: unsupported, stale or corrupt process record") var ErrNotRunning = errors.New("daemonize: process not running") -// WritePIDFile writes pid to path atomically. The temp file lives in -// the same directory so the rename is on the same filesystem -// (required by os.Rename for atomicity). 0o600 matches the profile -// dir's privacy. +type processIdentity struct { + PID int `json:"pid"` + Start string `json:"start"` + StopEvent string `json:"stop_event,omitempty"` +} + func WritePIDFile(path string, pid int) error { - if path == "" { - return errors.New("daemonize.WritePIDFile: empty path") + identity, err := identifyProcess(pid) + if err != nil { + return err } - if pid <= 0 { - return fmt.Errorf("daemonize.WritePIDFile: invalid pid %d", pid) + identity.StopEvent = os.Getenv(stopEventEnv) + return writeIdentity(path, identity) +} + +func writeIdentity(path string, identity processIdentity) error { + if path == "" { + return errors.New("daemonize: process record path required") } - tmp := path + ".tmp" - body := []byte(strconv.Itoa(pid) + "\n") - if err := os.WriteFile(tmp, body, 0o600); err != nil { - return fmt.Errorf("daemonize: write tmp pidfile: %w", err) + raw, err := json.Marshal(identity) + if err != nil { + return err } - if err := os.Rename(tmp, path); err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("daemonize: rename pidfile: %w", err) + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return err } - return nil + defer root.Close() + return runtimefs.WritePrivateAtomic(root, filepath.Base(path), raw) } -// ReadPIDFile reads, parses, and liveness-checks the pidfile. -// Missing file returns os.ErrNotExist verbatim; a file whose pid has -// no live process returns ErrStaleOrCorrupt wrapping ErrNotRunning. -func ReadPIDFile(path string) (int, error) { - raw, err := os.ReadFile(path) +func readIdentity(path string) (processIdentity, error) { + raw, err := runtimefs.ReadPrivatePath(path, 4096) if err != nil { - return 0, err + return processIdentity{}, err + } + var value processIdentity + if json.Unmarshal(raw, &value) != nil || value.PID <= 0 || value.Start == "" { + return processIdentity{}, ErrStaleOrCorrupt } - pid, parseErr := strconv.Atoi(strings.TrimSpace(string(raw))) - if parseErr != nil || pid <= 0 { - return 0, fmt.Errorf("%w: %q", ErrStaleOrCorrupt, strings.TrimSpace(string(raw))) + actual, err := identifyProcess(value.PID) + if err != nil { + if errors.Is(err, ErrNotRunning) { + return processIdentity{}, fmt.Errorf("%w: %w", ErrStaleOrCorrupt, err) + } + return processIdentity{}, err } - if err := IsAlive(pid); err != nil { - return pid, fmt.Errorf("%w: pid=%d: %w", ErrStaleOrCorrupt, pid, err) + if actual.Start != value.Start { + return processIdentity{}, ErrStaleOrCorrupt } - return pid, nil + return value, nil +} + +func ReadPIDFile(path string) (int, error) { + identity, err := readIdentity(path) + return identity.PID, err } -// IsAlive returns nil if pid corresponds to a process this user can -// signal. Wraps ErrNotRunning when the process is gone; other errors -// (e.g. EPERM) returned verbatim. -func IsAlive(pid int) error { - if pid <= 0 { - return fmt.Errorf("daemonize.IsAlive: invalid pid %d", pid) +func IsAlive(pid int) error { _, err := identifyProcess(pid); return err } + +// StopPIDFile never removes ownership before the exact process has exited. +// A cleanup timeout leaves the record available for observation and a later stop. +func StopPIDFile(path string, timeout time.Duration) error { + identity, err := readIdentity(path) + if err != nil { + return err } - // signal 0 is the POSIX "does this process exist?" probe: - // nil → exists, signalable - // ESRCH → no such process - // EPERM → exists but owned by another user - if err := syscall.Kill(pid, syscall.Signal(0)); err != nil { - if errors.Is(err, syscall.ESRCH) { - return fmt.Errorf("%w (pid=%d)", ErrNotRunning, pid) - } + if err = stopProcess(identity, timeout); err != nil { return err } - return nil + raw, err := runtimefs.ReadPrivatePath(path, 4096) + if err != nil { + return err + } + var current processIdentity + if json.Unmarshal(raw, ¤t) != nil || current != identity { + return errors.New("daemonize: process ownership changed during stop") + } + return RemovePIDFile(path) } -// RemovePIDFile deletes the pidfile. Missing files aren't an error so -// `oac-daemon stop` is idempotent. func RemovePIDFile(path string) error { if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("daemonize: remove pidfile: %w", err) - } - return nil -} - -// SignalAndWait SIGTERMs pid, polls for exit on 100ms cadence up to -// timeout, then SIGKILLs if still alive. Caller is responsible for -// removing the pidfile after success. -func SignalAndWait(pid int, timeout time.Duration) error { - if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { - if errors.Is(err, syscall.ESRCH) { - return nil - } - return fmt.Errorf("daemonize: SIGTERM pid=%d: %w", pid, err) - } - - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if err := IsAlive(pid); err != nil { - if errors.Is(err, ErrNotRunning) { - return nil - } - return err - } - time.Sleep(100 * time.Millisecond) - } - - if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { - if errors.Is(err, syscall.ESRCH) { - return nil - } - return fmt.Errorf("daemonize: SIGKILL pid=%d: %w", pid, err) + return err } - // Grace window for the kernel to reap before any subsequent - // IsAlive call. - time.Sleep(50 * time.Millisecond) return nil } diff --git a/apps/parsar-daemon/internal/daemonize/pidfile_test.go b/apps/parsar-daemon/internal/daemonize/pidfile_test.go index 1a8906b58..f390a7837 100644 --- a/apps/parsar-daemon/internal/daemonize/pidfile_test.go +++ b/apps/parsar-daemon/internal/daemonize/pidfile_test.go @@ -1,168 +1,98 @@ package daemonize import ( + "encoding/json" "errors" "os" "path/filepath" - "syscall" "testing" "time" ) -func TestWriteAndReadPIDFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "connect.pid") - pid := os.Getpid() - - if err := WritePIDFile(path, pid); err != nil { - t.Fatalf("WritePIDFile: %v", err) - } - - got, err := ReadPIDFile(path) - if err != nil { - t.Fatalf("ReadPIDFile: %v", err) +func TestProcessRecordIdentity(t *testing.T) { + path := filepath.Join(privateTempDir(t), "connect.pid") + if err := WritePIDFile(path, os.Getpid()); err != nil { + t.Fatal(err) } - if got != pid { - t.Fatalf("ReadPIDFile = %d, want %d", got, pid) + pid, err := ReadPIDFile(path) + if err != nil || pid != os.Getpid() { + t.Fatal(pid, err) } - - info, err := os.Stat(path) + raw, err := os.ReadFile(path) if err != nil { - t.Fatalf("stat: %v", err) + t.Fatal(err) } - if info.Mode().Perm() != 0o600 { - t.Errorf("pidfile mode = %o, want 0600", info.Mode().Perm()) + var identity processIdentity + if json.Unmarshal(raw, &identity) != nil || identity.Start == "" { + t.Fatal("missing process start identity") } -} - -func TestReadPIDFileMissingReturnsErrNotExist(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "nope.pid") - _, err := ReadPIDFile(path) - if !errors.Is(err, os.ErrNotExist) { - t.Fatalf("err = %v, want os.ErrNotExist", err) + identity.Start += "different" + raw, _ = json.Marshal(identity) + if err = os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) } -} - -func TestReadPIDFileCorruptReturnsStaleOrCorrupt(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bad.pid") - if err := os.WriteFile(path, []byte("not a number\n"), 0o600); err != nil { - t.Fatalf("seed: %v", err) + if err = StopPIDFile(path, time.Second); !errors.Is(err, ErrStaleOrCorrupt) { + t.Fatal("stale identity was accepted", err) } - _, err := ReadPIDFile(path) - if !errors.Is(err, ErrStaleOrCorrupt) { - t.Fatalf("err = %v, want ErrStaleOrCorrupt", err) + if err = IsAlive(os.Getpid()); err != nil { + t.Fatal("unrelated process was affected", err) } -} - -func TestReadPIDFileStaleReturnsStaleOrCorrupt(t *testing.T) { - // Pick a PID almost certainly dead. WritePIDFile rejects 0 so we - // write manually. - dir := t.TempDir() - path := filepath.Join(dir, "stale.pid") - if err := os.WriteFile(path, []byte("99999999\n"), 0o600); err != nil { - t.Fatalf("seed: %v", err) - } - if _, err := ReadPIDFile(path); !errors.Is(err, ErrStaleOrCorrupt) { - // Skip if 99999999 happens to exist. - t.Logf("ReadPIDFile = %v (PID 99999999 may exist on this host); skipping", err) - t.SkipNow() + if _, err = os.Stat(path); err != nil { + t.Fatal("unconfirmed record removed", err) } } -func TestIsAliveTrueForOurselves(t *testing.T) { - if err := IsAlive(os.Getpid()); err != nil { - t.Fatalf("IsAlive(self) = %v, want nil", err) +func TestProcessRecordRejectsOldAndMalformed(t *testing.T) { + path := filepath.Join(privateTempDir(t), "connect.pid") + if _, err := ReadPIDFile(path); !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) } -} - -func TestIsAliveFalseForDeadPID(t *testing.T) { - err := IsAlive(99999999) - if err == nil { - t.Skip("PID 99999999 unexpectedly exists; skipping") - } - if !errors.Is(err, ErrNotRunning) { - t.Fatalf("err = %v, want ErrNotRunning", err) - } -} - -func TestRemovePIDFileIdempotent(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "x.pid") - if err := WritePIDFile(path, os.Getpid()); err != nil { - t.Fatalf("write: %v", err) + for _, raw := range []string{"123\n", "{}", "not-json"} { + if err := os.WriteFile(path, []byte(raw), 0600); err != nil { + t.Fatal(err) + } + if _, err := ReadPIDFile(path); !errors.Is(err, ErrStaleOrCorrupt) { + t.Fatal(raw, err) + } } if err := RemovePIDFile(path); err != nil { - t.Fatalf("first remove: %v", err) + t.Fatal(err) } - // Second remove on missing file should be a no-op. if err := RemovePIDFile(path); err != nil { - t.Fatalf("second remove: %v", err) + t.Fatal(err) } } -func TestSignalAndWaitKillsChild(t *testing.T) { - // Launch a sleeping subshell so we have a real PID to signal. - cmd := startSleepingChild(t, 30*time.Second) - pid := cmd.Process.Pid - defer func() { - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() - }() - - if err := SignalAndWait(pid, 2*time.Second); err != nil { - t.Fatalf("SignalAndWait: %v", err) - } - // In production, init reaps the child so IsAlive returns - // ErrNotRunning. In tests we're the parent so the child becomes - // a zombie — Wait() reaps it. - if _, err := cmd.Process.Wait(); err != nil { - t.Fatalf("Wait after SignalAndWait: %v", err) - } - if err := IsAlive(pid); err == nil { - t.Fatalf("child still alive after SignalAndWait+Wait") - } else if !errors.Is(err, ErrNotRunning) { - t.Fatalf("IsAlive err = %v, want ErrNotRunning", err) +func TestStopTimeoutRetainsProcessRecord(t *testing.T) { + dir := privateTempDir(t) + path := filepath.Join(dir, "connect.pid") + pid, err := Spawn([]string{"daemon", "child"}, ReExecOptions{LogPath: filepath.Join(dir, "log"), PIDPath: path, ExtraEnv: []string{spawnTestChildEnv + "=ignore"}}) + if err != nil { + t.Fatal(err) } -} - -func TestSignalAndWaitEscalatesToSIGKILL(t *testing.T) { - // Child traps SIGTERM and refuses to exit. 300ms timeout so the - // escalation path fires quickly. - cmd := startTrapChild(t) - pid := cmd.Process.Pid defer func() { - _ = cmd.Process.Kill() - _, _ = cmd.Process.Wait() + p, _ := os.FindProcess(pid) + if p != nil { + _ = p.Kill() + _, _ = p.Wait() + } }() - - start := time.Now() - if err := SignalAndWait(pid, 300*time.Millisecond); err != nil { - t.Fatalf("SignalAndWait: %v", err) - } - if d := time.Since(start); d < 250*time.Millisecond { - t.Errorf("escalation happened too early (took %s); SIGTERM grace period not honoured", d) - } - // Reap so the kernel reuses the PID. ProcessState confirms SIGKILL. - state, err := cmd.Process.Wait() - if err != nil { - t.Fatalf("Wait after escalation: %v", err) - } - ws, ok := state.Sys().(syscall.WaitStatus) - if !ok || !ws.Signaled() || ws.Signal() != syscall.SIGKILL { - t.Fatalf("child exit = %v, want killed by SIGKILL", state) - } -} - -func TestSignalAndWaitNoopOnAlreadyDead(t *testing.T) { - cmd := startSleepingChild(t, 0) - pid := cmd.Process.Pid - // Wait for child to actually be gone before signalling so we - // test the ESRCH branch rather than a race. - _, _ = cmd.Process.Wait() - - if err := SignalAndWait(pid, time.Second); err != nil { - t.Fatalf("SignalAndWait on dead pid: %v, want nil", err) + // Unix has no readiness event; wait until the child installs its signal handler. + deadline := time.Now().Add(3 * time.Second) + for { + raw, _ := os.ReadFile(filepath.Join(dir, "log")) + if len(raw) > 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("child not ready") + } + time.Sleep(10 * time.Millisecond) + } + if err = StopPIDFile(path, 100*time.Millisecond); err == nil { + t.Fatal("unconfirmed stop succeeded") + } + if got, err := ReadPIDFile(path); err != nil || got != pid { + t.Fatal("timeout lost ownership", got, err) } } diff --git a/apps/parsar-daemon/internal/daemonize/process_darwin.go b/apps/parsar-daemon/internal/daemonize/process_darwin.go new file mode 100644 index 000000000..5945827f4 --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/process_darwin.go @@ -0,0 +1,52 @@ +//go:build darwin + +package daemonize + +import ( + "errors" + "fmt" + "time" + + "golang.org/x/sys/unix" +) + +func identifyProcess(pid int) (processIdentity, error) { + info, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if errors.Is(err, unix.ESRCH) || (err == nil && (info.Proc.P_pid == 0 || info.Proc.P_stat == 5)) { + return processIdentity{}, ErrNotRunning + } + if err != nil { + if errors.Is(unix.Kill(pid, 0), unix.ESRCH) { + return processIdentity{}, ErrNotRunning + } + return processIdentity{}, err + } + return processIdentity{PID: pid, Start: fmt.Sprintf("%d:%d", info.Proc.P_starttime.Sec, info.Proc.P_starttime.Usec)}, nil +} + +func stopProcess(identity processIdentity, timeout time.Duration) error { + current, err := identifyProcess(identity.PID) + if errors.Is(err, ErrNotRunning) { + return nil + } + if err != nil || current.Start != identity.Start { + return ErrStaleOrCorrupt + } + // Darwin has no pidfd; identity is checked immediately before signalling. + // PID reuse between this check and kill remains an OS-level race. + if err = unix.Kill(identity.PID, unix.SIGTERM); err != nil && !errors.Is(err, unix.ESRCH) { + return err + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + current, err = identifyProcess(identity.PID) + if errors.Is(err, ErrNotRunning) || (err == nil && current.Start != identity.Start) { + return nil + } + if err != nil { + return err + } + time.Sleep(20 * time.Millisecond) + } + return errors.New("daemonize: cleanup unconfirmed; process record retained") +} diff --git a/apps/parsar-daemon/internal/daemonize/process_linux.go b/apps/parsar-daemon/internal/daemonize/process_linux.go new file mode 100644 index 000000000..685cd8e4c --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/process_linux.go @@ -0,0 +1,68 @@ +//go:build linux + +package daemonize + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +func identifyProcess(pid int) (processIdentity, error) { + raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if errors.Is(err, os.ErrNotExist) { + return processIdentity{}, ErrNotRunning + } + if err != nil { + return processIdentity{}, err + } + end := strings.LastIndexByte(string(raw), ')') + if end < 0 { + return processIdentity{}, errors.New("daemonize: invalid process identity") + } + fields := strings.Fields(string(raw[end+1:])) + if len(fields) < 20 || fields[0] == "Z" || fields[0] == "X" { + return processIdentity{}, ErrNotRunning + } + if _, err := strconv.ParseUint(fields[19], 10, 64); err != nil { + return processIdentity{}, err + } + return processIdentity{PID: pid, Start: fields[19]}, nil +} + +func stopProcess(identity processIdentity, timeout time.Duration) error { + handle, err := unix.PidfdOpen(identity.PID, 0) + if errors.Is(err, unix.ESRCH) { + return nil + } + if err != nil { + return err + } + defer unix.Close(handle) + current, err := identifyProcess(identity.PID) + if errors.Is(err, ErrNotRunning) { + return nil + } + if err != nil || current.Start != identity.Start { + return ErrStaleOrCorrupt + } + if err = unix.PidfdSendSignal(handle, unix.SIGTERM, nil, 0); err != nil && !errors.Is(err, unix.ESRCH) { + return err + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + n, err := unix.Poll([]unix.PollFd{{Fd: int32(handle), Events: unix.POLLIN}}, 20) + if err != nil && !errors.Is(err, unix.EINTR) { + return err + } + if n > 0 { + return nil + } + } + return errors.New("daemonize: cleanup unconfirmed; process record retained") +} diff --git a/apps/parsar-daemon/internal/daemonize/process_windows.go b/apps/parsar-daemon/internal/daemonize/process_windows.go new file mode 100644 index 000000000..5a2a052bf --- /dev/null +++ b/apps/parsar-daemon/internal/daemonize/process_windows.go @@ -0,0 +1,81 @@ +//go:build windows + +package daemonize + +import ( + "errors" + "fmt" + "time" + + "golang.org/x/sys/windows" +) + +func processStart(handle windows.Handle) (string, error) { + var creation, exit, kernel, user windows.Filetime + if err := windows.GetProcessTimes(handle, &creation, &exit, &kernel, &user); err != nil { + return "", err + } + status, err := windows.WaitForSingleObject(handle, 0) + if err != nil { + return "", err + } + if status == windows.WAIT_OBJECT_0 { + return "", ErrNotRunning + } + return fmt.Sprintf("%d:%d", creation.HighDateTime, creation.LowDateTime), nil +} +func identifyProcess(pid int) (processIdentity, error) { + if pid <= 0 { + return processIdentity{}, ErrNotRunning + } + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, uint32(pid)) + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return processIdentity{}, ErrNotRunning + } + if err != nil { + return processIdentity{}, err + } + defer windows.CloseHandle(handle) + start, err := processStart(handle) + return processIdentity{PID: pid, Start: start}, err +} +func stopProcess(identity processIdentity, timeout time.Duration) error { + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE, false, uint32(identity.PID)) + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return nil + } + if err != nil { + return err + } + defer windows.CloseHandle(handle) + start, err := processStart(handle) + if errors.Is(err, ErrNotRunning) { + return nil + } + if err != nil || start != identity.Start { + return ErrStaleOrCorrupt + } + if identity.StopEvent == "" { + return errors.New("daemonize: process has no owned stop endpoint") + } + name, err := windows.UTF16PtrFromString(identity.StopEvent) + if err != nil { + return err + } + event, err := windows.OpenEvent(windows.EVENT_MODIFY_STATE, false, name) + if err != nil { + return err + } + defer windows.CloseHandle(event) + if err = windows.SetEvent(event); err != nil { + return err + } + status, err := windows.WaitForSingleObject(handle, uint32(max(timeout.Milliseconds(), 1))) + if err != nil { + return err + } + if status != windows.WAIT_OBJECT_0 { + return errors.New("daemonize: cleanup unconfirmed; process record retained") + } + return nil +} diff --git a/apps/parsar-daemon/internal/dispatch/local_directory_test.go b/apps/parsar-daemon/internal/dispatch/local_directory_test.go index 56605ca58..3e6d359e8 100644 --- a/apps/parsar-daemon/internal/dispatch/local_directory_test.go +++ b/apps/parsar-daemon/internal/dispatch/local_directory_test.go @@ -17,10 +17,8 @@ import ( ) func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing.T) { - workspace, helper := t.TempDir(), filepath.Join(t.TempDir(), "directory") - if err := os.WriteFile(helper, []byte("#!/bin/sh\nprintf '%s' '{\"version\":1,\"directory\":{\"entries\":[],\"truncated\":false}}'\n"), 0o700); err != nil { - t.Fatal(err) - } + workspace, helper := t.TempDir(), "" + environment, session := uuid.NewString(), uuid.NewString() binding, err := localworkspace.New(environment, session, workspace, helper) if err != nil { @@ -52,6 +50,14 @@ func TestLocalDirectoryPreparationNeedsNoHarnessAndRejectsOtherOwners(t *testing if got := waitWorkspaceRead(t, sender, "list"); got.Outcome != "completed" || got.Directory == nil { t.Fatal("idle directory unavailable", got) } + if err := os.WriteFile(filepath.Join(workspace, "bytes"), []byte{0, 255, 17}, 0600); err != nil { + t.Fatal(err) + } + content := proto.WorkspaceReadPayload{EnvironmentID: environment, Handle: ready.Handle, Path: "bytes", MaxBytes: 2} + _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "content", content)) + if got := waitWorkspaceRead(t, sender, "content"); got.Outcome != "completed" || !got.Truncated || !got.CloseAcknowledged || len(got.Data) != 2 || got.Data[1] != 255 { + t.Fatal("file prefix unavailable", got) + } read.EnvironmentID = uuid.NewString() _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, "foreign", read)) if got := waitWorkspaceRead(t, sender, "foreign"); got.Outcome != "rejected" { @@ -92,17 +98,8 @@ func waitWorkspaceRead(t *testing.T, sender *recSender, id string) proto.Workspa } func TestLocalDirectoryKeepsNotDirectorySeparateFromFailures(t *testing.T) { - workspace, helper := t.TempDir(), filepath.Join(t.TempDir(), "directory") - script := `#!/bin/sh -case "$2" in - missing) printf '%s' '{"version":1,"error":"not_directory"}' ;; - invalid) printf '%s' '{"version":1,"error":"invalid_path"}' ;; - gone) printf '%s' '{"version":1,"error":"not_found"}' ;; - denied) printf '%s' '{"version":1,"error":"permission_denied"}' ;; - broken) printf '%s' '{"version":1,"error":"native_error"}' ;; -esac -` - if err := os.WriteFile(helper, []byte(script), 0o700); err != nil { + workspace, helper := t.TempDir(), "" + if err := os.WriteFile(filepath.Join(workspace, "file"), nil, 0600); err != nil { t.Fatal(err) } environment, session := uuid.NewString(), uuid.NewString() @@ -129,11 +126,9 @@ esac } ready := waitPreparationStatus(t, sender, "idle", "ready", "") for path, want := range map[string]proto.WorkspaceReadResultPayload{ - "missing": {Outcome: "rejected", ErrorCode: proto.WorkspaceReadNotDirectory}, - "invalid": {Outcome: "rejected", ErrorCode: "invalid_request"}, - "gone": {Outcome: "rejected", ErrorCode: "not_found"}, - "denied": {Outcome: "rejected", ErrorCode: "permission_denied"}, - "broken": {Outcome: "unknown", ErrorCode: "read_unconfirmed"}, + "missing": {Outcome: "rejected", ErrorCode: proto.WorkspaceReadNotDirectory}, + "file": {Outcome: "rejected", ErrorCode: proto.WorkspaceReadNotDirectory}, + "../invalid": {Outcome: "rejected", ErrorCode: "invalid_request"}, } { read := proto.WorkspaceReadPayload{EnvironmentID: environment, Handle: ready.Handle, Operation: "directory", Path: path, MaxEntries: 10} _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, path, read)) diff --git a/apps/parsar-daemon/internal/dispatch/native_file_results_test.go b/apps/parsar-daemon/internal/dispatch/native_file_results_test.go new file mode 100644 index 000000000..e0f05f9e6 --- /dev/null +++ b/apps/parsar-daemon/internal/dispatch/native_file_results_test.go @@ -0,0 +1,53 @@ +package dispatch + +import ( + "errors" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" + "io/fs" + "testing" +) + +func TestNativeDirectoryFailureMapping(t *testing.T) { + for _, test := range []struct { + err error + outcome, code string + }{ + {agent.ErrWorkspaceNotDirectory, "rejected", proto.WorkspaceReadNotDirectory}, + {agent.ErrWorkspaceReadInvalid, "rejected", "invalid_request"}, + {fs.ErrNotExist, "rejected", "not_found"}, + {fs.ErrPermission, "rejected", "permission_denied"}, + {agent.ErrWorkspaceReadUncertain, "unknown", "read_unconfirmed"}, + } { + got := workspaceReadResult(agent.WorkspaceReadResult{}, test.err, 0) + if got.Outcome != test.outcome || got.ErrorCode != test.code { + t.Fatal(test.err, got) + } + } +} +func TestLocalUploadUnknownRetainsOwner(t *testing.T) { + sender := exportSender{make(chan proto.Envelope, 8)} + r, err := New(Config{Registry: agent.NewRegistry(), Sender: sender}) + if err != nil { + t.Fatal(err) + } + got := workspaceWriteResult(agent.WorkspaceWriteResult{}, errors.New("unconfirmed mutation"), 3) + if got.Outcome != "unknown" { + t.Fatal(got) + } + r.workspaceWrite = &workspaceUpload{envelope: proto.Envelope{ID: uuid.NewString()}, finished: true, uncertain: true} + request := proto.WorkspaceWritePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString(), Path: "file", SizeBytes: 0, SHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"} + env, _ := proto.NewEnvelope(proto.TypeWorkspaceWrite, uuid.NewString(), request) + if err = r.Handle(t.Context(), env); err != nil { + t.Fatal(err) + } + reply := <-sender.replies + var result proto.WorkspaceWriteResultPayload + if err = reply.DecodePayload(&result); err != nil || result.Outcome != "rejected" || result.ErrorCode != "write_capacity" { + t.Fatal(result, err) + } + if err = r.Shutdown(t.Context()); err == nil { + t.Fatal("shutdown declared uncertain mutation settled") + } +} diff --git a/apps/parsar-daemon/internal/dispatch/workspace_export_test.go b/apps/parsar-daemon/internal/dispatch/workspace_export_test.go index f8202a2af..8f8edbd69 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_export_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_export_test.go @@ -1,7 +1,10 @@ package dispatch import ( + "archive/tar" + "bytes" "context" + "io" "os" "path/filepath" "testing" @@ -26,23 +29,25 @@ func (s exportSender) Send(ctx context.Context, env proto.Envelope) error { func exporterRouter(t *testing.T, program string) (*Router, exportSender, proto.WorkspaceExportPayload) { t.Helper() - workspace, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { + workspace := t.TempDir() + if err := os.Mkdir(filepath.Join(workspace, "outputs"), 0700); err != nil { t.Fatal(err) } - dir, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { + if err := os.WriteFile(filepath.Join(workspace, "outputs", "a"), make([]byte, 131089), 0600); err != nil { t.Fatal(err) } - helper := filepath.Join(dir, "export") - if err := os.WriteFile(helper, []byte("#!/bin/sh\n"+program+"\n"), 0700); err != nil { - t.Fatal(err) + if program == "failure" { + f, err := os.Create(filepath.Join(workspace, "outputs", "z")) + if err != nil { + t.Fatal(err) + } + if err = f.Truncate(201 << 20); err != nil { + t.Fatal(err) + } + f.Close() } environment, session := uuid.NewString(), uuid.NewString() - for key, value := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": environment, "OAC_RUNTIME_SESSION_ID": session, "OAC_RUNTIME_WORKSPACE": workspace, "OAC_RUNTIME_DIRECTORY_HELPER": helper, "OAC_RUNTIME_EXPORT_HELPER": helper, "OAC_RUNTIME_WRITE_HELPER": "", "OAC_RUNTIME_STAGING": "", "OAC_RUNTIME_NETWORK_ACCESS": ""} { - t.Setenv(key, value) - } - binding, err := localworkspace.Load() + binding, err := localworkspace.New(environment, session, workspace, "") if err != nil { t.Fatal(err) } @@ -92,7 +97,7 @@ func readExport(t *testing.T, s exportSender) proto.WorkspaceExportResultPayload } func TestWorkspaceExportUsesExactReadPreparationAndPullsBoundedBytes(t *testing.T) { - r, s, request := exporterRouter(t, "head -c 131089 /dev/zero") + r, s, request := exporterRouter(t, "normal") for _, field := range []string{"environment", "handle"} { bad := request if field == "environment" { @@ -108,6 +113,7 @@ func TestWorkspaceExportUsesExactReadPreparationAndPullsBoundedBytes(t *testing. id := uuid.NewString() sendExport(t, r, id, request) var offset int64 + var archive bytes.Buffer for { p := readExport(t, s) if p.Offset != offset { @@ -119,11 +125,7 @@ func TestWorkspaceExportUsesExactReadPreparationAndPullsBoundedBytes(t *testing. if p.Outcome != "chunk" || len(p.Data) == 0 || len(p.Data) > proto.WorkspaceExportChunkBytes { t.Fatal("invalid chunk") } - for _, b := range p.Data { - if b != 0 { - t.Fatal("wrong byte") - } - } + archive.Write(p.Data) offset += int64(len(p.Data)) select { case <-s.replies: @@ -132,27 +134,43 @@ func TestWorkspaceExportUsesExactReadPreparationAndPullsBoundedBytes(t *testing. } sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "next", Offset: offset}) } - if offset != 131089 { - t.Fatal("truncated export", offset) + reader := tar.NewReader(&archive) + header, err := reader.Next() + if err != nil || header.Name != "outputs/a" || header.Size != 131089 { + t.Fatal("invalid archive", header, err) + } + data, err := io.ReadAll(reader) + if err != nil || !bytes.Equal(data, make([]byte, 131089)) { + t.Fatal("truncated artifact", err) + } + if _, err = reader.Next(); err != io.EOF { + t.Fatal("unexpected extra artifact", err) } } func TestWorkspaceExportFailureAfterBytesCannotComplete(t *testing.T) { - r, s, request := exporterRouter(t, "printf abc; exit 1") + r, s, request := exporterRouter(t, "failure") id := uuid.NewString() sendExport(t, r, id, request) - p := readExport(t, s) - if p.Outcome != "chunk" || string(p.Data) != "abc" { - t.Fatal("missing prefix", p) - } - sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "next", Offset: 3}) - if p := readExport(t, s); p.Outcome != "failed" { - t.Fatal("failed process appeared complete", p) + var offset int64 + for { + p := readExport(t, s) + if p.Outcome == "failed" { + if offset == 0 { + t.Fatal("no prefix before failure") + } + break + } + if p.Outcome != "chunk" || p.Offset != offset { + t.Fatal("failed export appeared complete", p) + } + offset += int64(len(p.Data)) + sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "next", Offset: offset}) } } -func TestWorkspaceExportCancelUnblocksProcessAndReleasesCapacity(t *testing.T) { - r, _, request := exporterRouter(t, "exec sleep 30") +func TestWorkspaceExportCancelUnblocksWriterAndReleasesCapacity(t *testing.T) { + r, _, request := exporterRouter(t, "normal") id := uuid.NewString() sendExport(t, r, id, request) sendExport(t, r, id, proto.WorkspaceExportPayload{Step: "cancel"}) diff --git a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go b/apps/parsar-daemon/internal/dispatch/workspace_write_test.go index ee32da350..c629d421f 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_write_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_write_test.go @@ -16,31 +16,11 @@ import ( "github.com/google/uuid" ) -func localWriterRouter(t *testing.T, response string) (*dispatch.Router, *recSender, proto.WorkspaceWritePayload, string) { +func localWriterRouter(t *testing.T) (*dispatch.Router, *recSender, proto.WorkspaceWritePayload, string) { t.Helper() - parent, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) - } - workspace, staging := filepath.Join(parent, "workspace"), filepath.Join(parent, "staging") - for _, p := range []string{workspace, staging} { - if err := os.Mkdir(p, 0700); err != nil { - t.Fatal(err) - } - } - helperRoot, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) - } - helper := filepath.Join(helperRoot, "helper") - if err := os.WriteFile(helper, []byte("#!/bin/sh\ncat >/dev/null\ntouch \"$1/invoked\"\nprintf '%s' '"+response+"'\n"), 0700); err != nil { - t.Fatal(err) - } + workspace := t.TempDir() environment, session := uuid.NewString(), uuid.NewString() - for k, v := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": environment, "OAC_RUNTIME_SESSION_ID": session, "OAC_RUNTIME_WORKSPACE": workspace, "OAC_RUNTIME_DIRECTORY_HELPER": helper, "OAC_RUNTIME_WRITE_HELPER": helper, "OAC_RUNTIME_STAGING": staging} { - t.Setenv(k, v) - } - binding, err := localworkspace.Load() + binding, err := localworkspace.New(environment, session, workspace, "") if err != nil { t.Fatal(err) } @@ -81,7 +61,7 @@ func waitWorkspaceWrite(t *testing.T, sender *recSender, id, outcome string) pro } func TestLocalUploadRequiresExactScopeAndCompleteBody(t *testing.T) { - r, sender, request, workspace := localWriterRouter(t, `{"version":1,"outcome":"completed","size_bytes":3}`) + r, sender, request, workspace := localWriterRouter(t) for _, field := range []string{"environment", "session"} { bad := request if field == "environment" { @@ -108,8 +88,8 @@ func TestLocalUploadRequiresExactScopeAndCompleteBody(t *testing.T) { t.Fatal(err) } } - if _, err := os.Stat(filepath.Join(workspace, "invoked")); !os.IsNotExist(err) { - t.Fatal("helper started before commit") + if _, err := os.Stat(filepath.Join(workspace, "file")); !os.IsNotExist(err) { + t.Fatal("file created before commit") } if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, proto.WorkspaceWritePayload{Step: "commit"})); err != nil { t.Fatal(err) @@ -117,15 +97,15 @@ func TestLocalUploadRequiresExactScopeAndCompleteBody(t *testing.T) { if got := waitWorkspaceWrite(t, sender, id, "completed"); got.SizeBytes != 3 { t.Fatal(got) } - if _, err := os.Stat(filepath.Join(workspace, "invoked")); err != nil { - t.Fatal("helper not called", err) + if data, err := os.ReadFile(filepath.Join(workspace, "file")); err != nil || string(data) != "abc" { + t.Fatal("committed bytes differ", err) } } func TestLocalUploadRejectsReorderedOrCorruptBodiesWithoutMutation(t *testing.T) { for _, mode := range []string{"offset", "digest", "short"} { t.Run(mode, func(t *testing.T) { - r, sender, request, workspace := localWriterRouter(t, `{"version":1,"outcome":"completed","size_bytes":3}`) + r, sender, request, workspace := localWriterRouter(t) id := uuid.NewString() _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, request)) chunk := proto.WorkspaceWritePayload{Step: "chunk", Data: []byte("abc")} @@ -140,32 +120,13 @@ func TestLocalUploadRejectsReorderedOrCorruptBodiesWithoutMutation(t *testing.T) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, chunk)) _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, proto.WorkspaceWritePayload{Step: "commit"})) waitWorkspaceWrite(t, sender, id, "rejected") - if _, err := os.Stat(filepath.Join(workspace, "invoked")); !os.IsNotExist(err) { - t.Fatal("bad transfer invoked helper") + if _, err := os.Stat(filepath.Join(workspace, "file")); !os.IsNotExist(err) { + t.Fatal("bad transfer mutated workspace") } }) } } -func TestLocalUploadUnknownRetainsOwner(t *testing.T) { - r, sender, request, _ := localWriterRouter(t, `{"version":1,"outcome":"unknown","error":"write_failed"}`) - id := uuid.NewString() - for _, p := range []proto.WorkspaceWritePayload{request, {Step: "chunk", Data: []byte("abc")}, {Step: "commit"}} { - if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, p)); err != nil { - t.Fatal(err) - } - } - waitWorkspaceWrite(t, sender, id, "unknown") - next := uuid.NewString() - _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, next, request)) - if got := waitWorkspaceWrite(t, sender, next, "rejected"); got.ErrorCode != "write_capacity" { - t.Fatal(got) - } - if err := r.Shutdown(t.Context()); err == nil { - t.Fatal("shutdown declared uncertain mutation settled") - } -} - func TestLocalUploadReportsDestinationConflictsAndReleasesOwner(t *testing.T) { for helperError, reason := range map[string]string{ "destination_directory": proto.WorkspaceWriteReasonDirectory, @@ -173,7 +134,22 @@ func TestLocalUploadReportsDestinationConflictsAndReleasesOwner(t *testing.T) { "write_failed": "", } { t.Run(helperError, func(t *testing.T) { - r, sender, request, _ := localWriterRouter(t, `{"version":1,"outcome":"failed","error":"`+helperError+`"}`) + r, sender, request, workspace := localWriterRouter(t) + switch helperError { + case "destination_directory": + if err := os.Mkdir(filepath.Join(workspace, "file"), 0700); err != nil { + t.Fatal(err) + } + case "unsafe_destination": + if err := os.WriteFile(filepath.Join(workspace, "file"), []byte("existing"), 0600); err != nil { + t.Fatal(err) + } + case "write_failed": + if err := os.WriteFile(filepath.Join(workspace, "parent"), nil, 0600); err != nil { + t.Fatal(err) + } + request.Path = "parent/file" + } id := uuid.NewString() for _, p := range []proto.WorkspaceWritePayload{request, {Step: "chunk", Data: []byte("abc")}, {Step: "commit"}} { if err := r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceWrite, id, p)); err != nil { diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/parsar-daemon/internal/localworkspace/binding.go index c533216d4..bd1a90aa2 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding.go +++ b/apps/parsar-daemon/internal/localworkspace/binding.go @@ -12,11 +12,11 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" - "github.com/google/uuid" ) // Binding freezes operator-owned identity and paths for one Runtime lifetime. type Binding struct { + executionMode string environment string networkAccess string allowedDomains []string @@ -30,48 +30,27 @@ type Binding struct { } func New(environment, session, workspace, helper string) (*Binding, error) { - for _, id := range []string{environment, session} { - parsed, err := uuid.Parse(id) - if err != nil || parsed == uuid.Nil || parsed.String() != id { - return nil, errors.New("local workspace requires canonical resource identities") - } - } - for _, name := range []string{workspace, helper} { - if !filepath.IsAbs(name) || filepath.Clean(name) != name || name == "/" || strings.ContainsAny(name, "\x00\r\n\\") { - return nil, errors.New("local workspace requires clean absolute deployment paths") - } - } - root, err := os.Lstat(workspace) - if err != nil || !root.IsDir() || root.Mode()&os.ModeSymlink != 0 { - return nil, errors.New("local workspace root must be an existing directory") + root, err := paths.Root() + if err != nil { + return nil, err } - program, err := os.Stat(helper) - if err != nil || !program.Mode().IsRegular() || program.Mode().Perm()&0111 == 0 || strings.HasPrefix(helper, workspace+string(filepath.Separator)) { - return nil, errors.New("local workspace helper must be executable outside the workspace") + b, err := newNativeBinding(environment, session, workspace, filepath.Join(root, "capabilities")) + if err != nil { + return nil, err } - return &Binding{environment: environment, stateKey: "agents-api-" + session, workspace: workspace, helper: helper, capabilityRoot: CapabilityDirectory}, nil + b.helper = helper + b.executionMode = os.Getenv("OAC_RUNTIME_EXECUTION_MODE") + return b, nil } -// NewWithCapabilityDirectory freezes an operator-owned installation layout. -// Core and capability transfer messages cannot choose this destination. +// NewWithCapabilityDirectory freezes paths selected by the Runtime operator. func NewWithCapabilityDirectory(environment, session, workspace, helper, directory string) (*Binding, error) { - b, err := New(environment, session, workspace, helper) + b, err := newNativeBinding(environment, session, workspace, directory) if err != nil { return nil, err } - if agentcapabilities.ValidateLocalDirectories([]string{directory}) != nil || directory == "/" || containsPath(workspace, directory) || containsPath(directory, workspace) || containsPath(PackageDirectory, directory) || containsPath(directory, PackageDirectory) { - return nil, agentcapabilities.ErrInvalid - } - private, err := paths.Root() - if err != nil || !filepath.IsAbs(private) || containsPath(private, directory) || containsPath(directory, private) { - return nil, agentcapabilities.ErrInvalid - } - for _, name := range []string{workspace, private, directory} { - if !canonicalExistingParent(name) { - return nil, agentcapabilities.ErrInvalid - } - } - b.capabilityRoot = directory + b.helper = helper + b.executionMode = os.Getenv("OAC_RUNTIME_EXECUTION_MODE") return b, nil } @@ -97,22 +76,9 @@ func Load() (*Binding, error) { } b.networkAccess = network b.allowedDomains = policy.Hosts() - if exportHelper != "" { - // Reuse the startup executable/root checks; this grants no caller authority. - if _, err := New(values[0], values[1], values[2], exportHelper); err != nil { - return nil, err - } - resolved, err := filepath.EvalSymlinks(exportHelper) - if err != nil || resolved != exportHelper { - return nil, errors.New("workspace exporter must be a canonical executable") - } - b.exportHelper = exportHelper - } - if writeHelper != "" || staging != "" { - if err := b.bindWriter(writeHelper, staging); err != nil { - return nil, err - } - } + b.exportHelper = exportHelper + b.writer.helper, b.writer.staging = writeHelper, staging + return b, nil } @@ -134,6 +100,7 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa } if !r.WorkspaceReadOnly { local := *r.LocalEnvironment + local.ExecutionMode = b.ExecutionMode() if local.WorkspaceDirectory != "/workspace" && local.WorkspaceDirectory != b.workspace { return r, errors.New("request does not match the local workspace selection") } diff --git a/apps/parsar-daemon/internal/localworkspace/binding_test.go b/apps/parsar-daemon/internal/localworkspace/binding_test.go index 4e0da0b2d..e07b0dbea 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding_test.go +++ b/apps/parsar-daemon/internal/localworkspace/binding_test.go @@ -123,23 +123,14 @@ func TestBindingAllowsRetainedExecutor(t *testing.T) { } } -func TestCapabilityLayoutExcludesWritableAndPrivateRoots(t *testing.T) { +func TestCapabilityLayoutUsesOperatorDirectories(t *testing.T) { b, _ := testBinding(t) - private := os.Getenv("OAC_RUNTIME_HOME") - for _, directory := range []string{b.workspace, filepath.Join(b.workspace, "caps"), private, filepath.Join(private, "caps"), PackageDirectory, filepath.Join(PackageDirectory, "caps"), "/"} { - if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.helper, directory); err == nil { - t.Fatal("unsafe installation layout accepted") + for _, directory := range []string{filepath.Join(b.workspace, "capabilities"), filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "capabilities")} { + if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, "", directory); err != nil { + t.Fatal(err) } } - root := t.TempDir() - alias := filepath.Join(root, "alias") - if err := os.Symlink(t.TempDir(), alias); err != nil { - t.Fatal(err) - } - if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.helper, filepath.Join(alias, "new")); err == nil { - t.Fatal("installation parent alias accepted") - } - if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, b.helper, t.TempDir()); err != nil { - t.Fatal(err) + if _, err := NewWithCapabilityDirectory(b.environment, b.capabilityIdentity().SessionID, b.workspace, "", "relative"); err == nil { + t.Fatal("relative installation path accepted") } } diff --git a/apps/parsar-daemon/internal/localworkspace/capabilities.go b/apps/parsar-daemon/internal/localworkspace/capabilities.go index e762b177f..c2b79390c 100644 --- a/apps/parsar-daemon/internal/localworkspace/capabilities.go +++ b/apps/parsar-daemon/internal/localworkspace/capabilities.go @@ -6,11 +6,10 @@ import ( "os" "path/filepath" "strings" - "syscall" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) // Prepare runs under the admitted executor's lifetime, before native startup. @@ -46,11 +45,12 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr } local := *r.LocalEnvironment local.Skills, local.MCP, local.CapabilityRoot = manifest.Skills, nil, b.capabilityRoot + local.ExecutionMode = b.ExecutionMode() for i := range local.Skills { local.Skills[i].InstallationRoot = b.capabilityRoot } if local.ToolEnvironment { - if err = VerifyToolEnvironment(local.SystemPackages); err != nil { + if _, err = b.ReadToolEnvironment(); err != nil { return r, err } } @@ -58,7 +58,7 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr if b.NetworkPolicy().Access != "enabled" { return r, agentcapabilities.ErrInvalid } - values, readErr := ReadOptionalToolEnvironment() + values, readErr := b.ReadToolEnvironment() if readErr != nil { return r, readErr } @@ -66,6 +66,7 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr for i := range local.MCP { local.MCP[i].InstallationRoot = b.capabilityRoot local.MCP[i].WorkspaceRoot = b.workspace + local.MCP[i].ExecutionMode = b.ExecutionMode() } if err != nil { return r, err @@ -155,7 +156,7 @@ func (b *Binding) openCapabilities(ctx context.Context, completed bool) (*os.Roo if err := ctx.Err(); err != nil { return nil, nil, err } - if b.capabilityRoot == "" || !filepath.IsAbs(b.capabilityRoot) || !canonicalExistingParent(b.capabilityRoot) { + if b.capabilityRoot == "" || runtimefs.ValidateLocalPath(b.capabilityRoot) != nil { return nil, nil, agentcapabilities.ErrInvalid } if !completed { @@ -163,25 +164,16 @@ func (b *Binding) openCapabilities(ctx context.Context, completed bool) (*os.Roo return nil, nil, agentcapabilities.ErrInvalid } } - actual, err := filepath.EvalSymlinks(b.capabilityRoot) - if err != nil || actual != b.capabilityRoot { - return nil, nil, agentcapabilities.ErrInvalid - } root, err := os.OpenRoot(b.capabilityRoot) if err != nil { return nil, nil, agentcapabilities.ErrInvalid } - dir, err := root.Open(".") + unlock, err := runtimefs.LockDirectory(root) if err != nil { root.Close() return nil, nil, agentcapabilities.ErrInvalid } - if err = syscall.Flock(int(dir.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { - dir.Close() - root.Close() - return nil, nil, agentcapabilities.ErrInvalid - } - return root, func() { _ = syscall.Flock(int(dir.Fd()), syscall.LOCK_UN); dir.Close(); root.Close() }, nil + return root, func() { unlock(); root.Close() }, nil } func containsPath(parent, child string) bool { @@ -196,27 +188,10 @@ func (b *Binding) resolveCapabilityDirectory(source string) (*os.Root, error) { if source == "/workspace" || strings.HasPrefix(source, "/workspace/") { source = filepath.Join(b.workspace, strings.TrimPrefix(source, "/workspace")) } - actual, err := filepath.EvalSymlinks(source) - if err != nil || actual != source { + // Refuse recursive installation into the selected source itself. + if containsPath(source, b.capabilityRoot) || containsPath(b.capabilityRoot, source) { return nil, agentcapabilities.ErrInvalid } - private, err := paths.Root() - if err != nil || !filepath.IsAbs(private) { - return nil, agentcapabilities.ErrInvalid - } - // Protect both descendants and ancestors: selecting a parent must not copy - // credentials, native history, installed capabilities or initialization env. - for _, protected := range []string{private, b.capabilityRoot, InitializationDirectory} { - canonical, e := filepath.EvalSymlinks(protected) - if e == nil { - protected = canonical - } else if !errors.Is(e, os.ErrNotExist) { - return nil, agentcapabilities.ErrInvalid - } - if containsPath(source, protected) || containsPath(protected, source) { - return nil, agentcapabilities.ErrInvalid - } - } root, err := os.OpenRoot(source) if err != nil { return nil, agentcapabilities.ErrInvalid diff --git a/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go b/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go index 656d5ddd5..35870e6a3 100644 --- a/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go +++ b/apps/parsar-daemon/internal/localworkspace/capability_preparation_test.go @@ -83,27 +83,22 @@ func TestPreparationFreezesLocalContentsAcrossReconnect(t *testing.T) { } } -func TestPreparationRejectsPrivateSourcesAndLeavesFailuresInert(t *testing.T) { +func TestPreparationUsesOperatorSourcesAndLeavesFailuresInert(t *testing.T) { b, req := testBinding(t) private := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", private) writeSourceSkill(t, private, "private") - for _, source := range []string{private, filepath.Dir(private), b.capabilityRoot, InitializationDirectory} { - if root, err := b.resolveCapabilityDirectory(source); err == nil { - root.Close() - t.Fatal("protected source accepted") - } + held, err := b.resolveCapabilityDirectory(private) + if err != nil { + t.Fatal("operator source rejected", err) + } + held.Close() + if held, err = b.resolveCapabilityDirectory(b.capabilityRoot); err == nil { + held.Close() + t.Fatal("recursive snapshot source accepted") } source := filepath.Join(b.workspace, "selected") writeSourceSkill(t, source, "valid") - link := filepath.Join(b.workspace, "link") - if err := os.Symlink(source, link); err != nil { - t.Fatal(err) - } - if root, err := b.resolveCapabilityDirectory(link); err == nil { - root.Close() - t.Fatal("source alias accepted") - } root, err := b.resolveCapabilityDirectory("/workspace/selected") if err != nil { t.Fatal("logical workspace source rejected", err) diff --git a/apps/parsar-daemon/internal/localworkspace/directory.go b/apps/parsar-daemon/internal/localworkspace/directory.go index b747c3821..8ce8ac130 100644 --- a/apps/parsar-daemon/internal/localworkspace/directory.go +++ b/apps/parsar-daemon/internal/localworkspace/directory.go @@ -6,10 +6,7 @@ import ( "encoding/json" "io" "io/fs" - "os/exec" - "strconv" "strings" - "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -19,29 +16,7 @@ func (b *Binding) ListWorkspaceDirectory(ctx context.Context, path string, limit if limit < 1 || limit > proto.WorkspaceDirectoryMaxEntries || len(path) > 4096 || strings.ContainsAny(path, "\\\x00\r\n") || (path != "" && (path == "." || !fs.ValidPath(path))) { return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadInvalid } - operation, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - cmd := exec.CommandContext(operation, b.helper, b.workspace, path, strconv.Itoa(limit)) - cmd.Dir = "/" - cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"} - cmd.WaitDelay = time.Second - pipe, err := cmd.StdoutPipe() - if err != nil { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnavailable - } - if err := cmd.Start(); err != nil { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUnavailable - } - const maxOutput = 4 << 20 - data, err := io.ReadAll(io.LimitReader(pipe, maxOutput+1)) - if err != nil || len(data) > maxOutput { - _ = cmd.Process.Kill() - } - waitErr := cmd.Wait() - if err != nil || waitErr != nil || len(data) > maxOutput { - return agent.WorkspaceDirectoryResult{}, agent.ErrWorkspaceReadUncertain - } - return decodeDirectory(data, limit) + return b.listNativeDirectory(ctx, path, limit) } func decodeDirectory(data []byte, limit int) (agent.WorkspaceDirectoryResult, error) { diff --git a/apps/parsar-daemon/internal/localworkspace/directory_native_test.go b/apps/parsar-daemon/internal/localworkspace/directory_native_test.go index efc2f7bf5..142c69062 100644 --- a/apps/parsar-daemon/internal/localworkspace/directory_native_test.go +++ b/apps/parsar-daemon/internal/localworkspace/directory_native_test.go @@ -2,61 +2,55 @@ package localworkspace import ( "errors" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "os" "path/filepath" "testing" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/google/uuid" ) -func TestNativeLocalDirectoryConfinement(t *testing.T) { - helper := os.Getenv("OAC_TEST_LOCAL_DIRECTORY_HELPER") - if helper == "" { - t.Skip("actual pinned directory helper required") - } - root, outside := t.TempDir(), t.TempDir() - if err := os.Mkdir(filepath.Join(root, "nested"), 0o700); err != nil { +func TestNativeDirectoryAPI(t *testing.T) { + b := nativeFileBinding(t) + if err := os.Mkdir(filepath.Join(b.workspace, "nested"), 0700); err != nil { t.Fatal(err) } - for _, path := range []string{filepath.Join(root, "nested", "data.bin"), filepath.Join(outside, "secret")} { - if err := os.WriteFile(path, []byte{0, 1, 255, 17}, 0o600); err != nil { - t.Fatal(err) - } - } - if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil { - t.Fatal(err) - } - b, err := New(uuid.NewString(), uuid.NewString(), root, helper) - if err != nil { + if err := os.WriteFile(filepath.Join(b.workspace, "nested", "data.bin"), []byte{0, 1, 255, 17}, 0600); err != nil { t.Fatal(err) } got, err := b.ListWorkspaceDirectory(t.Context(), "nested", 10) if err != nil || got.Truncated || len(got.Entries) != 1 || got.Entries[0].Name != "data.bin" || got.Entries[0].SizeBytes == nil || *got.Entries[0].SizeBytes != 4 { - t.Fatalf("native file metadata: %+v %v", got, err) + t.Fatal(got, err) } - if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("x"), 0o600); err != nil { - t.Fatal(err) - } - // A link, a regular file and a missing path are not listable directories; - // the external link target is never listed. - for _, path := range []string{"escape", "escape/secret", "file.txt", "missing", "missing/deeper"} { + for _, path := range []string{"missing", "nested/data.bin"} { if _, err := b.ListWorkspaceDirectory(t.Context(), path, 10); !errors.Is(err, agent.ErrWorkspaceNotDirectory) { - t.Fatal("native helper classified a non-directory path differently", path, err) + t.Fatal(path, err) } } + if err := os.WriteFile(filepath.Join(b.workspace, "second"), nil, 0600); err != nil { + t.Fatal(err) + } got, err = b.ListWorkspaceDirectory(t.Context(), "", 1) if err != nil || !got.Truncated || len(got.Entries) != 1 { - t.Fatalf("native directory bound: %+v %v", got, err) + t.Fatal(got, err) } - if err := os.Rename(root, root+"-original"); err != nil { +} + +func TestNativeFileReadPrefixAndEntryKinds(t *testing.T) { + b := nativeFileBinding(t) + if _, err := b.WriteWorkspaceFile(t.Context(), "nested/file", []byte{0, 255, 17}); err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = os.RemoveAll(root + "-original") }) - if err := os.Symlink(outside, root); err != nil { + got, err := b.ReadWorkspaceFile(t.Context(), "nested/file", 2) + if err != nil || !got.Truncated || len(got.Data) != 2 || got.Data[1] != 255 { + t.Fatal(got, err) + } + got, err = b.ReadWorkspaceFile(t.Context(), "nested/file", 3) + if err != nil || got.Truncated || len(got.Data) != 3 { + t.Fatal(got, err) + } + if _, err = b.ReadWorkspaceFile(t.Context(), "nested", 3); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { t.Fatal(err) } - if _, err := b.ListWorkspaceDirectory(t.Context(), "", 10); !errors.Is(err, agent.ErrWorkspaceReadInvalid) { - t.Fatal("native helper followed a replaced root or hid it as an empty directory", err) + if _, err = b.ReadWorkspaceFile(t.Context(), "missing", 3); !os.IsNotExist(err) { + t.Fatal(err) } } diff --git a/apps/parsar-daemon/internal/localworkspace/export.go b/apps/parsar-daemon/internal/localworkspace/export.go index 104750dee..015e020ab 100644 --- a/apps/parsar-daemon/internal/localworkspace/export.go +++ b/apps/parsar-daemon/internal/localworkspace/export.go @@ -3,71 +3,16 @@ package localworkspace import ( "context" "errors" - "io" - "os" - "os/exec" - "time" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "io" ) -func (b *Binding) CanExport() bool { return b != nil && b.exportHelper != "" } - -// ExportOutputs streams only from the deployment-owned root; successful exit is mandatory. +func (b *Binding) CanExport() bool { return b != nil && b.workspace != "" } func (b *Binding) ExportOutputs(ctx context.Context, output io.Writer) error { if !b.CanExport() || output == nil { return errors.New("workspace export unavailable") } - cmd := exec.CommandContext(ctx, b.exportHelper, b.workspace) - cmd.Dir = "/" - cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"} - cmd.WaitDelay = time.Second - stdout, childOutput, err := os.Pipe() - if err != nil { - return err - } - defer stdout.Close() - defer childOutput.Close() - cmd.Stdout = childOutput - if err := cmd.Start(); err != nil { - return err - } - _ = childOutput.Close() - stop := context.AfterFunc(ctx, func() { _ = stdout.Close() }) - defer stop() - exited := make(chan struct{}) - settled := make(chan error, 1) - go func() { - err := cmd.Wait() - // Bound a pending read when a descendant inherits stdout after helper exit. - _ = stdout.SetReadDeadline(time.Now().Add(time.Second)) - close(exited) - settled <- err - }() - // Own the pipe so exec.Wait cannot close buffered output during transport - // backpressure. Only actual pipe reads have a post-exit cleanup deadline. - _, copyErr := io.Copy(&exportWriter{output: output}, exportReader{stdout, exited}) - if copyErr != nil { - _ = cmd.Process.Kill() - } - return errors.Join(copyErr, <-settled) -} - -type exportReader struct { - pipe *os.File - exited <-chan struct{} -} - -func (r exportReader) Read(data []byte) (int, error) { - select { - case <-r.exited: - // Consumer delays do not consume the inherited-pipe cleanup allowance. - if err := r.pipe.SetReadDeadline(time.Now().Add(time.Second)); err != nil { - return 0, err - } - default: - } - return r.pipe.Read(data) + return b.exportNativeOutputs(ctx, &exportWriter{output: output}) } type exportWriter struct { @@ -76,6 +21,9 @@ type exportWriter struct { } func (w *exportWriter) Write(data []byte) (int, error) { + if len(data) == 0 { + return 0, nil + } if int64(len(data)) > proto.WorkspaceExportMaxBytes-w.size { return 0, errors.New("workspace export exceeds bound") } diff --git a/apps/parsar-daemon/internal/localworkspace/export_test.go b/apps/parsar-daemon/internal/localworkspace/export_test.go index df17b348e..f396f9f2d 100644 --- a/apps/parsar-daemon/internal/localworkspace/export_test.go +++ b/apps/parsar-daemon/internal/localworkspace/export_test.go @@ -1,111 +1,100 @@ package localworkspace import ( + "archive/tar" "bytes" "context" "errors" "io" "os" "path/filepath" - "strconv" - "strings" "testing" "time" ) -func exportBinding(t *testing.T, program string) *Binding { +func outputBinding(t *testing.T) *Binding { t.Helper() - b, _ := testBinding(t) - b.exportHelper = b.helper - if err := os.WriteFile(b.helper, []byte("#!/bin/sh\n"+program+"\n"), 0700); err != nil { + b := nativeFileBinding(t) + if _, err := b.WriteWorkspaceFile(t.Context(), "outputs/nested/proof.bin", []byte{0, 255, 17}); err != nil { t.Fatal(err) } - return b -} - -type heldExportWriter struct { - bytes.Buffer - entered chan struct{} - release chan struct{} -} - -func (w *heldExportWriter) Write(p []byte) (int, error) { - if w.entered != nil { - close(w.entered) - w.entered = nil - <-w.release - } - return w.Buffer.Write(p) -} - -func TestExportOutputsAllowsConsumerBackpressureAfterHelperExit(t *testing.T) { - b := exportBinding(t, "head -c 3072 /dev/zero") - entered, release := make(chan struct{}), make(chan struct{}) - w := &heldExportWriter{entered: entered, release: release} - done := make(chan error, 1) - go func() { done <- b.ExportOutputs(t.Context(), w) }() - select { - case <-entered: - case <-time.After(3 * time.Second): - close(release) - t.Fatal("helper produced no output") - } - // The helper can exit while the pull transport is waiting for its consumer. - time.Sleep(1500 * time.Millisecond) - close(release) - if err := <-done; err != nil { + if _, err := b.WriteWorkspaceFile(t.Context(), "outputs/empty", nil); err != nil { t.Fatal(err) } - if !bytes.Equal(w.Bytes(), make([]byte, 3072)) { - t.Fatalf("incomplete output: %d bytes", w.Len()) - } + return b } - -func TestExportOutputsRequiresSuccessfulHelperExit(t *testing.T) { - b := exportBinding(t, "printf complete-prefix; exit 7") +func TestNativeOutputArchive(t *testing.T) { + b := outputBinding(t) var output bytes.Buffer - if err := b.ExportOutputs(t.Context(), &output); err == nil || output.String() != "complete-prefix" { - t.Fatalf("failed helper accepted: output=%q error=%v", output.String(), err) + if err := b.ExportOutputs(t.Context(), &output); err != nil { + t.Fatal(err) } -} - -func TestExportOutputsBoundsInheritedPipeAfterHelperExit(t *testing.T) { - pidFile := filepath.Join(t.TempDir(), "descendant.pid") - b := exportBinding(t, "sleep 30 &\necho $! > '"+pidFile+"'\nprintf prefix") - t.Cleanup(func() { - data, err := os.ReadFile(pidFile) + reader := tar.NewReader(&output) + got := map[string][]byte{} + for { + header, err := reader.Next() + if err == io.EOF { + break + } if err != nil { - return + t.Fatal(err) } - pid, err := strconv.Atoi(strings.TrimSpace(string(data))) - if err == nil { - if process, err := os.FindProcess(pid); err == nil { - _ = process.Kill() - } + if header.Typeflag != tar.TypeReg { + t.Fatal(header) } - }) - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) - defer cancel() + raw, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + got[header.Name] = raw + } + if len(got) != 2 || !bytes.Equal(got["outputs/nested/proof.bin"], []byte{0, 255, 17}) { + t.Fatal(got) + } +} +func TestNativeExportMissingOutputsAndBound(t *testing.T) { + b := nativeFileBinding(t) var output bytes.Buffer - err := b.ExportOutputs(ctx, &output) - if !errors.Is(err, os.ErrDeadlineExceeded) || ctx.Err() != nil || output.String() != "prefix" { - t.Fatalf("inherited pipe not bounded: output=%q error=%v context=%v", output.String(), err, ctx.Err()) + if err := b.ExportOutputs(t.Context(), &output); err != nil { + t.Fatal(err) + } + if _, err := tar.NewReader(&output).Next(); err != io.EOF { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(b.workspace, "outputs"), 0700); err != nil { + t.Fatal(err) + } + f, err := os.Create(filepath.Join(b.workspace, "outputs", "large")) + if err != nil { + t.Fatal(err) + } + if err = f.Truncate(artifactFileBytes + 1); err != nil { + t.Fatal(err) + } + f.Close() + if err = b.ExportOutputs(t.Context(), io.Discard); err == nil { + t.Fatal("oversize artifact accepted") } } -func TestExportOutputsCancellationUsesConsumerCloseContract(t *testing.T) { - b := exportBinding(t, "printf prefix; exec sleep 30") +type failedExportWriter struct{ err error } + +func (w failedExportWriter) Write([]byte) (int, error) { return 0, w.err } +func TestNativeExportCancellationAndConsumerFailure(t *testing.T) { + b := outputBinding(t) + want := errors.New("consumer rejected") + if err := b.ExportOutputs(t.Context(), failedExportWriter{want}); !errors.Is(err, want) { + t.Fatal(err) + } ctx, cancel := context.WithCancel(t.Context()) - defer cancel() reader, writer := io.Pipe() defer reader.Close() - // The dispatch owner closes the consumer on cancellation, releasing Write. stop := context.AfterFunc(ctx, func() { _ = reader.CloseWithError(ctx.Err()) }) defer stop() done := make(chan error, 1) go func() { err := b.ExportOutputs(ctx, writer); _ = writer.CloseWithError(err); done <- err }() - got := make([]byte, 1) - if _, err := reader.Read(got); err != nil { + var prefix [1]byte + if _, err := reader.Read(prefix[:]); err != nil { t.Fatal(err) } cancel() @@ -114,21 +103,35 @@ func TestExportOutputsCancellationUsesConsumerCloseContract(t *testing.T) { if err == nil { t.Fatal("cancelled export succeeded") } - case <-time.After(3 * time.Second): - t.Fatal("cancellation did not settle exporter") + case <-time.After(time.Second): + t.Fatal("export did not settle") } } -type failedExportWriter struct{ err error } - -func (w failedExportWriter) Write([]byte) (int, error) { return 0, w.err } +type changingOutput struct { + bytes.Buffer + change func() +} -func TestExportOutputsConsumerFailureStopsHelper(t *testing.T) { - b := exportBinding(t, "printf prefix; exec sleep 30") - want := errors.New("consumer rejected output") - ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) - defer cancel() - if err := b.ExportOutputs(ctx, failedExportWriter{want}); !errors.Is(err, want) || ctx.Err() != nil { - t.Fatalf("consumer failure did not stop helper: %v context=%v", err, ctx.Err()) +func (w *changingOutput) Write(data []byte) (int, error) { + if w.change != nil { + change := w.change + w.change = nil + change() + } + return w.Buffer.Write(data) +} +func TestNativeExportRejectsChangedFile(t *testing.T) { + b := nativeFileBinding(t) + if _, err := b.WriteWorkspaceFile(t.Context(), "outputs/file", []byte("before")); err != nil { + t.Fatal(err) + } + w := &changingOutput{change: func() { + if err := os.WriteFile(filepath.Join(b.workspace, "outputs", "file"), []byte("changed-length"), 0600); err != nil { + t.Error(err) + } + }} + if err := b.ExportOutputs(t.Context(), w); err == nil { + t.Fatal("changed artifact accepted") } } diff --git a/apps/parsar-daemon/internal/localworkspace/initialization.go b/apps/parsar-daemon/internal/localworkspace/initialization.go index b1f87f0de..f7d4612e5 100644 --- a/apps/parsar-daemon/internal/localworkspace/initialization.go +++ b/apps/parsar-daemon/internal/localworkspace/initialization.go @@ -10,11 +10,11 @@ import ( // ReadToolEnvironment selects the immutable user configuration. It never reads // process environment or introduces live-execution prerequisites for Files. func ReadToolEnvironment() (map[string]string, error) { - info, err := os.Lstat(ToolEnvironmentJSON) - if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0222 != 0 || info.Size() > 1<<20 { + info, err := os.Stat(toolEnvironmentPath()) + if err != nil || !info.Mode().IsRegular() || info.Size() > 1<<20 { return nil, errors.New("initialized user environment unavailable") } - body, err := os.ReadFile(ToolEnvironmentJSON) + body, err := os.ReadFile(toolEnvironmentPath()) var values map[string]string if err != nil || json.Unmarshal(body, &values) != nil || values == nil { return nil, errors.New("initialized user environment unavailable") @@ -50,7 +50,7 @@ func VerifyToolEnvironment(systemPackages bool) error { if !info.IsDir() { return errors.New("initialized tool directory unavailable") } - } else if !info.Mode().IsRegular() || info.Mode().Perm()&0222 != 0 || info.Size() > 1024*1024 { + } else if !info.Mode().IsRegular() || info.Size() > 1024*1024 { return errors.New("initialized tool configuration is not immutable") } } @@ -69,8 +69,17 @@ func VerifyToolEnvironment(systemPackages bool) error { // ReadOptionalToolEnvironment permits a directory-only Runtime without user // variables. Declared MCP variables still require the immutable explicit file. func ReadOptionalToolEnvironment() (map[string]string, error) { - if _, err := os.Lstat(ToolEnvironmentJSON); errors.Is(err, os.ErrNotExist) { + if _, err := os.Stat(toolEnvironmentPath()); errors.Is(err, os.ErrNotExist) { return map[string]string{}, nil } return ReadToolEnvironment() } + +// Native installations can select an explicit tool configuration file. Packaged +// installations retain their existing resource layout, without a sandbox. +func toolEnvironmentPath() string { + if path := os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE"); path != "" { + return path + } + return ToolEnvironmentJSON +} diff --git a/apps/parsar-daemon/internal/localworkspace/mcp.go b/apps/parsar-daemon/internal/localworkspace/mcp.go index b196fd857..146d1e812 100644 --- a/apps/parsar-daemon/internal/localworkspace/mcp.go +++ b/apps/parsar-daemon/internal/localworkspace/mcp.go @@ -2,6 +2,7 @@ package localworkspace import ( "errors" + "os" "strings" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" @@ -10,10 +11,13 @@ import ( const MCPInitializer = "/usr/local/bin/oac-runtime-initialize" -// MCPStdioCommand contains only installed identities. The server's executable, -// arguments and selected user variables are resolved after entering isolation. +// MCPStdioCommand resolves the common installed manifest in the daemon. func MCPStdioCommand(server proto.EnvironmentMCP) (string, []string) { - return "/usr/bin/python3", []string{"-I", "-S", MCPInitializer, "stdio", server.InstallationRoot, server.WorkspaceRoot, server.PackageRoot, server.Server.Name} + executable, err := os.Executable() + if err != nil { + return "", nil + } + return executable, []string{"runtime-mcp-exec", server.InstallationRoot, server.PackageRoot, server.Server.Name} } func resolveEnvironmentMCP(installed []agentcapabilities.InstalledMCP, values map[string]string) ([]proto.EnvironmentMCP, error) { diff --git a/apps/parsar-daemon/internal/localworkspace/mcp_test.go b/apps/parsar-daemon/internal/localworkspace/mcp_test.go index 36834c4de..77293a5e0 100644 --- a/apps/parsar-daemon/internal/localworkspace/mcp_test.go +++ b/apps/parsar-daemon/internal/localworkspace/mcp_test.go @@ -1,6 +1,7 @@ package localworkspace import ( + "os" "slices" "testing" @@ -32,7 +33,11 @@ func TestMCPStdioLauncherContainsOnlyInstalledIdentity(t *testing.T) { Name: "package_tool", Type: "stdio", Command: "untrusted-command", Args: []string{"private-argument"}, }} command, args := MCPStdioCommand(server) - if command != "/usr/bin/python3" || !slices.Equal(args, []string{"-I", "-S", MCPInitializer, "stdio", "/private/runtime/capabilities", "/private/runtime/workspace", "plugins/0", "package_tool"}) { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if command != executable || !slices.Equal(args, []string{"runtime-mcp-exec", "/private/runtime/capabilities", "plugins/0", "package_tool"}) { t.Fatal("native configuration included untrusted process configuration") } } diff --git a/apps/parsar-daemon/internal/localworkspace/native_binding.go b/apps/parsar-daemon/internal/localworkspace/native_binding.go new file mode 100644 index 000000000..1dcf54df3 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/native_binding.go @@ -0,0 +1,42 @@ +package localworkspace + +import ( + "errors" + "os" + "path/filepath" + + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" + "github.com/google/uuid" +) + +// ExecutionMode selects the installation layout, never a permission boundary. +func (b *Binding) ExecutionMode() string { + if b == nil { + return "" + } + return b.executionMode +} + +func newNativeBinding(environment, session, workspace, capabilities string) (*Binding, error) { + for _, value := range []string{environment, session} { + id, err := uuid.Parse(value) + if err != nil || id == uuid.Nil || id.String() != value { + return nil, errors.New("local workspace requires canonical resource identities") + } + } + for _, path := range []string{workspace, capabilities} { + if runtimefs.ValidateLocalPath(path) != nil || filepath.Dir(path) == path { + return nil, errors.New("local workspace requires absolute installation paths") + } + } + info, err := os.Stat(workspace) + if err != nil || !info.IsDir() { + return nil, errors.New("local workspace root must be an existing directory") + } + return &Binding{environment: environment, stateKey: "agents-api-" + session, workspace: workspace, capabilityRoot: capabilities, executionMode: "native", writer: &fileWriter{}}, nil +} + +// ReadToolEnvironment reads explicit initialization values for this installation. +func (b *Binding) ReadToolEnvironment() (map[string]string, error) { + return ReadOptionalToolEnvironment() +} diff --git a/apps/parsar-daemon/internal/localworkspace/native_files.go b/apps/parsar-daemon/internal/localworkspace/native_files.go new file mode 100644 index 000000000..c40bf1e5d --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/native_files.go @@ -0,0 +1,329 @@ +package localworkspace + +import ( + "archive/tar" + "context" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/google/uuid" +) + +// Logical API paths stay slash-separated on every host. os.Root anchors API +// file operations to the selected workspace; it does not constrain native tools. +func nativeAPIPath(path string) (string, error) { + if path == "" { + return ".", nil + } + if path == "." || len(path) > 4096 || !fs.ValidPath(path) || strings.ContainsAny(path, "\\\x00\r\n") { + return "", fs.ErrInvalid + } + return filepath.Localize(path) +} + +func (b *Binding) listNativeDirectory(ctx context.Context, path string, limit int) (agent.WorkspaceDirectoryResult, error) { + result := agent.WorkspaceDirectoryResult{Entries: []agent.WorkspaceDirectoryEntry{}} + if b == nil || ctx.Err() != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + local, err := nativeAPIPath(path) + if err != nil { + return result, agent.ErrWorkspaceReadInvalid + } + root, err := os.OpenRoot(b.workspace) + if err != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + defer root.Close() + if local != "." { + partial := "" + for _, part := range strings.Split(path, "/") { + partial = filepath.Join(partial, part) + info, err := root.Lstat(partial) + if errors.Is(err, fs.ErrPermission) { + return result, fs.ErrPermission + } + if err != nil || !info.IsDir() { + return result, agent.ErrWorkspaceNotDirectory + } + } + } + dir, err := root.Open(local) + if errors.Is(err, fs.ErrPermission) { + return result, fs.ErrPermission + } + if err != nil { + return result, agent.ErrWorkspaceNotDirectory + } + defer dir.Close() + entries, err := dir.ReadDir(limit + 1) + if err != nil && err != io.EOF { + return result, agent.ErrWorkspaceNotDirectory + } + if ctx.Err() != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + result.Truncated = len(entries) > limit + if result.Truncated { + entries = entries[:limit] + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + wire := &proto.WorkspaceDirectoryResult{Entries: []proto.WorkspaceDirectoryEntry{}, Truncated: result.Truncated} + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + return result, agent.ErrWorkspaceReadUncertain + } + item := proto.WorkspaceDirectoryEntry{Name: entry.Name(), Kind: "other"} + switch { + case info.Mode().IsRegular(): + item.Kind = "file" + size := info.Size() + item.SizeBytes = &size + case info.IsDir(): + item.Kind = "directory" + case info.Mode()&os.ModeSymlink != 0: + item.Kind = "symlink" + } + wire.Entries = append(wire.Entries, item) + } + if !proto.ValidWorkspaceDirectory(wire, limit) { + return result, agent.ErrWorkspaceReadInvalid + } + for _, item := range wire.Entries { + result.Entries = append(result.Entries, agent.WorkspaceDirectoryEntry{Name: item.Name, Kind: item.Kind, SizeBytes: item.SizeBytes}) + } + return result, nil +} + +func (b *Binding) writeNativeFile(ctx context.Context, path string, data []byte) (agent.WorkspaceWriteResult, error) { + result := agent.WorkspaceWriteResult{} + local, err := nativeAPIPath(path) + if err != nil { + return result, agent.ErrWorkspaceWriteInvalid + } + root, err := os.OpenRoot(b.workspace) + if err != nil { + return result, agent.ErrWorkspaceWriteUnavailable + } + defer root.Close() + if err = root.MkdirAll(filepath.Dir(local), 0700); err != nil { + return result, agent.ErrWorkspaceWriteRejected + } + temporary := ".oac-write-" + uuid.NewString() + file, err := root.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return result, agent.ErrWorkspaceWriteRejected + } + defer root.Remove(temporary) + _, err = file.Write(data) + if err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err != nil || closeErr != nil { + return result, agent.ErrWorkspaceWriteRejected + } + // Link publishes complete bytes without replacing an existing destination. + if err = root.Link(temporary, local); err != nil { + if info, e := root.Lstat(local); e == nil { + if info.IsDir() { + return result, agent.ErrWorkspaceWriteDirectory + } + return result, agent.ErrWorkspaceWriteUnsafe + } + return result, agent.ErrWorkspaceWriteRejected + } + return agent.WorkspaceWriteResult{SizeBytes: int64(len(data))}, nil +} + +const artifactFileBytes int64 = 200 << 20 +const artifactBatchBytes int64 = 500 << 20 +const artifactEntries = 4096 + +type nativeExport struct { + ctx context.Context + root *os.Root + archive *tar.Writer + entries int + bytes int64 +} + +func (b *Binding) exportNativeOutputs(ctx context.Context, output io.Writer) error { + if err := ctx.Err(); err != nil { + return err + } + root, err := os.OpenRoot(b.workspace) + if err != nil { + return err + } + defer root.Close() + archive := tar.NewWriter(output) + info, err := root.Lstat("outputs") + if errors.Is(err, fs.ErrNotExist) { + return archive.Close() + } + if err != nil { + return err + } + if !info.IsDir() { + return errors.New("workspace outputs is not a directory") + } + exporter := nativeExport{ctx: ctx, root: root, archive: archive} + if err = exporter.walk("outputs", 0); err != nil { + return err + } + if err = ctx.Err(); err != nil { + return err + } + return archive.Close() +} +func (x *nativeExport) walk(path string, depth int) error { + if err := x.ctx.Err(); err != nil { + return err + } + if depth > 64 || len(path) > 4096 { + return errors.New("workspace export exceeds traversal bound") + } + dir, err := x.root.Open(path) + if err != nil { + return err + } + entries, err := dir.ReadDir(artifactEntries + 1) + _ = dir.Close() + if err != nil && err != io.EOF { + return err + } + x.entries += len(entries) + if x.entries > artifactEntries { + return errors.New("workspace export exceeds entry bound") + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + name := path + "/" + entry.Name() + if _, err := nativeAPIPath(name); err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + switch { + case info.Mode()&os.ModeSymlink != 0: + continue + case info.IsDir(): + if err = x.walk(name, depth+1); err != nil { + return err + } + case info.Mode().IsRegular(): + if err = x.append(name); err != nil { + return err + } + default: + return errors.New("workspace output is not a regular file") + } + } + return nil +} +func (x *nativeExport) append(path string) error { + if err := x.ctx.Err(); err != nil { + return err + } + file, err := x.root.Open(path) + if err != nil { + return err + } + defer file.Close() + before, err := file.Stat() + if err != nil { + return err + } + size := before.Size() + if !before.Mode().IsRegular() || size < 0 || size > artifactFileBytes || size > artifactBatchBytes-x.bytes { + return errors.New("workspace export exceeds file bound") + } + x.bytes += size + if err = x.archive.WriteHeader(&tar.Header{Name: path, Typeflag: tar.TypeReg, Mode: 0600, Size: size, Format: tar.FormatPAX}); err != nil { + return err + } + if _, err = io.CopyN(x.archive, nativeContextReader{ctx: x.ctx, reader: file}, size); err != nil { + return err + } + after, err := file.Stat() + if err != nil { + return err + } + if after.Size() != size || !after.ModTime().Equal(before.ModTime()) { + return errors.New("workspace output changed during export") + } + return nil +} + +type nativeContextReader struct { + ctx context.Context + reader io.Reader +} + +func (r nativeContextReader) Read(data []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(data) +} + +// ReadWorkspaceFile returns the existing protocol's bounded prefix; the wire +// has no offset field. Non-regular entries cannot become unbounded streams. +func (b *Binding) ReadWorkspaceFile(ctx context.Context, path string, limit int) (agent.WorkspaceReadResult, error) { + result := agent.WorkspaceReadResult{} + if b == nil || ctx.Err() != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + if path == "" || limit < 1 || limit > proto.WorkspaceReadMaxBytes { + return result, agent.ErrWorkspaceReadInvalid + } + local, err := nativeAPIPath(path) + if err != nil { + return result, agent.ErrWorkspaceReadInvalid + } + root, err := os.OpenRoot(b.workspace) + if err != nil { + return result, agent.ErrWorkspaceReadUnavailable + } + defer root.Close() + info, err := root.Lstat(local) + if err != nil { + return result, err + } + if !info.Mode().IsRegular() { + return result, agent.ErrWorkspaceReadInvalid + } + file, err := root.Open(local) + if err != nil { + return result, err + } + defer file.Close() + info, err = file.Stat() + if err != nil { + return result, agent.ErrWorkspaceReadUncertain + } + if !info.Mode().IsRegular() { + return result, agent.ErrWorkspaceReadInvalid + } + raw, err := io.ReadAll(io.LimitReader(nativeContextReader{ctx: ctx, reader: file}, int64(limit)+1)) + if err != nil { + return result, agent.ErrWorkspaceReadUncertain + } + result.Truncated = len(raw) > limit + if result.Truncated { + raw = raw[:limit] + } + result.Data = raw + return result, nil +} diff --git a/apps/parsar-daemon/internal/localworkspace/snapshot_marker.go b/apps/parsar-daemon/internal/localworkspace/snapshot_marker.go index 2aaab5e8f..a55443dd6 100644 --- a/apps/parsar-daemon/internal/localworkspace/snapshot_marker.go +++ b/apps/parsar-daemon/internal/localworkspace/snapshot_marker.go @@ -5,11 +5,10 @@ import ( "errors" "io" "os" - "path/filepath" - "syscall" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "github.com/google/uuid" ) @@ -17,7 +16,7 @@ import ( // only the operator root, never capability configuration or a second inventory. type snapshotMarker struct { directory *os.Root - lock *os.File + unlock func() name string root string completed bool @@ -32,12 +31,7 @@ func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { } } private, err := paths.Root() - if err != nil || agentcapabilities.ValidateLocalDirectories([]string{private}) != nil || private == "/" || - !canonicalExistingParent(private) { - return nil, agentcapabilities.ErrInvalid - } - canonical, err := filepath.EvalSymlinks(private) - if err != nil || canonical != private { + if err != nil || agentcapabilities.ValidateLocalDirectories([]string{private}) != nil { return nil, agentcapabilities.ErrInvalid } current, err := os.OpenRoot(private) @@ -49,30 +43,10 @@ func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { current.Close() return nil, agentcapabilities.ErrInvalid } - err = current.Mkdir(child, 0700) - if err != nil && !errors.Is(err, os.ErrExist) { - current.Close() - return nil, agentcapabilities.ErrInvalid - } - created := err == nil - info, err := current.Lstat(child) - if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + if err = runtimefs.MkdirPrivate(current, child); err != nil || runtimefs.SyncDirectory(current) != nil { current.Close() return nil, agentcapabilities.ErrInvalid } - if created { - parent, openErr := current.Open(".") - if openErr != nil { - current.Close() - return nil, agentcapabilities.ErrInvalid - } - syncErr := parent.Sync() - parent.Close() - if syncErr != nil { - current.Close() - return nil, agentcapabilities.ErrInvalid - } - } next, err := current.OpenRoot(child) current.Close() if err != nil { @@ -84,17 +58,12 @@ func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { current.Close() return nil, agentcapabilities.ErrInvalid } - lock, err := current.Open(".") + unlock, err := runtimefs.LockDirectory(current) if err != nil { current.Close() return nil, agentcapabilities.ErrInvalid } - if syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil { - lock.Close() - current.Close() - return nil, agentcapabilities.ErrInvalid - } - marker := &snapshotMarker{directory: current, lock: lock, name: identity.EnvironmentID + "-" + identity.SessionID + ".json", root: b.capabilityRoot} + marker := &snapshotMarker{directory: current, unlock: unlock, name: identity.EnvironmentID + "-" + identity.SessionID + ".json", root: b.capabilityRoot} completed, err := marker.read() if err != nil { marker.close() @@ -104,22 +73,10 @@ func (b *Binding) openSnapshotMarker() (*snapshotMarker, error) { return marker, nil } -func privateSnapshotDirectory(root *os.Root) bool { - file, err := root.Open(".") - if err != nil { - return false - } - defer file.Close() - info, err := file.Stat() - if err != nil || !info.IsDir() || info.Mode().Perm()&0077 != 0 { - return false - } - stat, ok := info.Sys().(*syscall.Stat_t) - return ok && stat.Uid == uint32(os.Getuid()) -} +func privateSnapshotDirectory(root *os.Root) bool { return runtimefs.PrivateDirectory(root) == nil } func (m *snapshotMarker) read() (bool, error) { - file, err := m.directory.OpenFile(m.name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + file, err := runtimefs.OpenPrivate(m.directory, m.name, os.O_RDONLY) if errors.Is(err, os.ErrNotExist) { return false, nil } @@ -131,8 +88,7 @@ func (m *snapshotMarker) read() (bool, error) { if err != nil { return false, err } - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || stat.Uid != uint32(os.Getuid()) || stat.Nlink != 1 || info.Size() > 8192 { + if info.Size() > 8192 { return false, agentcapabilities.ErrInvalid } // Exact bytes also reject duplicate members, trailing data and extra fields. @@ -156,14 +112,14 @@ func (m *snapshotMarker) complete() error { if err != nil { return agentcapabilities.ErrInvalid } - file, err := m.directory.OpenFile(m.name, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0600) + file, err := runtimefs.OpenPrivate(m.directory, m.name, os.O_WRONLY|os.O_CREATE|os.O_EXCL) if err != nil { return agentcapabilities.ErrInvalid } _, writeErr := file.Write(append(data, '\n')) syncErr := file.Sync() closeErr := file.Close() - if writeErr != nil || syncErr != nil || closeErr != nil || m.lock.Sync() != nil { + if writeErr != nil || syncErr != nil || closeErr != nil || runtimefs.SyncDirectory(m.directory) != nil { return agentcapabilities.ErrInvalid } m.completed = true @@ -171,7 +127,6 @@ func (m *snapshotMarker) complete() error { } func (m *snapshotMarker) close() { - _ = syscall.Flock(int(m.lock.Fd()), syscall.LOCK_UN) - m.lock.Close() + m.unlock() m.directory.Close() } diff --git a/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go b/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go index ca0b6696c..9c9cf40b4 100644 --- a/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go +++ b/apps/parsar-daemon/internal/localworkspace/snapshot_marker_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "testing" "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" @@ -29,7 +30,7 @@ func markerPath(b *Binding) string { identity := b.capabilityIdentity() return filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "daemon", "capability-installations", identity.EnvironmentID+"-"+identity.SessionID+".json") } -func TestSnapshotMarkerPrivateRoundTrip(t *testing.T) { +func TestSnapshotMarkerRoundTrip(t *testing.T) { b, _ := markerBinding(t) m, err := b.openSnapshotMarker() if err != nil { @@ -43,7 +44,7 @@ func TestSnapshotMarkerPrivateRoundTrip(t *testing.T) { } m.close() info, err := os.Stat(markerPath(b)) - if err != nil || info.Mode().Perm() != 0600 { + if err != nil || runtime.GOOS != "windows" && info.Mode().Perm() != 0600 { t.Fatal("marker not private", err) } raw, err := os.ReadFile(markerPath(b)) @@ -151,8 +152,8 @@ func TestSnapshotMarkerBackfillsOnlyVerifiedManifest(t *testing.T) { b.capabilityRoot = previousRoot } -func TestSnapshotMarkerRefusesDamagedOrAliasedPrivateState(t *testing.T) { - for _, kind := range []string{"corrupt", "foreign root", "public file", "symlink file", "hardlink file", "symlink directory", "symlink private root"} { +func TestSnapshotMarkerRefusesDamagedState(t *testing.T) { + for _, kind := range []string{"corrupt", "foreign root"} { t.Run(kind, func(t *testing.T) { b, request := markerBinding(t) if _, err := b.Prepare(t.Context(), request); err != nil { @@ -168,36 +169,7 @@ func TestSnapshotMarkerRefusesDamagedOrAliasedPrivateState(t *testing.T) { if err := os.WriteFile(name, []byte("{\"capability_root\":\"/another\"}\n"), 0600); err != nil { t.Fatal(err) } - case "public file": - if err := os.Chmod(name, 0644); err != nil { - t.Fatal(err) - } - case "symlink file": - saved := filepath.Join(t.TempDir(), "saved") - if err := os.Rename(name, saved); err != nil { - t.Fatal(err) - } - if err := os.Symlink(saved, name); err != nil { - t.Fatal(err) - } - case "hardlink file": - if err := os.Link(name, filepath.Join(t.TempDir(), "linked")); err != nil { - t.Fatal(err) - } - case "symlink directory": - saved := filepath.Join(filepath.Dir(filepath.Dir(name)), "moved") - if err := os.Rename(filepath.Dir(name), saved); err != nil { - t.Fatal(err) - } - if err := os.Symlink(saved, filepath.Dir(name)); err != nil { - t.Fatal(err) - } - case "symlink private root": - alias := filepath.Join(t.TempDir(), "private") - if err := os.Symlink(os.Getenv("OAC_RUNTIME_HOME"), alias); err != nil { - t.Fatal(err) - } - t.Setenv("OAC_RUNTIME_HOME", alias) + } before, _ := os.ReadFile(name) if _, err := b.Prepare(t.Context(), request); err == nil { diff --git a/apps/parsar-daemon/internal/localworkspace/write.go b/apps/parsar-daemon/internal/localworkspace/write.go index 15aaf726d..4ea1b191e 100644 --- a/apps/parsar-daemon/internal/localworkspace/write.go +++ b/apps/parsar-daemon/internal/localworkspace/write.go @@ -3,15 +3,11 @@ package localworkspace import ( "bytes" "context" - "crypto/sha256" "encoding/json" "errors" "io" "io/fs" - "os/exec" - "strconv" "strings" - "time" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -47,27 +43,8 @@ func (b *Binding) WriteWorkspaceFile(ctx context.Context, path string, data []by return result, agent.ErrWorkspaceWriteUncertain } defer func() { w.uncertain = errors.Is(err, agent.ErrWorkspaceWriteUncertain) }() - // Once admitted, observer cancellation cannot turn a possible commit into a - // rejection. Missing results poison this Runtime writer, even after local reap. - operation, cancel := context.WithTimeout(context.WithoutCancel(ctx), 65*time.Second) - defer cancel() - digest := sha256.Sum256(data) - // This writer serves only public Files.create, so it always selects the - // helper's create mode: create missing parents and never replace. - cmd := exec.CommandContext(operation, w.helper, b.workspace, path, strconv.Itoa(len(data)), w.staging, "create") - cmd.Dir = "/" - cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"} - cmd.Stdin = io.MultiReader(bytes.NewReader(data), bytes.NewReader(digest[:])) - output := &writeOutput{} - cmd.Stdout = output - cmd.WaitDelay = time.Second - if err := cmd.Start(); err != nil { - return result, agent.ErrWorkspaceWriteUnavailable - } - if err := cmd.Wait(); err != nil { - return result, agent.ErrWorkspaceWriteUncertain - } - return decodeWrite(output.Bytes(), len(data)) + // Once admitted, finish this synchronous mutation before returning ownership. + return b.writeNativeFile(context.WithoutCancel(ctx), path, data) } // A malformed helper cannot grow the daemon's output buffer without bound. diff --git a/apps/parsar-daemon/internal/localworkspace/write_test.go b/apps/parsar-daemon/internal/localworkspace/write_test.go index 3e46a4164..0cf675e23 100644 --- a/apps/parsar-daemon/internal/localworkspace/write_test.go +++ b/apps/parsar-daemon/internal/localworkspace/write_test.go @@ -5,12 +5,10 @@ import ( "context" "errors" "fmt" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "os" "path/filepath" "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" ) func writableBinding(t *testing.T, script string) *Binding { @@ -41,101 +39,6 @@ func writableBinding(t *testing.T, script string) *Binding { return b } -func TestWriteRejectsUnsafeBindings(t *testing.T) { - b := writableBinding(t, "exit 1\n") - link := filepath.Join(filepath.Dir(b.workspace), "alias") - if err := os.Symlink(b.writer.staging, link); err != nil { - t.Fatal(err) - } - inside := filepath.Join(b.workspace, "helper") - if err := os.WriteFile(inside, []byte("#!/bin/sh\n"), 0700); err != nil { - t.Fatal(err) - } - for _, pair := range [][2]string{{"", b.writer.staging}, {b.writer.helper, ""}, {b.writer.helper, b.workspace}, {b.writer.helper, link}, {inside, b.writer.staging}, {b.writer.helper, filepath.Dir(b.workspace)}} { - if err := b.bindWriter(pair[0], pair[1]); err == nil { - t.Fatalf("unsafe writer accepted: %q", pair) - } - } -} - -func TestWriteReceiptAndCredentialBoundary(t *testing.T) { - t.Setenv("OAC_TEST_PRIVATE_CREDENTIAL", "synthetic-secret") - // Files.create always selects the helper's explicit create mode. - b := writableBinding(t, "[ -z \"$OAC_TEST_PRIVATE_CREDENTIAL\" ] || exit 13\n[ \"$#\" = 5 ] && [ \"$5\" = create ] || exit 14\ncat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":3}'\n") - result, err := b.WriteWorkspaceFile(t.Context(), "file", []byte{0, 1, 2}) - if err != nil || result.SizeBytes != 3 { - t.Fatalf("write: %+v %v", result, err) - } - for _, p := range []string{"", ".", "..", "/etc/passwd", "a/../b", "a//b", "a\\b", "a\nb"} { - if _, err := b.WriteWorkspaceFile(t.Context(), p, nil); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { - t.Fatalf("path %q: %v", p, err) - } - } - if _, err := b.WriteWorkspaceFile(t.Context(), "file", make([]byte, WriteMaxBytes+1)); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { - t.Fatal(err) - } -} - -func TestWriteDetachmentAndConcurrentAdmission(t *testing.T) { - b := writableBinding(t, "cat >/dev/null\ntouch \"$1/started\"\nwhile [ ! -f \"$1/release\" ]; do sleep 0.01; done\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":0}'\n") - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - done := make(chan error, 1) - go func() { _, err := b.WriteWorkspaceFile(ctx, "file", nil); done <- err }() - defer os.WriteFile(filepath.Join(b.workspace, "release"), nil, 0600) - deadline := time.After(5 * time.Second) - for { - if _, err := os.Stat(filepath.Join(b.workspace, "started")); err == nil { - break - } - select { - case <-deadline: - t.Fatal("helper did not start") - case <-time.After(5 * time.Millisecond): - } - } - cancel() - if _, err := b.WriteWorkspaceFile(t.Context(), "other", nil); !errors.Is(err, agent.ErrWorkspaceWriteBusy) { - t.Fatalf("overlap: %v", err) - } - if err := os.WriteFile(filepath.Join(b.workspace, "release"), nil, 0600); err != nil { - t.Fatal(err) - } - select { - case err := <-done: - if err != nil { - t.Fatal("detached admitted write lost receipt", err) - } - case <-time.After(5 * time.Second): - t.Fatal("write did not finish") - } -} - -func TestWriteUncertaintyStopsSuccessor(t *testing.T) { - for name, script := range map[string]string{ - "missing": "cat >/dev/null\nexit 0\n", - "wrong-size": "cat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":8}'\n", - "overflow": "cat >/dev/null\nhead -c 2048 /dev/zero\n", - "exit": "cat >/dev/null\nprintf '%s' '{\"version\":1,\"outcome\":\"completed\",\"size_bytes\":0}'\nexit 1\n", - } { - t.Run(name, func(t *testing.T) { - b := writableBinding(t, script) - if _, err := b.WriteWorkspaceFile(t.Context(), "file", nil); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { - t.Fatalf("first: %v", err) - } - if err := os.WriteFile(b.writer.helper, []byte("#!/bin/sh\ntouch \"$1/forbidden\"\n"), 0700); err != nil { - t.Fatal(err) - } - if _, err := b.WriteWorkspaceFile(t.Context(), "other", nil); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { - t.Fatalf("successor: %v", err) - } - if _, err := os.Stat(filepath.Join(b.workspace, "forbidden")); !os.IsNotExist(err) { - t.Fatal("unknown write admitted successor") - } - }) - } -} - func TestWriteReceiptValidation(t *testing.T) { for _, response := range []string{ `{"version":1,"outcome":"completed"}`, @@ -165,61 +68,69 @@ func TestWriteReceiptValidation(t *testing.T) { } } -func TestLocalWriteNativeInstaller(t *testing.T) { - helper := os.Getenv("OAC_TEST_LOCAL_WRITE_HELPER") - if helper == "" { - t.Skip("requires the built native installer") +func nativeFileBinding(t *testing.T) *Binding { + t.Helper() + return &Binding{workspace: t.TempDir(), writer: &fileWriter{}} +} +func TestNativeFileCreateAndNoReplace(t *testing.T) { + b := nativeFileBinding(t) + for _, size := range []int{0, 3, (1 << 20) + 17, WriteMaxBytes} { + name := fmt.Sprintf("nested/file-%d", size) + data := bytes.Repeat([]byte{17}, size) + got, err := b.WriteWorkspaceFile(t.Context(), name, data) + if err != nil || got.SizeBytes != int64(size) { + t.Fatal(size, got, err) + } + if _, err = b.WriteWorkspaceFile(t.Context(), name, []byte("overwrite")); !errors.Is(err, agent.ErrWorkspaceWriteUnsafe) { + t.Fatal("existing file replaced", err) + } + raw, err := os.ReadFile(filepath.Join(b.workspace, filepath.FromSlash(name))) + if err != nil || !bytes.Equal(raw, data) { + t.Fatal("file content changed", err) + } } - b := writableBinding(t, "exit 1\n") - if err := b.bindWriter(helper, b.writer.staging); err != nil { + if _, err := b.WriteWorkspaceFile(t.Context(), "nested", nil); !errors.Is(err, agent.ErrWorkspaceWriteDirectory) { t.Fatal(err) } - escape := filepath.Join(b.workspace, "escape") - if err := os.Symlink(b.writer.staging, escape); err != nil { + if _, err := b.WriteWorkspaceFile(t.Context(), "after-rejection", nil); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(b.workspace, "setup.txt"), []byte("setup"), 0600); err != nil { + entries, err := os.ReadDir(b.workspace) + if err != nil { t.Fatal(err) } - // Missing parents are created; an existing destination is never replaced. - for _, size := range []int{0, 3, (1 << 20) + 17, WriteMaxBytes} { - path := fmt.Sprintf("n1/n2/file-%d", size) - data := bytes.Repeat([]byte{0, 255, 17}, (size+2)/3)[:size] - got, err := b.WriteWorkspaceFile(t.Context(), path, data) - if err != nil || got.SizeBytes != int64(size) { - t.Fatalf("size %d: %+v %v", size, got, err) + for _, entry := range entries { + if len(entry.Name()) >= 11 && entry.Name()[:11] == ".oac-write-" { + t.Fatal("temporary retained") } - actual, err := os.ReadFile(filepath.Join(b.workspace, path)) - if err != nil || !bytes.Equal(actual, data) { - t.Fatalf("size %d bytes differ: %v", size, err) - } - } - if info, err := os.Lstat(filepath.Join(b.workspace, "n1")); err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { - t.Fatal("parent directory", info, err) } - for path, want := range map[string]error{ - "n1/n2/file-3": agent.ErrWorkspaceWriteUnsafe, - "setup.txt": agent.ErrWorkspaceWriteUnsafe, - "escape": agent.ErrWorkspaceWriteUnsafe, - "escape/new": agent.ErrWorkspaceWriteUnsafe, - "n1/n2": agent.ErrWorkspaceWriteDirectory, - "n1": agent.ErrWorkspaceWriteDirectory, - } { - if _, err := b.WriteWorkspaceFile(t.Context(), path, []byte("denied")); err != want { - t.Fatalf("%s: %v", path, err) +} +func TestNativeFileAdmission(t *testing.T) { + b := nativeFileBinding(t) + for _, path := range []string{"", ".", "..", "/etc/passwd", "a/../b", "a//b", "a\\b", "a\nb"} { + if _, err := b.WriteWorkspaceFile(t.Context(), path, nil); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { + t.Fatal(path, err) } } - if data, err := os.ReadFile(filepath.Join(b.workspace, "n1/n2/file-3")); err != nil || !bytes.Equal(data, []byte{0, 255, 17}) { - t.Fatal("existing file changed", err) + if _, err := b.WriteWorkspaceFile(t.Context(), "large", make([]byte, WriteMaxBytes+1)); !errors.Is(err, agent.ErrWorkspaceWriteInvalid) { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := b.WriteWorkspaceFile(ctx, "cancelled", nil); !errors.Is(err, agent.ErrWorkspaceWriteUnavailable) { + t.Fatal(err) } - if data, err := os.ReadFile(filepath.Join(b.workspace, "setup.txt")); err != nil || string(data) != "setup" { - t.Fatal("untracked file changed", err) + if _, err := os.Stat(filepath.Join(b.workspace, "cancelled")); !os.IsNotExist(err) { + t.Fatal("cancelled request mutated workspace") } - if _, err := b.WriteWorkspaceFile(t.Context(), "after-rejection", nil); err != nil { - t.Fatal("known rejection blocked next write", err) + b.writer.mu.Lock() + _, err := b.WriteWorkspaceFile(t.Context(), "busy", nil) + b.writer.mu.Unlock() + if !errors.Is(err, agent.ErrWorkspaceWriteBusy) { + t.Fatal(err) } - entries, err := os.ReadDir(b.writer.staging) - if err != nil || len(entries) != 0 { - t.Fatal("staging retained successful/known-rejected data", err) + b.writer.uncertain = true + if _, err = b.WriteWorkspaceFile(t.Context(), "uncertain", nil); !errors.Is(err, agent.ErrWorkspaceWriteUncertain) { + t.Fatal(err) } } diff --git a/apps/parsar-daemon/internal/paths/paths.go b/apps/parsar-daemon/internal/paths/paths.go index c302e2564..6b48dc6bb 100644 --- a/apps/parsar-daemon/internal/paths/paths.go +++ b/apps/parsar-daemon/internal/paths/paths.go @@ -8,9 +8,11 @@ package paths import ( "fmt" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "os" "path/filepath" "regexp" + "strings" ) const DefaultProfile = "default" @@ -25,7 +27,9 @@ func ValidateProfile(name string) error { if name == "" { return fmt.Errorf("profile name must not be empty") } - if !profilePattern.MatchString(name) { + base := strings.ToUpper(strings.SplitN(name, ".", 2)[0]) + reserved := base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || (len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9') + if strings.HasSuffix(name, ".") || reserved || !profilePattern.MatchString(name) { return fmt.Errorf("profile %q must match %s", name, profilePattern.String()) } return nil @@ -35,6 +39,9 @@ func ValidateProfile(name string) error { // sandbox environments without a writable home. func Root() (string, error) { if override := os.Getenv("OAC_RUNTIME_HOME"); override != "" { + if !filepath.IsAbs(override) || filepath.Clean(override) != override { + return "", fmt.Errorf("OAC_RUNTIME_HOME must be a clean absolute directory") + } return override, nil } home, err := os.UserHomeDir() @@ -64,7 +71,7 @@ func EnsureProfileDir(profile string) (string, error) { if err != nil { return "", err } - if err := os.MkdirAll(dir, 0o700); err != nil { + if err := runtimefs.EnsurePrivateDir(dir); err != nil { return "", fmt.Errorf("create profile dir %s: %w", dir, err) } return dir, nil diff --git a/apps/parsar-daemon/internal/paths/paths_test.go b/apps/parsar-daemon/internal/paths/paths_test.go index 053f4b94d..4f42c4057 100644 --- a/apps/parsar-daemon/internal/paths/paths_test.go +++ b/apps/parsar-daemon/internal/paths/paths_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" @@ -15,7 +16,10 @@ import ( // we want. func withTempHome(t *testing.T) string { t.Helper() - dir := t.TempDir() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } t.Setenv("OAC_RUNTIME_HOME", dir) return dir } @@ -29,7 +33,7 @@ func TestValidateProfile(t *testing.T) { } bad := []string{ "", - "../escape", + "../escape", ".", "..", "profile.", "CON", "nul.json", "com1", "LPT9", "with/slash", "with\\backslash", "with space", @@ -102,7 +106,7 @@ func TestEnsureProfileDirCreates0700(t *testing.T) { if !info.IsDir() { t.Fatalf("EnsureProfileDir returned %q which is not a directory", dir) } - if mode := info.Mode().Perm(); mode != 0o700 { + if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o700 { t.Errorf("EnsureProfileDir mode = %o, want 0700", mode) } @@ -128,3 +132,10 @@ func TestInvalidProfileShortCircuits(t *testing.T) { t.Fatal("EnsureProfileDir accepted invalid profile name") } } + +func TestRootRejectsRelativeOverride(t *testing.T) { + t.Setenv("OAC_RUNTIME_HOME", "relative") + if _, err := paths.Root(); err == nil { + t.Fatal("relative private home accepted") + } +} diff --git a/docs/api/public-agent-api.md b/docs/api/public-agent-api.md index d62ff1a79..37aed4732 100644 --- a/docs/api/public-agent-api.md +++ b/docs/api/public-agent-api.md @@ -85,7 +85,10 @@ read. `openai_hosted` keeps the official wire name and means Core-managed compute here. The deployment chooses E2B or its own Docker/microsandbox nodes. A public `self_hosted` Environment uses caller-owned compute and the same colocated -Runtime. For a `self_hosted` Session, the deployment operator issues the +Runtime on Linux, macOS or Windows; managed Providers remain Linux-only. Native +Runtime execution uses the launching user's permissions without an inner sandbox. +See [native installation and validation status](../self-hosted-native.md). +For a `self_hosted` Session, the deployment operator issues the Environment's executor credential with the Core key; see [executor credentials](../../contracts/agents-api/environment-executor-credentials.md). Our daemon transport and the supported native harness differences are diff --git a/docs/design-principles.md b/docs/design-principles.md index bccbf88b9..16a43ac74 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -63,6 +63,22 @@ create, copy or edit arbitrary assets, start a Session, send an event, cancel work, or read stored credentials. Project keys share all assets within their Project; nothing is shared or copied across Projects. +## Runtime and outer isolation + +The same daemon and Core protocol serve self-hosted Linux, macOS and Windows. +OS differences belong to Runtime implementations; harness differences belong to +adapters. Managed Providers remain Linux-only. Runtime prepares capabilities and +executes work; Providers create, bootstrap and reclaim outer Environments. + +The daemon runs tools with its launching user's permissions. It does not add a +filesystem, permission or network sandbox, including on Linux. Outer Docker/E2B/ +microsandbox Environments provide managed isolation. Credential authentication, +ordinary private storage, atomic writes, locks and process cleanup remain required, +but do not protect Runtime data from tools running as the same user. Local +capability snapshots share the managed parser and do not create a second security +boundary. See the [native Runtime guide](self-hosted-native.md) for current platform +validation limits. + ## Secrets and evidence Credential values, model credentials and confidential template initialization are diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index b7bb5dc03..07c002e4a 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -10,6 +10,7 @@ applications call Core with. | [Install Core and Web](install.md) | Administrators | Prerequisites, download, the installer and every option, HTTPS and the reverse proxy, a local trial, first sign-in, what is created on disk | | [Nodes](nodes.md) | Administrators | Adding a node with one command, sudo and no-sudo modes, removal, logs and troubleshooting | | [Self-hosted executors](self-hosted.md) | Administrators and application owners | Connecting an application's own machine to a `self_hosted` Session, rotating and revoking its credential | +| [Native Runtime installation](../self-hosted-native.md) | Executor operators | Linux, macOS and Windows installation commands, lifecycle and current validation limits | | [Operations](operations.md) | Administrators | The `oac` command, the Core key, backups, current-release repair and troubleshooting | | [Configuration reference](../configuration.md) | Administrators | Every `config.json` setting and every runtime setting in Web | | [Call the API](quickstart.md) | Application developers | `OPENAI_BASE_URL` and `OPENAI_API_KEY`, running a Session, model providers and `x_agents_core` | diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index 1a8975074..fe93ad8a9 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -222,11 +222,14 @@ sign-in sessions live in Web's memory and last 12 hours. microsandbox uses an explicit network policy: public egress, the Core and DNS ports the Runtime needs, and no inbound or private-network access. Private model or MCP endpoints -need an explicit policy change. A Session's own network policy is separate. - -The Docker node uses the nested-sandbox Runtime policy. Its service account needs the -host's Docker daemon, and Core has no Docker socket. Install nodes on trusted hosts and -don't share their Docker access with untrusted users. +need an explicit policy change. The daemon does not enforce Session-level +`disabled` or `restricted` networking. Combinations without the required outer +network enforcement are unsupported; a requested mode is not an isolation guarantee. + +Docker isolates the Runtime at the outer container boundary. The daemon does not +add an inner filesystem, permission or network sandbox. The node's service account +needs the host's Docker daemon, and Core has no Docker socket. Install nodes on +trusted hosts and do not share their Docker access with untrusted users. A Core restart leaves node services and resident microVMs running. A host reboot, the end of a user manager or the loss of a running microVM is not a Core restart and is not diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md index 4425ca8bd..cc22d12c0 100644 --- a/docs/getting-started/self-hosted.md +++ b/docs/getting-started/self-hosted.md @@ -1,35 +1,27 @@ # Self-hosted executors -A `self_hosted` Session runs on a machine the application owns, the executor host, -instead of a node. The application creates the Session through `/v1`; the -administrator issues an executor credential for it in Web; one command on the host -starts a Runtime container that connects to Core. The Runtime is the same one nodes -use: daemon, native harness and workspace. - -An executor credential works for one Session's environment only. It can't call the -Agent API, the Core API or node enrollment. - -## Before you start - -- **Core has an HTTPS public URL.** The Session's `remote_url` is - `wss:///api/v1/agent-daemon/ws`, and the installer connects only over - `wss://`. Web shows no command until Core has a reachable public URL. -- **Web holds the installer and the Runtime files.** The command downloads the - installer, the Runtime launcher and the image from Core's public URL, which the - reverse proxy sends to Web (in a [split deployment](install.md#split-deployment), to - the Web host); install from the offline bundle. A console without the self-hosted - installer shows no **Connect a host** section at all. -- **The host** runs Linux amd64 with Python 3.9+, `curl`, `sha256sum` and the Docker - CLI, with Docker usable through `/var/run/docker.sock` by the non-root user that runs - the installer; the installer refuses root. It reaches the public URL over HTTPS. -- **The Session carries its model provider**, in the request or saved on its Agent. - Default models never apply to self-hosted Sessions, and creating one without a - provider fails with 400 `model_provider_required`. +A `self_hosted` Session runs on a machine the application owns. The application +creates the Session through `/v1`; the administrator issues an executor credential +in Web or through `/core/v1`; the host runs `oac-daemon` with that credential. +Linux, macOS and Windows use the same Runtime protocol. Core-managed Providers +remain Linux-only. + +The daemon runs with its launching user's permissions and adds no inner +filesystem, permission or network sandbox. Its tools may access that user's files +and Runtime credentials. Managed isolation belongs to the outer Environment; +self-hosted operators choose any outer isolation they need. See the +[native installation guide](../self-hosted-native.md) for prerequisites, commands +and the current platform validation status. + +An executor credential works for one Environment only. It cannot call the Agent +API, Core API or node enrollment. Default model providers never apply to +self-hosted Sessions: the request or Agent must carry its own model provider. +Otherwise creation fails with 400 `model_provider_required`. ## Connect a host -1. **The application creates the Session**, with its Project API key and its own model - provider: +1. Create an existing workspace on the executor host. The application creates a + Session using that host's absolute path and its own Project API key: ```python import os @@ -37,7 +29,10 @@ Agent API, the Core API or node enrollment. client = OpenAI() # reads OPENAI_BASE_URL and OPENAI_API_KEY session = client.beta.agents.sessions.create( - environment={"type": "self_hosted", "workspace_directory": "/workspace"}, + environment={ + "type": "self_hosted", + "workspace_directory": os.environ["EXECUTOR_WORKSPACE"], + }, extra_body={ "agent": {"model": os.environ["MODEL_NAME"], "x_agents_core": {"harness": "codex"}}, "x_agents_core": {"model_provider": { @@ -50,92 +45,60 @@ Agent API, the Core API or node enrollment. print(session.id, session.environment.id, session.environment.remote_url) ``` -2. **In Web**, open **Session log**, then the Session's page. In its - **Executor credentials** section, **Connect a host** shows a command in a - **Terminal** block; copy it and run it on the executor host: - - ```sh - (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT - curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example/node-install/self-hosted-install.pyz' -o "$d/install.pyz" && - printf '%s %s\n' '' "$d/install.pyz" | sha256sum -c --status && - python3 "$d/install.pyz" --source-url 'https://core.example' --environment-id '' --remote 'wss://core.example/api/v1/agent-daemon/ws') - ``` - - The command contains no secret. It downloads the installer from your console and - checks its SHA-256 before running it. -3. **Issue the credential.** Select **Issue credential**. The **Executor credential** - dialog shows it once: choose **Copy credential**. -4. **Paste it at the installer's hidden prompt** and press Enter. The prompt accepts the - compact or the pretty-printed credential and never echoes it. Then choose **Done** in - the dialog; after that the credential can't be shown again. Closing the dialog with - × keeps the credential on the page until you choose **Done**. - -The installer downloads the Runtime from the console and checks it, starts a container -named `oac-selfhost-<32 hex digits>`, and waits until Core confirms that the -Environment is connected. The Session then runs its Turns there. Rerunning the same -command resumes the same installation. It refuses, with "This installation belongs to -another Environment or distribution", once Core runs another release or its public URL -changed: the host's installation is tied to both. Moving an executor to a new release -or address isn't supported yet. A fresh installation would start a new container -without the old workspace and native history, so the Session can't continue there: -stop the old container, create a new self-hosted Session and connect a host for it. - -For automation without a terminal, choose **Download credential file**, which saves -`executor-credential-.json`, make it private -(`chmod 600`), and add `--credential-file /absolute/path/to/that-file.json` to the -installer line. The path must not go through a symbolic link. - -The host keeps its state in `~/.oac/self-hosted//` -(`--install-dir` chooses another absolute directory). Keep it with the container's -volumes: they hold the credential, workspace and native history. The Runtime's log is -`docker logs `. A connected Environment proves the connection, not that the -model works; send the Session a Turn to test execution. - -Core delivers the Session's model provider only over this Environment's executor -connection. The host keeps it in the Runtime's harness home, where the model's tools -and the Files API can't read it but the host's owner can. +2. In Web, open **Session log**, then the Session's **Executor credentials** + section. Choose **Issue credential**, then **Download credential file**. Core + returns the credential only once; retain it privately before choosing **Done**. +3. On the host, run `oac-daemon install` with the returned `remote_url`, Environment + ID, matching workspace and absolute credential-file path, then `oac-daemon start`. + The [native guide](../self-hosted-native.md#install-and-start) has Linux/macOS and + PowerShell examples. No Docker installation is required for this native path. + +The console's existing **Connect a host** download command targets the Linux +container installer, not the native installation command. Use the native guide for +this workflow; downloading an executor credential is independent of the console's +installer assets. Core must be reachable from the host: `wss://` is required +outside loopback, while a local Core can use a loopback `ws://` URL. + +A connected Environment proves only the machine connection. Send a Turn to check +the selected harness and model. Core supplies the Session's model provider over +this Environment's authenticated executor connection. The host's user and tools +running with that user's permissions can access locally stored Runtime data. ## Local capability directories -A self-hosted Session may supply `capability_directories` alongside its -`workspace_directory`, for example: +A self-hosted Session may supply `capability_directories` alongside its workspace: ```python environment = { "type": "self_hosted", - "workspace_directory": "/workspace", - "capability_directories": ["/workspace/capabilities"], + "workspace_directory": os.environ["EXECUTOR_WORKSPACE"], + "capability_directories": [os.environ["EXECUTOR_CAPABILITIES"]], } ``` -The API accepts portable absolute Unix, Windows drive and UNC source paths without -checking Core's filesystem. The current Linux Runtime requires canonical local Unix -paths and applies its own access checks; this does not add Windows support. -These paths refer to directories visible inside that Runtime. Populate them before -first execution. `/workspace` maps to its bound workspace; an operator-prepared -Runtime may use its exact canonical absolute workspace instead. Directory aliases, -private credentials, native history and protected initialization trees are refused. -This does not mount arbitrary host paths into the container or add a new installer. -Self-hosted public input accepts local directories, not managed Skill/Plugin archives -or hosted Templates. - -The current implementation uses the packaged Linux helpers and protected -`installed.json`, by default under `/environment/initialization/capabilities`. -Operators may freeze another root with `OAC_RUNTIME_CAPABILITY_DIRECTORY`; it must -stay separate from the workspace and private Runtime state and is not a public API -setting. Runtime snapshots sources -before native execution and supplies the resulting Skill and MCP inventory through -the same parser as managed bundles. Reconnect reuses installed contents even after -source edits; a new Session captures its own snapshot. A missing or inconsistent -snapshot fails preparation without silently reinstalling. Local discovery does not -create entries in the public API-managed installation arrays. +Core accepts absolute Unix, Windows drive and UNC source paths without checking +its own filesystem. Runtime validates them using the executor host's path syntax. +Populate these directories before first execution. They are ordinary paths visible +to that process; naming a directory does not mount it or create a sandbox. +Self-hosted public input accepts local directories, not managed Skill/Plugin +archives or hosted Templates. + +Runtime snapshots sources before native execution and uses the same parser and +`installed.json` format as managed bundles to supply Skills and Plugin MCP. +The native installer defaults the snapshot destination to `capabilities` under +`OAC_RUNTIME_HOME`; `--capability-directory` selects another local destination. +It is an operator setting, not a public API write destination. Reconnect reuses +installed contents even after source edits; a new Session captures its own +configuration. A missing or inconsistent snapshot fails preparation rather than +silently reinstalling. Local discovery does not create entries in the public +API-managed installation arrays. Snapshot file modes do not isolate the snapshot +from tools running as the same user. Closing an executor, cancelling a Turn or losing its connection preserves the -snapshot and workspace. The compute owner remains responsible for explicit resource -cleanup. This source-level capability behavior requires its own real acceptance; -[earlier qualification](../../contracts/agents-api/user-managed-runtime-v1.md) covered -only its recorded inputs and binaries, not local directory preparation or additional -platforms. +snapshot and workspace. The compute owner remains responsible for explicit +cleanup. [Historical qualification](../../contracts/agents-api/user-managed-runtime-v1.md) +records only its stated inputs and binaries; it does not qualify the current native +platforms or local capability preparation. ## Rotate or revoke @@ -144,25 +107,16 @@ platforms. | **Rotate** | The credential gets a new secret; the old secret stops working at once. Rotating a revoked credential restores it | | **Revoke** | The credential stops working at once | -After either, the Runtime disconnects and stops retrying: its log shows one message -that names the fix, and the container keeps running without a restart loop. To -reconnect the host: - -1. **Rotate** the same credential in Web and copy the new secret. -2. Rerun the same install command on the host and paste it. +To reconnect a native installation, stop it with `oac-daemon stop`, rotate the same +credential, replace the JSON at its configured credential-file path, and run +`oac-daemon start`. Keep the same `OAC_RUNTIME_HOME` for every command. Issuing a +new credential does not reconnect an Environment already bound to its first +credential; rotate that credential instead. Do not run `install` again over the +existing installation. -The installer puts the new credential into the same container, which reconnects with -its workspace and history. Issuing a *new* credential doesn't reconnect an Environment -that has already connected: it stays bound to its first credential, and the installer -says to rotate that one. - -To remove the Runtime instead, stop its container: -`docker --host unix:///var/run/docker.sock stop `. Deleting the Session does -not remove containers or volumes on the executor host. - -In an archived project, Web shows "This project is archived, so executor credentials -can't be issued or rotated." Only **Revoke** remains; a host that already holds a -credential keeps it when you rerun the command. +Stopping the daemon keeps its workspace and native history. Deleting a Session +does not remove host files. In an archived Project, credentials cannot be issued +or rotated; revocation remains available. ## Without Web @@ -185,15 +139,10 @@ credentials with `GET` before trying again. The [credential contract](../../contracts/agents-api/environment-executor-credentials.md) has every rule and error. - ## Historical executor installations -The current installer does not upgrade or adopt an executor from an older release. -It refuses when the same Environment retains state under the old -`~/.parsar/self-hosted` directory, including when another `--install-dir` is supplied. -Preserve the old container, files and native history; provision a fresh self-hosted -Session and connect its executor separately. There is no supported cross-version -continuation or automatic data deletion. - -Credential replacement for executors created by the current installer updates and -restarts the same owned container. It does not authorize adopting an older executor. +The native installer supports its current version only. It does not adopt or +upgrade an older daemon or container installation. Preserve old files, containers +and native history; use a fresh Runtime home and Session when moving versions. +Historical container-installer instructions and qualification records describe +those earlier artifacts, not acceptance of the current native daemon. diff --git a/docs/self-hosted-native.md b/docs/self-hosted-native.md new file mode 100644 index 000000000..cf8c9e364 --- /dev/null +++ b/docs/self-hosted-native.md @@ -0,0 +1,139 @@ +# Native self-hosted Runtime + +A self-hosted Runtime runs the same `oac-daemon` on Linux, macOS or Windows. +Core uses the same Environment protocol on all three platforms; native harness +adapters handle harness differences. Core-managed Providers remain Linux-only. + +The daemon runs tools with the permissions of the user who starts it. It adds no +filesystem, permission or network sandbox. Tools can access whatever that user +can access, including Runtime state and credentials. Authentication, ordinary +credential storage, atomic state writes and process cleanup do not isolate tools +from the host. When isolation is needed, run the Runtime inside an outer sandbox; +Core creates that outer boundary for managed Docker/E2B/microsandbox Environments. +The daemon does not enforce `disabled` or `restricted` network modes; combinations +without corresponding outer enforcement are unsupported. + +## Prerequisites and validation status + +Use a current-version daemon binary for the host's OS and architecture, and install +the native harness selected by the Session. The daemon installer saves local +configuration; it does not install the harness, create a container, or register an +OS service. Native harness dependencies must already be available to its process. +Claude on Windows requires Git Bash. The MiniMax adapter does not currently support +Windows. + +| Platform | Current validation status | +| --- | --- | +| Linux | Existing Runtime platform; the removal of inner sandboxing needs the current change's focused checks | +| macOS | Native daemon lifecycle and Files tests passed with the race detector; native harness validation is in progress | +| Windows | Native CI build and automated execution tests are required and have not yet passed for this change; cross-compilation is not acceptance, and no manual Windows acceptance is recorded | + +These are validation results for the current implementation work, not a claim that +every harness/model combination has passed on every platform. + +Create a `self_hosted` Session with its model provider and an existing absolute +`workspace_directory` on the executor host. Obtain its `environment.id` and +`environment.remote_url`, then have the Core administrator issue an +[executor credential](getting-started/self-hosted.md#without-web). Save the returned +JSON as a private local file. It contains `key_id`, `environment_id` and +`executor_token`; do not put the token in command arguments. + +Use the exact `remote_url` from Core. It uses `wss://` outside loopback; `ws://` is +accepted only for a loopback Core. The Environment ID must be a canonical UUID. +Paths must be clean absolute paths in the host's syntax, and the workspace must +already exist. The install workspace must match the Session's configured workspace. + +## Install and start + +Choose a separate `OAC_RUNTIME_HOME` for each installation. It contains local +configuration, connection state, logs and the default capability snapshot. Keep +this same value for install, start, status, logs and stop. + +On Linux or macOS: + +```sh +export OAC_RUNTIME_HOME="$HOME/.oac/runtime-example" +mkdir -p "$HOME/agent-workspace" +chmod 600 "$HOME/executor-credential.json" +oac-daemon install \ + --remote 'wss://core.example/api/v1/agent-daemon/ws' \ + --environment-id '11111111-2222-4333-8444-555555555555' \ + --workspace "$HOME/agent-workspace" \ + --credential-file "$HOME/executor-credential.json" +oac-daemon start +``` + +On Windows, use native absolute paths in PowerShell: + +```powershell +$env:OAC_RUNTIME_HOME = "$HOME\.oac\runtime-example" +New-Item -ItemType Directory -Force "$HOME\agent-workspace" | Out-Null +oac-daemon.exe install ` + --remote 'wss://core.example/api/v1/agent-daemon/ws' ` + --environment-id '11111111-2222-4333-8444-555555555555' ` + --workspace "$HOME\agent-workspace" ` + --credential-file "$HOME\executor-credential.json" +oac-daemon.exe start +``` + +Replace the example URL and UUID with the Session's actual values. On Windows, keep +the credential file under the intended user's normal private storage permissions. +The Windows commands describe the installation interface; they do not replace the +pending native CI validation above. + +Optional install flags: + +| Flag | Meaning | +| --- | --- | +| `--capability-directory ABS` | Local destination for the installed capability snapshot; defaults to `capabilities` under `OAC_RUNTIME_HOME`. This is an operator setting, not a public API destination | +| `--tool-env-file ABS` | JSON object of string-valued tool environment variables. Use it for explicitly configured tool/MCP variables; it is not a dotenv or shell file | + +For example, a tool environment file can contain `{"EXAMPLE_SETTING":"value"}`. +Treat files containing credentials as secrets. Retain the credential and tool +configuration files at their configured paths; installation records those paths. + +`oac-daemon start --foreground` stays attached to the terminal instead of starting +in the background. An operator can use their own service manager to run it; the +native installer does not configure one. + +## Operate the same installation + +With the same `OAC_RUNTIME_HOME` still set: + +```sh +oac-daemon status +oac-daemon logs -n 100 +oac-daemon logs -f +oac-daemon stop +``` + +Use `oac-daemon.exe` for these commands on Windows. A connected Environment proves +the machine connection, not model execution: send a Turn to validate the selected +native harness and model. Stopping the daemon preserves the workspace, native +history and capability snapshot; deleting a Session does not remove host files. + +After credential rotation, stop the daemon, replace the JSON at the configured +credential path with the newly issued credential for the same `key_id`, then start +it again. Do not rerun `install` over an existing installation. Revocation prevents +the old credential from reconnecting. + +## Capabilities and versions + +`capability_directories` supplies local source directories when the Session is +created. Runtime snapshots them before execution and resolves Skills and Plugin +MCP from the common `installed.json` format used for managed bundles. Paths use the +executor host's native syntax, not Core's filesystem. Reconnect reuses the snapshot; +source edits do not silently replace it. A new Session captures new configuration. +See [local capability directories](getting-started/self-hosted.md#local-capability-directories). +Read-only snapshot modes are an integrity hint, not protection from the same user. + +Installation is current-version only. It refuses an existing installation, and +`start` refuses configuration from another daemon version. There is no in-place +upgrade, adoption of a historical container installation, or cross-version native +history migration. Preserve an old installation's files and use a fresh Runtime +home and Session when moving versions. + +Managed system-package installation remains under its existing contract while the +ordinary apt ownership decision is pending. This guide does not claim that apt has +been migrated or that a native daemon can install packages without the launching +user's required permissions. diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 11d12b547..089fda97a 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -81,6 +81,12 @@ Resource-specific boundaries: ## Executor credentials +The credential operations below also serve the native daemon on Linux, macOS and +Windows. The existing **Connect a host** download command is the Linux container +installer; use the [native installation guide](../self-hosted-native.md) for +`oac-daemon install` and lifecycle commands. Native credential rotation replaces +the configured credential file and restarts the daemon, without rerunning install. + Core issues the credentials of a self_hosted executor, and the console is where the administrator does it: the **Executor credentials** section of a Session page, shown only when the Session's environment is `self_hosted`, for diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index e499388ce..5b3da3129 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -6,9 +6,9 @@ import ( "encoding/hex" "encoding/json" "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" "io/fs" "os" - "path" "strings" "unicode/utf8" @@ -81,7 +81,7 @@ func ValidateDirectories(directories []string) error { } // ValidateLocalDirectories checks source spelling, not local access authority. -// This Linux implementation requires canonical Unix paths. The Runtime resolver +// Runtime requires canonical paths for its own operating system. The resolver // owns directory access and protected-root exclusions. func ValidateLocalDirectories(directories []string) error { if len(directories) > 50 { @@ -89,8 +89,7 @@ func ValidateLocalDirectories(directories []string) error { } seen := map[string]bool{} for _, directory := range directories { - if !path.IsAbs(directory) || len(directory) > 4096 || !utf8.ValidString(directory) || - path.Clean(directory) != directory || strings.ContainsAny(directory, "\\\x00\r\n") || seen[directory] { + if runtimefs.ValidateLocalPath(directory) != nil || len(directory) > 4096 || !utf8.ValidString(directory) || seen[directory] { return ErrInvalid } seen[directory] = true diff --git a/internal/agentcapabilities/tree.go b/internal/agentcapabilities/tree.go index 61ace6284..793261937 100644 --- a/internal/agentcapabilities/tree.go +++ b/internal/agentcapabilities/tree.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/MiniMax-AI-Dev/parsar/internal/agentbundle" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) // ReadTree stays inside an already-owned root and rejects aliases/special files. @@ -124,13 +125,12 @@ func writeFile(root *os.Root, name string, body []byte, mode fs.FileMode) error } func syncDirectory(root *os.Root, name string) error { - file, err := root.Open(name) + directory, err := root.OpenRoot(name) if err != nil { return ErrInvalid } - err = file.Sync() - closeErr := file.Close() - if err != nil || closeErr != nil { + defer directory.Close() + if runtimefs.SyncDirectory(directory) != nil { return ErrInvalid } return nil diff --git a/internal/agentdaemon/proto/environment.go b/internal/agentdaemon/proto/environment.go index 74772af6a..11712269f 100644 --- a/internal/agentdaemon/proto/environment.go +++ b/internal/agentdaemon/proto/environment.go @@ -8,6 +8,7 @@ import ( // LocalEnvironment names a frozen workspace selection. Runtime must verify it // against the bound local root before resolving capabilities or native execution. type LocalEnvironment struct { + ExecutionMode string `json:"-"` CapabilityRoot string `json:"-"` ID string `json:"id"` WorkspaceDirectory string `json:"workspace_directory"` @@ -31,6 +32,7 @@ type LocalEnvironment struct { // EnvironmentMCP is transient Runtime configuration. Do not log it: HTTP headers // and the selected user bearer may be confidential. It is not agent.tools MCP. type EnvironmentMCP struct { + ExecutionMode string `json:"-"` InstallationRoot string WorkspaceRoot string PackageRoot string diff --git a/internal/runtimefs/runtimefs.go b/internal/runtimefs/runtimefs.go new file mode 100644 index 000000000..093369431 --- /dev/null +++ b/internal/runtimefs/runtimefs.go @@ -0,0 +1,148 @@ +// Package runtimefs provides ordinary Runtime state files and process locks. +// It does not restrict the launching user or implement a sandbox. +package runtimefs + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "io" + "os" + "strings" +) + +var ErrUnsafe = errors.New("Runtime state path unavailable or invalid") + +func validName(name string) bool { + return name != "" && name != "." && name != ".." && !strings.ContainsAny(name, "/\\\x00") +} + +// ReadPrivate reads a bounded regular state file. +func ReadPrivate(root *os.Root, name string, limit int64) ([]byte, error) { + file, err := OpenPrivate(root, name, os.O_RDONLY) + if err != nil { + return nil, err + } + defer file.Close() + raw, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil || int64(len(raw)) > limit { + return nil, ErrUnsafe + } + return raw, nil +} + +// ReadPrivatePath is for startup credentials outside an already-open root. +func ReadPrivatePath(path string, limit int64) ([]byte, error) { + if ValidateLocalPath(path) != nil { + return nil, ErrUnsafe + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + return nil, ErrUnsafe + } + raw, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil || int64(len(raw)) > limit { + return nil, ErrUnsafe + } + return raw, nil +} + +// WritePrivateAtomic writes a fresh state file before replacing the name. +// An old permissive target is replaced, never opened or repaired in place. +func WritePrivateAtomic(root *os.Root, name string, data []byte) error { + if !validName(name) || PrivateDirectory(root) != nil { + return ErrUnsafe + } + var suffix [12]byte + if _, err := rand.Read(suffix[:]); err != nil { + return ErrUnsafe + } + temporary := "." + name + "-" + hex.EncodeToString(suffix[:]) + ".tmp" + file, err := OpenPrivate(root, temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL) + if err != nil { + return err + } + defer root.Remove(temporary) + _, err = file.Write(data) + if err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err != nil || closeErr != nil { + return ErrUnsafe + } + if err = replacePrivate(root, temporary, name); err != nil { + return err + } + return SyncDirectory(root) +} + +// EnsurePrivateDir creates state directories with normal account permissions. +func EnsurePrivateDir(path string) error { + if ValidateLocalPath(path) != nil { + return ErrUnsafe + } + if err := ensurePrivateDir(path); err != nil { + return err + } + root, err := os.OpenRoot(path) + if err != nil { + return err + } + defer root.Close() + return PrivateDirectory(root) +} + +// PrivateDirectory checks the storage type, not the account's access policy. +func PrivateDirectory(root *os.Root) error { + info, err := root.Stat(".") + if err != nil { + return err + } + if !info.IsDir() { + return ErrUnsafe + } + return nil +} + +// OpenPrivate uses default credential-file permissions when creating a file. +// Existing files retain the launching user's normal filesystem permissions. +func OpenPrivate(root *os.Root, name string, flags int) (*os.File, error) { + if !validName(name) { + return nil, ErrUnsafe + } + file, err := root.OpenFile(name, flags, 0600) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + file.Close() + return nil, ErrUnsafe + } + return file, nil +} + +func ensurePrivateDir(path string) error { return os.MkdirAll(path, 0700) } + +func MkdirPrivate(root *os.Root, name string) error { + if !validName(name) { + return ErrUnsafe + } + if err := root.Mkdir(name, 0700); err != nil && !errors.Is(err, os.ErrExist) { + return err + } + info, err := root.Stat(name) + if err != nil { + return err + } + if !info.IsDir() { + return ErrUnsafe + } + return nil +} diff --git a/internal/runtimefs/runtimefs_test.go b/internal/runtimefs/runtimefs_test.go new file mode 100644 index 000000000..8a25f4ac5 --- /dev/null +++ b/internal/runtimefs/runtimefs_test.go @@ -0,0 +1,117 @@ +package runtimefs + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func privateRoot(t *testing.T) (*os.Root, string) { + t.Helper() + parent, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + name := filepath.Join(parent, "private") + if err = EnsurePrivateDir(name); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(name) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { root.Close() }) + return root, name +} + +func TestPrivateAtomicWriteAndExclusiveCreate(t *testing.T) { + root, _ := privateRoot(t) + for _, body := range []string{"first", "replacement"} { + if err := WritePrivateAtomic(root, "credential.json", []byte(body)); err != nil { + t.Fatal(err) + } + got, err := ReadPrivate(root, "credential.json", 100) + if err != nil || string(got) != body { + t.Fatal("private replacement failed", err) + } + } + if _, err := ReadPrivate(root, "credential.json", 3); err == nil { + t.Fatal("oversize accepted") + } + if _, err := OpenPrivate(root, "credential.json", os.O_WRONLY|os.O_CREATE|os.O_EXCL); !errors.Is(err, os.ErrExist) { + t.Fatal("exclusive create replaced file", err) + } + for _, name := range []string{"../escape", "nested/file", ".", "", `nested\file`} { + if _, err := OpenPrivate(root, name, os.O_RDONLY); err == nil { + t.Fatal("invalid name accepted") + } + } + entries, err := os.ReadDir(root.Name()) + if err != nil || len(entries) != 1 { + t.Fatal("temporary publication left behind", err) + } +} + +func TestPrivateDirectoryLockAcrossProcesses(t *testing.T) { + root, name := privateRoot(t) + unlock, err := LockDirectory(root) + if err != nil { + t.Fatal(err) + } + held := true + defer func() { + if held { + unlock() + } + }() + child := func(expect string) { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^TestPrivateLockChild$") + cmd.Env = append(os.Environ(), "OAC_TEST_PRIVATE_LOCK="+name, "OAC_TEST_PRIVATE_LOCK_EXPECT="+expect) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("lock child failed: %v %s", err, out) + } + } + child("busy") + unlock() + held = false + child("available") +} + +func TestPrivateLockChild(t *testing.T) { + name := os.Getenv("OAC_TEST_PRIVATE_LOCK") + if name == "" { + return + } + root, err := os.OpenRoot(name) + if err != nil { + t.Fatal(err) + } + defer root.Close() + unlock, err := LockDirectory(root) + if os.Getenv("OAC_TEST_PRIVATE_LOCK_EXPECT") == "busy" { + if err == nil { + unlock() + t.Fatal("concurrent lock admitted") + } + } else { + if err != nil { + t.Fatal(err) + } + unlock() + } +} + +func TestLocalPathValidation(t *testing.T) { + for _, path := range []string{"", "relative", "a/../b", "/tmp/line\n"} { + if ValidateLocalPath(path) == nil { + t.Fatal("ambiguous path accepted") + } + } + _, name := privateRoot(t) + if err := ValidateLocalPath(name); err != nil { + t.Fatal(err) + } +} diff --git a/internal/runtimefs/runtimefs_unix.go b/internal/runtimefs/runtimefs_unix.go new file mode 100644 index 000000000..91448d0f0 --- /dev/null +++ b/internal/runtimefs/runtimefs_unix.go @@ -0,0 +1,40 @@ +//go:build unix + +package runtimefs + +import ( + "os" + "path/filepath" + "strings" + "syscall" +) + +func ValidateLocalPath(path string) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.ContainsAny(path, "\\\x00\r\n") { + return ErrUnsafe + } + return nil +} + +func LockDirectory(root *os.Root) (func(), error) { + f, err := root.Open(".") + if err != nil { + return nil, err + } + if err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + f.Close() + return nil, err + } + return func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); _ = f.Close() }, nil +} + +func SyncDirectory(root *os.Root) error { + f, err := root.Open(".") + if err != nil { + return err + } + defer f.Close() + return f.Sync() +} + +func replacePrivate(root *os.Root, from, to string) error { return root.Rename(from, to) } diff --git a/internal/runtimefs/runtimefs_unix_test.go b/internal/runtimefs/runtimefs_unix_test.go new file mode 100644 index 000000000..a9337f2ab --- /dev/null +++ b/internal/runtimefs/runtimefs_unix_test.go @@ -0,0 +1,46 @@ +//go:build unix + +package runtimefs + +import ( + "os" + "path/filepath" + "testing" +) + +func TestUnixDefaultStatePermissionsAndUserSelectedFiles(t *testing.T) { + root, name := privateRoot(t) + if err := WritePrivateAtomic(root, "credential", []byte("value")); err != nil { + t.Fatal(err) + } + info, err := root.Stat("credential") + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("default credential mode", err) + } + if err := os.Chmod(filepath.Join(name, "credential"), 0644); err != nil { + t.Fatal(err) + } + if _, err := ReadPrivate(root, "credential", 100); err != nil { + t.Fatal("user-selected mode rejected", err) + } + linked := filepath.Join(t.TempDir(), "linked") + if err := os.Link(filepath.Join(name, "credential"), linked); err != nil { + t.Fatal(err) + } + if _, err := ReadPrivate(root, "credential", 100); err != nil { + t.Fatal("user-selected link rejected", err) + } + alias := filepath.Join(name, "alias") + if err := os.Symlink("credential", alias); err != nil { + t.Fatal(err) + } + if _, err := ReadPrivate(root, "alias", 100); err != nil { + t.Fatal("user-selected alias rejected", err) + } + if err := os.Chmod(name, 0755); err != nil { + t.Fatal(err) + } + if err := EnsurePrivateDir(name); err != nil { + t.Fatal("user-selected directory mode rejected", err) + } +} diff --git a/internal/runtimefs/runtimefs_windows.go b/internal/runtimefs/runtimefs_windows.go new file mode 100644 index 000000000..6d79718b0 --- /dev/null +++ b/internal/runtimefs/runtimefs_windows.go @@ -0,0 +1,54 @@ +package runtimefs + +import ( + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/windows" +) + +// ValidateLocalPath checks local Windows spelling, not execution authority. +func ValidateLocalPath(path string) error { + if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.HasPrefix(path, `\\?\`) || strings.HasPrefix(path, `\\.\`) || strings.ContainsAny(path, "\x00\r\n*?\"<>|") { + return ErrUnsafe + } + tail := strings.TrimPrefix(path, filepath.VolumeName(path)) + for _, part := range strings.FieldsFunc(tail, func(r rune) bool { return r == '/' || r == '\\' }) { + if strings.Contains(part, ":") || strings.TrimRight(part, " .") != part { + return ErrUnsafe + } + } + return nil +} + +func LockDirectory(root *os.Root) (func(), error) { + // Windows cannot byte-lock a directory. A stable sidecar avoids polluting the + // capability inventory and is not removed while another process may open it. + parent, err := os.OpenRoot(filepath.Dir(root.Name())) + if err != nil { + return nil, err + } + defer parent.Close() + name := "." + filepath.Base(root.Name()) + ".runtime.lock" + f, err := OpenPrivate(parent, name, os.O_RDWR|os.O_CREATE) + if err != nil { + return nil, err + } + overlapped := new(windows.Overlapped) + if err = windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, overlapped); err != nil { + f.Close() + return nil, err + } + return func() { _ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, overlapped); _ = f.Close() }, nil +} + +// Windows has no POSIX directory fsync. The writer flushes the file before +// publication; this does not promise directory durability across power loss. +func SyncDirectory(*os.Root) error { return nil } + +func replacePrivate(root *os.Root, from, to string) error { + // Go's Windows os.Root.Rename uses handle-relative FileRenameInformation with + // ReplaceIfExists, keeping publication within the held directory. + return root.Rename(from, to) +} diff --git a/internal/runtimefs/runtimefs_windows_test.go b/internal/runtimefs/runtimefs_windows_test.go new file mode 100644 index 000000000..4fada6949 --- /dev/null +++ b/internal/runtimefs/runtimefs_windows_test.go @@ -0,0 +1,16 @@ +package runtimefs + +import "testing" + +func TestWindowsLocalPathsRejectAlternateSpellings(t *testing.T) { + for _, path := range []string{`C:\private\file:stream`, `C:\private\name.`, `C:\private\name `, `\\?\C:\private`, `\\.\pipe\name`, `C:relative`} { + if ValidateLocalPath(path) == nil { + t.Fatalf("unsafe Windows path accepted: %q", path) + } + } + for _, path := range []string{`C:\private\root`, `\\server\share\private`} { + if err := ValidateLocalPath(path); err != nil { + t.Fatal("native path rejected", err) + } + } +} diff --git a/packages/claude-sdk-adapter/SUBAGENTS.md b/packages/claude-sdk-adapter/SUBAGENTS.md index 40e37d9f0..3514af3ab 100644 --- a/packages/claude-sdk-adapter/SUBAGENTS.md +++ b/packages/claude-sdk-adapter/SUBAGENTS.md @@ -32,23 +32,27 @@ Native background execution, alternate agent types, worktree isolation and per-call model overrides are not admitted in this profile. -Workspace children use Bash under the existing WorkspaceProfile hooks and native -sandbox. They do not receive unrestricted private-file tools. The parent retains -its existing workspace tool policy. Functions and MCP with subagents are not -qualified combinations and are rejected explicitly. This does not affect the -existing functions/MCP paths without subagents. +Workspace children use native Bash with the same launching-user permissions as +the parent. The daemon and adapter add no inner filesystem, permission or network +sandbox. Workspace hooks retain their execution and event responsibilities, but +are not a private-file boundary. Tools can access Runtime state that the host user +can access. Managed isolation belongs to the outer Environment. Functions and MCP +with subagents are not qualified combinations and are rejected explicitly; this +does not affect existing functions/MCP paths without subagents. Claude on Windows +requires Git Bash; native Windows validation remains pending. -The adapter mechanism qualification uses real Kimi calls in the accepted Docker -Runtime, including two child identities, their own histories, workspace writes, -private credential/history/proc-read denial, strict concurrent admission and -same-ID continuation from a new process. These checks are distinct from the -Core public resource, pagination and isolation acceptance performed by the -integration suite. +The earlier adapter mechanism qualification is historical evidence for its tested +Docker Runtime and binaries. It used real Kimi calls for two child identities, +their histories, workspace writes, private credential/history/proc-read denial, +strict concurrent admission and same-ID continuation from a new process. Its +private-file denial results describe the former inner sandbox and are not current +behavior. They do not qualify the current bypass execution or additional native +platforms. Core public resource and pagination acceptance remain separate checks. Cancellation uses an adapter-owned effect receipt only after the existing query owner confirms native process exit. The fixed native history can end at a tool call without a cancellation result or timestamp. Each receipt is linked into -the protected native history directory atomically, without overwriting an +the native history directory atomically, without overwriting an earlier receipt, and records the child, own Turn, spawn call and confirmed effect time. Native records remain unchanged. Replay uses that same timestamp; it never obtains a new cancellation time by observing an unfinished history. Child diff --git a/packages/claude-sdk-adapter/src/mcp_environment.ts b/packages/claude-sdk-adapter/src/mcp_environment.ts index b60623d3d..4484c1674 100644 --- a/packages/claude-sdk-adapter/src/mcp_environment.ts +++ b/packages/claude-sdk-adapter/src/mcp_environment.ts @@ -1,4 +1,4 @@ -import { posix } from "node:path"; +import { isAbsolute, normalize, parse } from "node:path"; import { parseHTTPServers, type HTTPServer } from "./mcp.js"; export type StdioServer = { @@ -9,8 +9,7 @@ export type StdioServer = { }; export type EnvironmentMCPServer = HTTPServer | StdioServer; -// This private projection accepts only the Runtime-owned sandbox entry. Package -// command/env/cwd are resolved by the shared Go helper after entering isolation. +// The Runtime launcher resolves installed package identities and their commands. export function parseEnvironmentMCP(value: unknown): EnvironmentMCPServer[] | undefined { if (value === undefined) return undefined; if (!Array.isArray(value)) throw new Error("invalid_request"); @@ -18,19 +17,15 @@ export function parseEnvironmentMCP(value: unknown): EnvironmentMCPServer[] | un for (const server of value) { if (!server || typeof server !== "object" || labels.has(server.server_label)) throw new Error("invalid_request"); if ("command" in server) { - if (Object.keys(server).some(key => !["server_label", "command", "args", "allowed_tools"].includes(key)) || - typeof server.server_label !== "string" || !/^[a-zA-Z0-9_-]+$/.test(server.server_label) || - server.server_label === "functions" || server.allowed_tools !== null || server.command !== "/usr/bin/python3" || - !Array.isArray(server.args) || server.args.length !== 8 || - server.args[0] !== "-I" || server.args[1] !== "-S" || - server.args[2] !== "/usr/local/bin/oac-runtime-initialize" || server.args[3] !== "stdio" || - typeof server.args[4] !== "string" || !posix.isAbsolute(server.args[4]) || server.args[4].endsWith("/") || posix.normalize(server.args[4]) !== server.args[4] || - /[\x00-\x1f\x7f\\]/.test(server.args[4]) || - typeof server.args[5] !== "string" || !posix.isAbsolute(server.args[5]) || server.args[5].endsWith("/") || posix.normalize(server.args[5]) !== server.args[5] || - /[\x00-\x1f\x7f\\]/.test(server.args[5]) || - typeof server.args[6] !== "string" || !server.args[6] || server.args[6].startsWith("/") || - server.args[6].split("/").some((part: string) => !part || part === "." || part === "..") || - /[\x00-\x1f\x7f\\]/.test(server.args[6]) || server.args[7] !== server.server_label) throw new Error("invalid_request"); + if (Object.keys(server).some(key => !["server_label", "command", "args", "allowed_tools"].includes(key)) || + typeof server.server_label !== "string" || !/^[a-zA-Z0-9_-]+$/.test(server.server_label) || server.server_label === "functions" || server.allowed_tools !== null || + typeof server.command !== "string" || !isAbsolute(server.command) || normalize(server.command) !== server.command || + !Array.isArray(server.args) || server.args.length !== 4 || server.args[0] !== "runtime-mcp-exec" || + typeof server.args[1] !== "string" || !isAbsolute(server.args[1]) || normalize(server.args[1]) !== server.args[1] || server.args[1] === parse(server.args[1]).root || + typeof server.args[2] !== "string" || !server.args[2] || /[\\]/.test(server.args[2]) || server.args[2].split("/").some((part: string) => !part || part === "." || part === "..") || + server.args[3] !== server.server_label || [server.command, ...server.args].some((part: string) => /[\x00-\x1f\x7f]/.test(part))) throw new Error("invalid_request"); + labels.add(server.server_label); + } else { parseHTTPServers([server]); if (server.allowed_tools !== null || server.required !== undefined) throw new Error("invalid_request"); diff --git a/packages/claude-sdk-adapter/src/runtime_check.ts b/packages/claude-sdk-adapter/src/runtime_check.ts index dc6caec94..7e3ec1560 100644 --- a/packages/claude-sdk-adapter/src/runtime_check.ts +++ b/packages/claude-sdk-adapter/src/runtime_check.ts @@ -45,7 +45,7 @@ try { assert.equal(smoke.error, undefined, "bridge_unavailable"); assert.equal(smoke.status, 0, "bridge_unavailable"); assert.deepEqual(JSON.parse(smoke.stdout), { type: "error", code: "invalid_request" }); - process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 3, features: ["executor_reuse", ...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions", "workspace_structured_output"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); + process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 3, features: ["executor_reuse", ...(["linux", "darwin", "win32"].includes(process.platform) ? ["workspace_directory", "local_runtime_v1", "workspace_functions", "workspace_structured_output"] : []), "message_images", "function_result_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); } catch { // Native diagnostics can include operator environment; never forward them. process.stdout.write(JSON.stringify({ type: "runtime_unavailable" }) + "\n"); diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index 7b525d9d2..0fa9ed9d0 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -3,22 +3,19 @@ import { parseEnvironmentMCP, type EnvironmentMCPServer } from "./mcp_environmen import type { MCPProfile } from "./mcp.js"; import { parseSkills, workspaceSkills, type WorkspaceSkill } from "./workspace_skills.js"; import type { CanUseTool, HookCallback, Options } from "@anthropic-ai/claude-agent-sdk"; -import { lstatSync, realpathSync, statSync } from "node:fs"; -import { isIP } from "node:net"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { realpathSync, statSync } from "node:fs"; +import { isAbsolute, parse, resolve } from "node:path"; export type Workspace = { + execution_mode?: "native"; + tool_env?: Record; home: string; state: string; scratch: string; - protected_dirs: string[]; - dependency_path: string; env_names: string[]; skills?: WorkspaceSkill[]; capability_root?: string; mcp?: EnvironmentMCPServer[]; - tool_environment?: boolean; - system_packages?: boolean; network_access?: "enabled" | "disabled" | "restricted"; allowed_domains?: string[]; }; @@ -28,15 +25,13 @@ const environmentNames = new Set([ "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", ]); -const credentialNames = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", - "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS"]; const nativeTools = ["Bash", "Read", "Edit"]; const denial = "Tool is outside the workspace execution profile."; -const invalidPath = /[\x00-\x1f\x7f\\:*?\[\]{}()]/; -const contains = (root: string, path: string) => path === root || path.startsWith(root + "/"); +const invalidPath = /[\x00-\x1f\x7f]/; + function directory(value: unknown, canonical: boolean): string { - if (typeof value !== "string" || !isAbsolute(value) || value === "/" || invalidPath.test(value)) { + if (typeof value !== "string" || !isAbsolute(value) || value === parse(value).root || invalidPath.test(value)) { throw new Error("invalid_request"); } try { @@ -50,39 +45,23 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin if (value === undefined) return undefined; if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const config = value as Record; - if (Object.keys(config).some(key => !["home", "state", "scratch", "protected_dirs", "dependency_path", "env_names", "network_access", "allowed_domains", "tool_environment", "system_packages", "skills", "mcp", "capability_root"].includes(key)) || - (config.tool_environment !== undefined && typeof config.tool_environment !== "boolean") || - (config.system_packages !== undefined && typeof config.system_packages !== "boolean") || - (config.system_packages === true && config.tool_environment !== true) || + if (Object.keys(config).some(key => !["tool_env", "execution_mode", "home", "state", "scratch", "env_names", "network_access", "allowed_domains", "skills", "mcp", "capability_root"].includes(key)) || + (config.execution_mode !== undefined && config.execution_mode !== "native") || (config.network_access !== undefined && config.network_access !== "enabled" && config.network_access !== "disabled" && config.network_access !== "restricted") || - !Array.isArray(config.protected_dirs) || !Array.isArray(config.env_names) || - typeof config.dependency_path !== "string" || !config.dependency_path || + !Array.isArray(config.env_names) || config.env_names.some(name => typeof name !== "string" || !environmentNames.has(name)) || new Set(config.env_names).size !== config.env_names.length) throw new Error("invalid_request"); + if (config.network_access !== undefined && config.network_access !== "enabled") throw new Error("invalid_request"); + if (config.tool_env !== undefined && (!config.tool_env || typeof config.tool_env !== "object" || Array.isArray(config.tool_env) || Object.values(config.tool_env).some(value => typeof value !== "string"))) throw new Error("invalid_request"); const mcp = parseEnvironmentMCP(config.mcp); - if (config.capability_root !== undefined) { - const capabilityRoot = directory(config.capability_root, true); - if ([cwd, config.home, config.state, config.scratch].some(root => - typeof root === "string" && (contains(root, capabilityRoot) || contains(capabilityRoot, root)))) throw new Error("invalid_request"); - } + if (config.capability_root !== undefined) directory(config.capability_root, false); if ((Array.isArray(config.skills) && config.skills.length || mcp?.length) && !config.capability_root) throw new Error("invalid_request"); if (mcp?.length && config.network_access !== "enabled") throw new Error("invalid_request"); const domains = config.allowed_domains ?? []; - const hostname = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i; - if (!Array.isArray(domains) || (config.network_access === "restricted" - ? domains.length < 1 || domains.length > 100 || domains.some(host => typeof host !== "string" || !hostname.test(host) || host.trim() !== host || isIP(host) !== 0) - : domains.length !== 0)) throw new Error("invalid_request"); - const roots = [cwd, config.home, config.state, config.scratch, ...config.protected_dirs].map(path => directory(path, true)); - if (roots.some((root, index) => roots.some((other, otherIndex) => index !== otherIndex && contains(root, other)))) { - throw new Error("invalid_request"); - } - const dependencies = config.dependency_path.split(":").map(path => { - const actual = directory(path, false); - if (roots.some(root => contains(root, resolve(path)) || contains(resolve(path), root) || - contains(root, actual) || contains(actual, root))) throw new Error("invalid_request"); - return actual; - }); - return { ...config, ...(config.skills === undefined ? {} : { skills: parseSkills(config.skills) }), dependency_path: dependencies.join(":") } as Workspace; + if (!Array.isArray(domains) || domains.length !== 0) throw new Error("invalid_request"); + for(const root of [cwd,config.home,config.state,config.scratch]) directory(root,false); + return { ...config, ...(config.skills === undefined ? {} : { skills: parseSkills(config.skills) }) } as Workspace; + } export class WorkspaceProfile { @@ -95,10 +74,12 @@ export class WorkspaceProfile { if (process.env.HOME !== config.home || process.env.CLAUDE_CONFIG_DIR !== config.state || process.env.CLAUDE_CODE_PROJECT_DIR_NAME !== undefined) throw new Error("invalid_request"); const env: Record = { - PATH: config.dependency_path, HOME: config.home, TMPDIR: config.scratch, CLAUDE_CONFIG_DIR: config.state, + ...Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string,string] => entry[1] !== undefined)), + HOME: config.home, TMPDIR: config.scratch, CLAUDE_CONFIG_DIR: config.state, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_TELEMETRY: "1", DISABLE_ERROR_REPORTING: "1", DISABLE_AUTOUPDATER: "1", CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", }; + Object.assign(env, config.tool_env ?? {}); for (const name of config.env_names) { const value = process.env[name]; if (value === undefined) throw new Error("invalid_request"); @@ -108,39 +89,16 @@ export class WorkspaceProfile { if (!process.env[reference]) throw new Error("invalid_request"); env[reference] = process.env[reference]!; } - if (config.system_packages) { - env.CLAUDE_CODE_SHELL_PREFIX = "/usr/local/bin/oac-claude-shell-prefix"; - env.OAC_RUNTIME_TOOL_SCRATCH = config.scratch; - } const skills = workspaceSkills(config.skills ?? [], config.capability_root ?? ""); this.skillNames = skills?.names ?? []; const skillTools = skills ? ["Skill"] : []; - const protectedRoots = [config.home, config.state, ...config.protected_dirs]; this.options = { env, tools: [...nativeTools, ...skillTools, ...(subagents ? ["Agent", "SendMessage"] : [])], ...(skills ? { plugins: skills.paths.map(path => ({ type: "local" as const, path, skipMcpDiscovery: true })) } : {}), allowedTools: mcp?.allowed ?? [...functions], mcpServers: {}, strictMcpConfig: true, - settingSources: [], permissionMode: "default", persistSession: true, - settings: { - ...(skills ? { disableSkillShellExecution: true } : {}), - permissions: { - blockReadsOutsideWorkingDirectories: true, disableBypassPermissionsMode: "disable", - deny: [...protectedRoots, "/proc", "/sys"].flatMap(path => [ - `Read(/${path})`, `Read(/${path}/**)`, `Edit(/${path})`, `Edit(/${path}/**)`, - ]), - }, - }, - sandbox: { - enabled: true, failIfUnavailable: true, autoAllowBashIfSandboxed: false, allowUnsandboxedCommands: false, - excludedCommands: [], enableWeakerNestedSandbox: false, enableWeakerNetworkIsolation: false, - filesystem: { disabled: false, allowWrite: [cwd, config.scratch, ...(config.tool_environment ? ["/environment/packages"] : [])], denyRead: protectedRoots, - denyWrite: [...protectedRoots, ...(config.capability_root ? [config.capability_root] : []), - ...(config.system_packages ? ["/environment/packages/system"] : [])], allowRead: [] }, - credentials: { - envVars: [...new Set([...credentialNames, ...config.env_names, ...(mcp?.credentialReferences() ?? [])])].map(name => ({ name, mode: "deny" })), - files: protectedRoots.map(path => ({ path, mode: "deny" })), - }, - network: { allowedDomains: config.network_access === "enabled" ? ["*"] : config.network_access === "restricted" ? [...config.allowed_domains!] : [], strictAllowlist: true, allowAllUnixSockets: false, allowLocalBinding: false }, - }, + settingSources: [], permissionMode: "bypassPermissions", persistSession: true, + settings: {}, + sandbox: { enabled: false }, + allowDangerouslySkipPermissions: true, canUseTool: this.canUseTool, hooks: { PreToolUse: [{ hooks: [this.beforeTool] }] }, }; @@ -174,12 +132,6 @@ export class WorkspaceProfile { } if (!signal.aborted && input.hook_event_name === "PreToolUse" && (input.agent_id === undefined || this.subagents?.permitsActor(input.agent_id)) && (id === undefined || id === input.tool_use_id) && this.permits(input.tool_name, input.tool_input)) { - if (input.tool_name === "Bash" && this.config.tool_environment) { - const toolInput = input.tool_input as Record; - const quote = (text: string) => "'" + text.replaceAll("'", "'\\''") + "'"; - return { hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: { ...toolInput, - command: ". /environment/initialization/tool-env.sh && eval -- " + quote(toolInput.command as string) } } }; - } return input.tool_name !== "Read" && input.tool_name !== "Edit" ? {} : { hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: this.absoluteInput(input.tool_name, input.tool_input as Record) } }; } @@ -201,20 +153,6 @@ export class WorkspaceProfile { (input.dangerouslyDisableSandbox === undefined || input.dangerouslyDisableSandbox === false); if ((name !== "Read" && name !== "Edit") || typeof input.file_path !== "string" || !input.file_path || /[\x00-\x1f]/.test(input.file_path)) return false; - const path = resolve(this.cwd, input.file_path); - if (!contains(this.cwd, path)) return false; - let existing = path; - const missing: string[] = []; - try { - while (true) { - try { lstatSync(existing); break; } - catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT" || existing === this.cwd) return false; - missing.unshift(existing.slice(dirname(existing).length + 1)); - existing = dirname(existing); - } - } - return contains(this.cwd, join(realpathSync(existing), ...missing)); - } catch { return false; } + return true; } } diff --git a/packages/claude-sdk-adapter/src/workspace_directories.ts b/packages/claude-sdk-adapter/src/workspace_directories.ts index c36c48cbc..355bf0c0a 100644 --- a/packages/claude-sdk-adapter/src/workspace_directories.ts +++ b/packages/claude-sdk-adapter/src/workspace_directories.ts @@ -1,24 +1,21 @@ -import { constants } from "node:fs"; -import { lstat, open, opendir, type FileHandle } from "node:fs/promises"; +import { join, isAbsolute } from "node:path"; +import { lstat, opendir, stat } from "node:fs/promises"; export type WorkspaceDirectoryEntry = { name: string; kind: "file" | "directory" | "symlink" | "other"; size_bytes?: number }; export type WorkspaceDirectoryEvent = { type: "workspace_directory"; id: string } & ( { entries: WorkspaceDirectoryEntry[]; truncated: boolean } | { error: "invalid" | "busy" | "unavailable" | "uncertain" | "not_found" | "permission" } ); -const flags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW; -const anchored = (handle: FileHandle) => `/proc/self/fd/${handle.fd}`; - export class WorkspaceDirectories { - private root?: FileHandle; + private root?: string; private stopped = false; private pending?: Promise; constructor(private readonly emit: (event: WorkspaceDirectoryEvent) => Promise, private readonly abort: AbortController) {} async bind(root: string): Promise { - if (process.platform !== "linux" || this.root || this.stopped) throw new Error("directory listing unavailable"); - this.root = await open(root, flags); + if (this.root || this.stopped || !isAbsolute(root) || !(await stat(root)).isDirectory()) throw new Error("directory listing unavailable"); + this.root = root; } submit(value: Record): void { @@ -49,15 +46,8 @@ export class WorkspaceDirectories { } private async enumerate(directory: string, maxEntries: number): Promise<{ entries: WorkspaceDirectoryEntry[]; truncated: boolean }> { - const handles: FileHandle[] = []; - try { - let parent = this.root!; - for (const component of directory === "" ? [] : directory.split("/")) { - parent = await open(`${anchored(parent)}/${component}`, flags); - handles.push(parent); - } - // Node supports byte names, but its opendir typings omit the buffer encoding. - const dir = await opendir(anchored(parent), { encoding: "buffer" as BufferEncoding }); + const path = join(this.root!, ...directory.split("/")); + const dir = await opendir(path, { encoding: "buffer" as BufferEncoding }); try { const entries: WorkspaceDirectoryEntry[] = []; while (true) { @@ -69,22 +59,17 @@ export class WorkspaceDirectories { if (!Buffer.isBuffer(rawName)) throw new Error("invalid entry name"); const name = rawName.toString("utf8"); if (!Buffer.from(name).equals(rawName) || !name || /[\x00/]/.test(name) || name === "." || name === "..") throw new Error("unsupported entry name"); - const stat = await lstat(`${anchored(parent)}/${name}`); + const stat = await lstat(join(path,name)); const kind = stat.isFile() ? "file" : stat.isDirectory() ? "directory" : stat.isSymbolicLink() ? "symlink" : "other"; if (kind === "file" && (!Number.isSafeInteger(stat.size) || stat.size < 0)) throw new Error("invalid file size"); entries.push({ name, kind, ...(kind === "file" ? { size_bytes: stat.size } : {}) }); } } finally { await dir.close(); } - } finally { - const closed = await Promise.allSettled(handles.reverse().map(handle => handle.close())); - if (closed.some(result => result.status === "rejected")) throw new Error("directory close failed"); - } } async close(): Promise { this.stopped = true; await this.pending; - await this.root?.close(); this.root = undefined; } } diff --git a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs index d037130c4..c765b4872 100644 --- a/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/mcp_workspace.test.mjs @@ -8,8 +8,8 @@ import { parseEnvironmentMCP } from "../dist/mcp_environment.js"; import { immediateInput, parseStart } from "../dist/request.js"; import { WorkspaceProfile } from "../dist/workspace.js"; -const stdio = { server_label: "installed", command: "/usr/bin/python3", allowed_tools: null, - args: ["-I", "-S", "/usr/local/bin/oac-runtime-initialize", "stdio", "/private/runtime/capabilities", "/private/runtime/workspace", "plugins/installed", "installed"] }; +const stdio = { server_label: "installed", command: process.execPath, allowed_tools: null, + args: ["runtime-mcp-exec", join(process.cwd(), "capabilities"), "plugins/installed", "installed"] }; const native = "mcp__installed__echo_v1"; const statuses = [{ name: "installed", status: "connected", tools: [{ name: "echo.v1" }] }]; const baseline = ["Bash", "Read", "Edit"]; @@ -19,8 +19,8 @@ function fixture(t, declarations = [stdio]) { const dirs = Object.fromEntries(["work", "home", "state", "scratch", "secrets", "deps", "capabilities"].map(name => { const path = join(root, name); mkdirSync(path); return [name, path]; })); - const config = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, protected_dirs: [dirs.secrets], - capability_root: dirs.capabilities, dependency_path: dirs.deps, env_names: ["ANTHROPIC_AUTH_TOKEN"], network_access: "enabled", mcp: declarations }; + const config = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, + capability_root: dirs.capabilities, env_names: ["ANTHROPIC_AUTH_TOKEN"], network_access: "enabled", mcp: declarations }; const previous = process.env; process.env = { HOME: dirs.home, CLAUDE_CONFIG_DIR: dirs.state, ANTHROPIC_AUTH_TOKEN: "model-secret" }; t.after(() => { process.env = previous; rmSync(root, { recursive: true, force: true }); }); @@ -35,13 +35,13 @@ test("installed MCP private projection cannot launch arbitrary unsandboxed comma output_format: { type: "json_schema", schema: { type: "object" } } })), /invalid_request/); assert.equal(immediateInput(request), undefined); assert.deepEqual(parseEnvironmentMCP([stdio]), [stdio]); - for (const value of [[stdio, stdio], [{ ...stdio, command: "/bin/sh" }], [{ ...stdio, env: { TOKEN: "secret" } }], + for (const value of [[stdio, stdio], [{ ...stdio, command: "relative" }], [{ ...stdio, env: { TOKEN: "secret" } }], [{ ...stdio, args: ["-c", "untrusted"] }], [{ ...stdio, allowed_tools: ["*"] }], - [{ ...stdio, server_url: "https://example.invalid" }], [{ ...stdio, args: [...stdio.args.slice(0, 6), "../escape", "installed"] }]]) { + [{ ...stdio, server_url: "https://example.invalid" }], [{ ...stdio, args: [...stdio.args.slice(0, 2), "../escape", "installed"] }]]) { assert.throws(() => parseEnvironmentMCP(value), /invalid_request/); } - for (const index of [4, 5]) { - for (const invalid of ["/", "relative", "/tmp/../escape", "/tmp/root/", "/tmp/line\n"]) { + for (const index of [1]) { + for (const invalid of ["/", "relative", "/tmp/../escape", "/tmp/line\n"]) { const args = [...stdio.args]; args[index] = invalid; assert.throws(() => parseEnvironmentMCP([{ ...stdio, args }]), /invalid_request/); } @@ -49,7 +49,7 @@ test("installed MCP private projection cannot launch arbitrary unsandboxed comma assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...request.workspace, network_access: "disabled" } })), /invalid_request/); }); -test("combined inventory admits exact MCP identities without granting local file authority", async t => { +test("combined inventory admits exact MCP identities with host file authority", async t => { const { dirs, config } = fixture(t); const mcp = new MCPProfile([stdio], []); const workspace = new WorkspaceProfile(dirs.work, config, [], mcp); @@ -64,7 +64,7 @@ test("combined inventory admits exact MCP identities without granting local file assert.deepEqual(mcp.identities.get(native), { server: "installed", name: "echo.v1" }); assert.equal((await workspace.canUseTool(native, {}, { signal })).behavior, "allow"); assert.equal((await workspace.canUseTool("mcp__installed__undeclared", {}, { signal })).behavior, "deny"); - assert.equal((await workspace.canUseTool("Read", { file_path: join(dirs.secrets, "model.key") }, { signal })).behavior, "deny"); + assert.equal((await workspace.canUseTool("Read", { file_path: join(dirs.secrets, "model.key") }, { signal })).behavior, "allow"); assert.equal((await workspace.canUseTool("Bash", { command: "pwd", dangerouslyDisableSandbox: true }, { signal })).behavior, "deny"); assert.equal((await workspace.beforeTool({ ...input, tool_name: "Read", tool_input: { file_path: "ok.txt" } }, "call", { signal })).hookSpecificOutput.updatedInput.file_path, join(dirs.work, "ok.txt")); for (const fields of [{ session_id: "other" }, { agent_id: "child" }, { tool_name: "mcp__ambient__echo" }]) { @@ -72,8 +72,7 @@ test("combined inventory admits exact MCP identities without granting local file } assert.deepEqual(workspace.options.allowedTools, ["mcp__installed__*"]); assert.deepEqual(workspace.options.tools, baseline); - assert.ok(workspace.options.sandbox.filesystem.denyWrite.includes(dirs.capabilities)); - assert.equal(workspace.options.sandbox.allowUnsandboxedCommands, false); + assert.deepEqual(workspace.options.sandbox, {enabled:false}); mcp.close(); assert.equal((await workspace.canUseTool(native, {}, { signal })).behavior, "deny"); }); @@ -92,7 +91,7 @@ test("combined inventory rejects extra servers, tools and normalized identity co } }); -test("workspace bearer references reach native HTTP and stay denied to Bash", t => { +test("workspace bearer references reach native HTTP without leaking values into the request", t => { const reference = "OAC_RUNTIME_MCP_BEARER_ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const http = { server_label: "remote", server_url: "https://example.invalid/mcp", allowed_tools: null, bearer_token_env_var: reference }; const { dirs, config } = fixture(t, [http]); @@ -101,23 +100,23 @@ test("workspace bearer references reach native HTTP and stay denied to Bash", t const mcp = new MCPProfile([http], []); const workspace = new WorkspaceProfile(dirs.work, config, [], mcp); assert.equal(workspace.options.env[reference], "selected-user-token"); - assert.equal(workspace.options.env.OAC_RUNTIME_MCP_BEARER_UNSELECTED, undefined); + assert.equal(workspace.options.env.OAC_RUNTIME_MCP_BEARER_UNSELECTED, "other-token"); assert.deepEqual(mcp.servers.remote.headers, { Authorization: `Bearer \${${reference}}` }); - assert.ok(workspace.options.sandbox.credentials.envVars.some(entry => entry.name === reference && entry.mode === "deny")); assert.equal(JSON.stringify(mcp.servers).includes("selected-user-token"), false); delete process.env[reference]; assert.throws(() => new WorkspaceProfile(dirs.work, config, [], mcp), /invalid_request/); }); -test("MCP identity validation preserves host functions and Bash environment wrapping", async t => { +test("MCP identity validation preserves host functions and ordinary child environment", async t => { const { dirs, config } = fixture(t); const functions = ["mcp__functions__lookup"]; const mcp = new MCPProfile([stdio], functions); - const workspace = new WorkspaceProfile(dirs.work, { ...config, tool_environment: true }, functions, mcp); + const workspace = new WorkspaceProfile(dirs.work, { ...config, tool_env: { USER_VALUE: "initialized" } }, functions, mcp); workspace.verify([...baseline, native, ...functions], [...statuses, { name: "functions", status: "connected" }], "session"); const signal = new AbortController().signal; const input = { hook_event_name: "PreToolUse", session_id: "session", tool_use_id: "call", tool_name: "Bash", tool_input: { command: "printf ok" } }; - assert.match((await workspace.beforeTool(input, "call", { signal })).hookSpecificOutput.updatedInput.command, /^\. \/environment\/initialization\/tool-env.sh/); + assert.deepEqual(await workspace.beforeTool(input, "call", { signal }), {}); + assert.equal(workspace.options.env.USER_VALUE, "initialized"); assert.equal((await workspace.canUseTool(functions[0], {}, { signal })).behavior, "allow"); mcp.close(); }); diff --git a/packages/claude-sdk-adapter/tests/preparation.test.mjs b/packages/claude-sdk-adapter/tests/preparation.test.mjs index 1ae8bb3f1..8b7a4a590 100644 --- a/packages/claude-sdk-adapter/tests/preparation.test.mjs +++ b/packages/claude-sdk-adapter/tests/preparation.test.mjs @@ -20,10 +20,10 @@ registerHooks({ resolve(specifier, context, next) { globalThis.startupFixture = async ({options, initializeTimeoutMs}) => { assert.equal(initializeTimeoutMs,15000); assert.equal(options.model,"fixed-model"); - assert.equal(options.env.UNSELECTED_CANARY,undefined); + assert.equal(options.env.UNSELECTED_CANARY,"must-not-inherit"); assert.equal(options.env.HOME,process.env.HOME); assert.deepEqual(options.tools,["Bash","Read","Edit"]); - assert.equal(options.sandbox.failIfUnavailable,true); + assert.equal(options.sandbox.enabled,false); assert.equal(options.hooks.PreToolUse.length,1); const child = options.spawnClaudeCodeProcess({command:process.execPath,env:options.env,signal:options.abortController.signal, args:["-e","process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"]}); @@ -88,7 +88,7 @@ function placement() { const path = join(root, name); mkdirSync(path); return [name, path]; })); return { root, request: { type: "prepare", model: "fixed-model", system_prompt: "", cwd: dirs.workspace, - workspace: { home: dirs.home, state: dirs.state, scratch: dirs.scratch, dependency_path: dirs.deps, protected_dirs: [], env_names: [] } } }; + workspace: { home: dirs.home, state: dirs.state, scratch: dirs.scratch, env_names: [] } } }; } test("prepare validates a workspace-only immutable configuration and prompt-only Start", () => { diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs index 745b8c3e3..36f51f77a 100644 --- a/packages/claude-sdk-adapter/tests/workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -13,8 +13,8 @@ function fixture(t) { mkdirSync(path); return [name, path]; })); - const config = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, protected_dirs: [dirs.protected], - dependency_path: dirs.deps, env_names: ["ANTHROPIC_API_KEY", "HTTP_PROXY"] }; + const config = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, + env_names: ["ANTHROPIC_API_KEY", "HTTP_PROXY"] }; const request = { type: "start", input: [{ content: [{ type: "input_text", text: "fixture" }] }], model: "fixture", system_prompt: "", cwd: dirs.workspace, workspace: config }; const previous = process.env; process.env = { HOME: dirs.home, CLAUDE_CONFIG_DIR: dirs.state, ANTHROPIC_API_KEY: "fixture-secret", @@ -42,45 +42,21 @@ test("workspace is explicit, typed and rejects external MCP declarations", t => { ...ordinary, functions: [], mcp_http_servers: [] }); }); -test("workspace roots are existing canonical directories with no overlap or rule syntax", t => { - const { root, dirs, config } = fixture(t); - const alias = join(root, "alias"); - symlinkSync(dirs.home, alias); - const child = join(dirs.workspace, "child"); - mkdirSync(child); - const file = join(root, "file"); - writeFileSync(file, "fixture"); - for (const home of ["relative", "/", dirs.home + "/", dirs.home + "/../home", join(root, "missing"), - dirs.workspace, child, root, alias, file, dirs.home + "*", dirs.home + "\n"]) { - assert.throws(() => parseWorkspace({ ...config, home }, dirs.workspace), /invalid_request/); - } - assert.throws(() => parseWorkspace({ ...config, protected_dirs: [dirs.protected, dirs.protected] }, dirs.workspace), /invalid_request/); - for (const path of ["", ":" + dirs.deps, dirs.deps + ":", "relative", dirs.home, child, root]) { - assert.throws(() => parseWorkspace({ ...config, dependency_path: path }, dirs.workspace), /invalid_request/); - } - const depsAlias = join(root, "deps-alias"); - symlinkSync(dirs.deps, depsAlias); - assert.equal(parseWorkspace({ ...config, dependency_path: depsAlias }, dirs.workspace).dependency_path, dirs.deps); - assert.equal(new WorkspaceProfile(dirs.workspace, { ...config, dependency_path: depsAlias }).options.env.PATH, dirs.deps); - for (const mutable of [dirs.workspace, dirs.scratch]) { - const unsafeAlias = join(mutable, "deps-alias"); - symlinkSync(dirs.deps, unsafeAlias); - assert.throws(() => new WorkspaceProfile(dirs.workspace, { ...config, dependency_path: unsafeAlias }), /invalid_request/); - } - assert.throws(() => parseWorkspace({ ...config, dependency_path: alias }, dirs.workspace), /invalid_request/); +test("workspace paths use ordinary host directories", t => { + const {root,dirs,config}=fixture(t); + for(const home of ["relative",join(root,"missing")]) assert.throws(()=>parseWorkspace({...config,home},dirs.workspace),/invalid_request/); + const alias=join(root,"alias");symlinkSync(dirs.home,alias); + assert.equal(parseWorkspace({...config,home:alias},dirs.workspace).home,alias); + assert.equal(parseWorkspace({...config,scratch:dirs.home},dirs.workspace).scratch,dirs.home); }); test("workspace environment copies only selected refs and fixed values", t => { const { dirs, config } = fixture(t); const options = new WorkspaceProfile(dirs.workspace, config).options; - assert.deepEqual(options.env, { - PATH: dirs.deps, HOME: dirs.home, TMPDIR: dirs.scratch, CLAUDE_CONFIG_DIR: dirs.state, - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_TELEMETRY: "1", DISABLE_ERROR_REPORTING: "1", - DISABLE_AUTOUPDATER: "1", CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", - ANTHROPIC_API_KEY: "fixture-secret", HTTP_PROXY: "http://fixture-proxy", - }); - assert.ok(options.sandbox.credentials.envVars.some(entry => entry.name === "ANTHROPIC_AUTH_TOKEN" && entry.mode === "deny")); - assert.ok(options.sandbox.credentials.envVars.some(entry => entry.name === "HTTP_PROXY" && entry.mode === "deny")); + assert.equal(options.env.UNSELECTED_CANARY,"must-not-inherit"); + assert.equal(options.env.HOME,dirs.home); + assert.equal(options.env.ANTHROPIC_API_KEY,"fixture-secret"); + delete process.env.ANTHROPIC_API_KEY; assert.throws(() => new WorkspaceProfile(dirs.workspace, config), /invalid_request/); process.env.ANTHROPIC_API_KEY = "fixture-secret"; @@ -103,24 +79,12 @@ test("workspace native options keep the strict sandbox and exact native inventor assert.deepEqual(options.settingSources, []); assert.deepEqual(options.mcpServers, {}); assert.equal(options.strictMcpConfig, true); - assert.equal(options.permissionMode, "default"); + assert.equal(options.permissionMode, "bypassPermissions"); assert.equal(options.persistSession, true); - assert.equal(options.sandbox.enabled, true); - assert.equal(options.sandbox.failIfUnavailable, true); - for (const key of ["autoAllowBashIfSandboxed", "allowUnsandboxedCommands", "enableWeakerNestedSandbox", "enableWeakerNetworkIsolation"]) { - assert.equal(options.sandbox[key], false); - } - assert.deepEqual(options.sandbox.excludedCommands, []); - assert.deepEqual(options.sandbox.filesystem, { disabled: false, allowWrite: [dirs.workspace, dirs.scratch], - denyRead: [dirs.home, dirs.state, dirs.protected], denyWrite: [dirs.home, dirs.state, dirs.protected], allowRead: [] }); - assert.deepEqual(options.sandbox.network, - { allowedDomains: [], strictAllowlist: true, allowAllUnixSockets: false, allowLocalBinding: false }); - assert.equal(options.settings.permissions.blockReadsOutsideWorkingDirectories, true); - assert.equal(options.settings.permissions.disableBypassPermissionsMode, "disable"); - for (const path of [dirs.home, dirs.state, dirs.protected, "/proc", "/sys"]) { - assert.ok(options.settings.permissions.deny.includes(`Read(/${path}/**)`)); - assert.ok(options.settings.permissions.deny.includes(`Edit(/${path}/**)`)); - } + assert.equal(options.sandbox.enabled, false); + assert.deepEqual(options.sandbox, { enabled: false }); + assert.equal(options.allowDangerouslySkipPermissions, true); + assert.deepEqual(options.settings, {}); profile.verify(["Read", "Edit", "Bash"], []); for (const tools of [[], ["Bash", "Read", "Read"], ["Bash", "Read", "Write"], ["Bash", "Read", "Edit", "Agent"]]) { assert.throws(() => profile.verify(tools, []), /unexpected native workspace inventory/); @@ -178,9 +142,7 @@ test("workspace permissions and pre-tool hook reject outside paths and unsafe Ba } for (const [name, input] of [["Bash", { command: "true", run_in_background: true }], ["Bash", { command: "true", dangerouslyDisableSandbox: true }], ["Bash", { command: "true", run_in_background: "false" }], - ["Write", { file_path: "file.txt" }], ["Read", { file_path: "../protected/value" }], - ["Read", { file_path: join(dirs.home, "credentials") }], ["Edit", { file_path: "escape/new" }], - ["Read", { file_path: "dangling" }], ["Edit", { file_path: "dangling/new" }]]) { + ["Write", { file_path: "file.txt" }]]) { assert.equal((await profile.canUseTool(name, input, options)).behavior, "deny"); assert.equal((await hook(name, input)).hookSpecificOutput.permissionDecision, "deny"); } @@ -188,29 +150,10 @@ test("workspace permissions and pre-tool hook reject outside paths and unsafe Ba assert.equal((await profile.canUseTool("Bash", { command: "true" }, { ...options, signal: AbortSignal.abort() })).behavior, "deny"); }); -test("dedicated Runtime carries an explicit native network policy", t => { - const { dirs, config, request } = fixture(t); - for (const network_access of ["enabled", "disabled"]) { - const workspace = { ...config, network_access }; - const parsed = parseStart(JSON.stringify({ ...request, workspace, require_history: true })); - assert.equal(parsed.require_history, true); - const options = new WorkspaceProfile(dirs.workspace, workspace).options; - assert.deepEqual(options.sandbox.network.allowedDomains, network_access === "enabled" ? ["*"] : []); - assert.equal(options.sandbox.enableWeakerNestedSandbox, false); - assert.equal(options.sandbox.allowUnsandboxedCommands, false); - } - assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...config, network_access: "restricted" } })), /invalid_request/); - const restricted = { ...config, network_access: "restricted", allowed_domains: ["example.com", "api.example.com"] }; - const parsed = parseStart(JSON.stringify({ ...request, workspace: restricted })); - assert.deepEqual(parsed.workspace.allowed_domains, restricted.allowed_domains); - const network = new WorkspaceProfile(dirs.workspace, restricted).options.sandbox.network; - assert.deepEqual(network, { allowedDomains: restricted.allowed_domains, strictAllowlist: true, allowAllUnixSockets: false, allowLocalBinding: false }); - for (const allowed_domains of [["*"], ["*.example.com"], ["example.com:443"], ["127.0.0.1"], ["example.com\n"], []]) { - assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...restricted, allowed_domains } })), /invalid_request/); - } - assert.throws(() => parseStart(JSON.stringify({ ...request, workspace: { ...restricted, network_access: "enabled" } })), /invalid_request/); - const { workspace, ...none } = request; - assert.throws(() => parseStart(JSON.stringify({ ...none, require_history: true })), /invalid_request/); +test("Runtime rejects network isolation requests", t => { + const {dirs,config}=fixture(t); + assert.deepEqual(new WorkspaceProfile(dirs.workspace,{...config,network_access:"enabled"}).options.sandbox,{enabled:false}); + for(const network_access of ["disabled","restricted"]) assert.throws(()=>parseWorkspace({...config,network_access,allowed_domains:network_access==="restricted"?["example.com"]:[]},dirs.workspace),/invalid_request/); }); test("workspace functions retain native sandbox and exact tool authority", async t => { @@ -233,37 +176,24 @@ test("workspace functions retain native sandbox and exact tool authority", async assert.deepEqual(await profile.beforeTool(event, "call", options), {}); assert.equal((await profile.beforeTool({ ...event, tool_name: "mcp__external__lookup" }, "call", options)).hookSpecificOutput.permissionDecision, "deny"); assert.equal((await profile.canUseTool("Bash", { command: "true", dangerouslyDisableSandbox: true }, options)).behavior, "deny"); - assert.equal((await profile.canUseTool("Read", { file_path: dirs.state + "/history" }, options)).behavior, "deny"); - assert.equal(profile.options.sandbox.failIfUnavailable, true); - assert.equal(profile.options.sandbox.allowUnsandboxedCommands, false); + assert.equal((await profile.canUseTool("Read", { file_path: dirs.state + "/history" }, options)).behavior, "allow"); controller.abort(); assert.equal((await profile.canUseTool("mcp__functions__lookup", input, options)).behavior, "deny"); }); -test("initialized user env is applied inside native Bash, never SDK spawn env", async t => { - const { dirs, config } = fixture(t); - const profile = new WorkspaceProfile(dirs.workspace, { ...config, tool_environment: true }); - assert.equal(profile.options.env.PYTHONPATH, undefined); - assert.equal(profile.options.env.PATH, dirs.deps); - assert.ok(profile.options.sandbox.filesystem.allowWrite.includes("/environment/packages")); - const input = { hook_event_name: "PreToolUse", tool_name: "Bash", tool_use_id: "tool", - tool_input: { command: "printf '%s' 'quoted value'", timeout: 1000 } }; - const result = await profile.beforeTool(input, "tool", { signal: new AbortController().signal }); - assert.equal(result.hookSpecificOutput.updatedInput.timeout, 1000); - assert.equal(result.hookSpecificOutput.updatedInput.command, - ". /environment/initialization/tool-env.sh && eval -- 'printf '\\''%s'\\'' '\\''quoted value'\\'''" ); - const denied = await profile.beforeTool({ ...input, tool_input: { command: "id", dangerouslyDisableSandbox: true } }, "tool", { signal: new AbortController().signal }); - assert.equal(denied.hookSpecificOutput.permissionDecision, "deny"); +test("initialized environment is ordinary child environment on every layout", t => { + const {dirs,config}=fixture(t); + for(const execution_mode of [undefined,"native"]) { + const profile=new WorkspaceProfile(dirs.workspace,{...config,execution_mode,tool_env:{USER_VALUE:"initialized"}}); + assert.equal(profile.options.env.USER_VALUE,"initialized"); + assert.deepEqual(profile.options.sandbox,{enabled:false}); + } }); -test("installed system tools retain the native prefix boundary and existing scratch", t => { - const { dirs, config } = fixture(t); - assert.throws(() => new WorkspaceProfile(dirs.workspace, { ...config, system_packages: true }), /invalid_request/); - const profile = new WorkspaceProfile(dirs.workspace, { ...config, tool_environment: true, system_packages: true }); - assert.equal(profile.options.env.CLAUDE_CODE_SHELL_PREFIX, "/usr/local/bin/oac-claude-shell-prefix"); - assert.equal(profile.options.env.OAC_RUNTIME_TOOL_SCRATCH, dirs.scratch); - assert.equal(profile.options.env.TMPDIR, dirs.scratch); - assert.ok(profile.options.sandbox.filesystem.denyWrite.includes("/environment/packages/system")); - assert.equal(profile.options.env.PYTHONPATH, undefined); - assert.equal(profile.options.sandbox.failIfUnavailable, true); +test("host tools can access paths outside the workspace", async t => { + const {dirs,config}=fixture(t); + const profile=new WorkspaceProfile(dirs.workspace,config); + for(const file_path of ["../protected/value",join(dirs.home,"credentials")]) { + assert.equal((await profile.canUseTool("Read",{file_path},{signal:new AbortController().signal})).behavior,"allow"); + } }); diff --git a/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs b/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs index 87781e616..d9c3ae1bb 100644 --- a/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace_directories.test.mjs @@ -24,7 +24,7 @@ async function fixture(t) { } }; } -test("bounded literal metadata, missing directory, and traversal denial", { skip: process.platform !== "linux" }, async t => { +test("bounded literal metadata, missing directory, and traversal denial", async t => { const f = await fixture(t); await mkdir(join(f.workspace, "nested")); await writeFile(join(f.workspace, "binary"), Buffer.from([0, 255, 0])); @@ -41,51 +41,12 @@ test("bounded literal metadata, missing directory, and traversal denial", { skip assert.deepEqual((await f.list("nested")).entries, []); assert.equal((await f.list("", 1)).truncated, true); assert.equal((await f.list("absent")).error, "not_found"); - for (const path of ["outside", "outside/protected", "inside"]) assert.ok(["invalid", "permission"].includes((await f.list(path)).error)); + assert.deepEqual((await f.list("inside")).entries, []); for (const path of ["/", "a//b", ".", "..", "a/../b", "a\\b", "a\n"]) assert.equal((await f.list(path)).error, "invalid"); for (const limit of [0, -1, 1001, 1.1]) assert.equal((await f.list("", limit)).error, "invalid"); }); -test("frozen root survives replacement and closes descriptors", { skip: process.platform !== "linux" }, async t => { - const baseline = (await readdir("/proc/self/fd")).length; - const f = await fixture(t); - await mkdir(join(f.workspace, "nested")); - await writeFile(join(f.workspace, "nested", "owned"), "ok"); - await rename(f.workspace, join(f.root, "original")); - await mkdir(f.workspace); - await writeFile(join(f.workspace, "wrong"), "no"); - for (let i = 0; i < 20; i++) assert.deepEqual((await f.list("nested")).entries, [{ name: "owned", kind: "file", size_bytes: 2 }]); - await f.directories.close(); - assert.equal((await f.list()).error, "unavailable"); - assert.equal((await readdir("/proc/self/fd")).length, baseline); -}); - -test("concurrent parent symlink swaps never enumerate the outside target", { skip: process.platform !== "linux" }, async t => { - const f = await fixture(t); - const nested = join(f.workspace, "nested"), parked = join(f.workspace, "parked"); - await mkdir(nested); - await writeFile(join(nested, "owned"), "ok"); - await mkdir(join(f.root, "protected")); - await writeFile(join(f.root, "protected", "secret"), "outside"); - let stop = false; - const swapping = (async () => { - while (!stop) { - await rename(nested, parked); - await symlink(join(f.root, "protected"), nested); - await rm(nested); - await rename(parked, nested); - } - })(); - try { - for (let i = 0; i < 100; i++) { - const result = await f.list("nested"); - if (result.error) assert.ok(["not_found", "permission", "invalid"].includes(result.error)); - else assert.deepEqual(result.entries, [{ name: "owned", kind: "file", size_bytes: 2 }]); - } - } finally { stop = true; await swapping; } -}); - -test("literal Unicode names are preserved and undecodable names fail explicitly", { skip: process.platform !== "linux" }, async t => { +test("literal Unicode names are preserved and undecodable names fail explicitly", {skip:process.platform === "win32"}, async t => { const f = await fixture(t); await writeFile(join(f.workspace, "字\ufffd"), "ok"); assert.deepEqual((await f.list()).entries, [{ name: "字\ufffd", kind: "file", size_bytes: 2 }]); diff --git a/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs b/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs index 24a16b872..50ff156d8 100644 --- a/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace_execution.test.mjs @@ -20,7 +20,7 @@ const dirs = Object.fromEntries(["workspace", "home", "state", "scratch", "deps" })); const mode = process.argv[1]; const workspace = { home: dirs.home, state: dirs.state, scratch: dirs.scratch, - protected_dirs: [], dependency_path: dirs.deps, env_names: ["ANTHROPIC_API_KEY"] }; + env_names: ["ANTHROPIC_API_KEY"] }; const request = { type: "start", input: [{ content: [{ type: "input_text", text: "fixture" }] }], model: "fixture", system_prompt: "", cwd: dirs.workspace, workspace, ...(mode.startsWith("resume") ? { resume: "native" } : {}) }; process.env.HOME = dirs.home; @@ -40,14 +40,14 @@ globalThis.historyFixture = async (id, options) => { }; globalThis.queryFixture = ({prompt, options}) => { calls.push("query"); - assert.equal(options.env.UNSELECTED_CANARY, undefined); + assert.equal(options.env.UNSELECTED_CANARY, "must-not-inherit"); assert.equal(options.env.ANTHROPIC_API_KEY, "fixture-secret"); assert.equal(options.env.HOME, dirs.home); assert.deepEqual(options.tools, ["Bash", "Read", "Edit"]); - assert.equal(options.sandbox.failIfUnavailable, true); + assert.equal(options.sandbox.enabled, false); assert.equal(options.resume, request.resume); const child = options.spawnClaudeCodeProcess({ command: process.execPath, env: options.env, signal: abort.signal, - args: ["-e", "const assert=require('node:assert/strict');assert.equal(process.env.UNSELECTED_CANARY,undefined);assert.equal(process.env.ANTHROPIC_API_KEY,'fixture-secret');process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"] }); + args: ["-e", "const assert=require('node:assert/strict');assert.equal(process.env.UNSELECTED_CANARY,'must-not-inherit');assert.equal(process.env.ANTHROPIC_API_KEY,'fixture-secret');process.stdin.resume();process.stdin.on('end',()=>process.exit(0));"] }); return { close() { child.stdin.end(); }, async *[Symbol.asyncIterator]() { const first = (await prompt[Symbol.asyncIterator]().next()).value; yield { type: "system", subtype: "init", session_id: "native", mcp_servers: mode === "extra-mcp" ? [{name:"other",status:"connected"}] : [], diff --git a/packages/mcode-harness/bridge.mjs b/packages/mcode-harness/bridge.mjs index f843c1fb0..89b9bd3b9 100644 --- a/packages/mcode-harness/bridge.mjs +++ b/packages/mcode-harness/bridge.mjs @@ -7,13 +7,14 @@ import { ToolExecutor } from './tool-executor.mjs'; const profile = process.argv[2]; if (!profile || !isAbsolute(profile)) throw new Error('Private workspace profile is required'); +const workspace = JSON.parse(readFileSync(profile, 'utf8')).workspace; const definitions = JSON.parse(readFileSync(new URL('./dist/tools.json', import.meta.url), 'utf8')); const tools = new Map(definitions.map(tool => ['workspace_' + tool.name, tool])); const executor = new ToolExecutor(profile); const server = new Server({ name: 'oac-workspace', version: '1' }, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...tools].map(([name, tool]) => ({ ...tool, name, - description: 'Bound working directory: /workspace. ' + tool.description })), + description: 'Bound working directory: ' + workspace + '. ' + tool.description })), })); server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { const tool = tools.get(request.params.name); diff --git a/packages/mcode-harness/build-sandbox.mjs b/packages/mcode-harness/build-sandbox.mjs deleted file mode 100644 index 25deca882..000000000 --- a/packages/mcode-harness/build-sandbox.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import { build } from 'esbuild'; -import { mkdirSync, cpSync, writeFileSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -const here = dirname(fileURLToPath(import.meta.url)); -const source = process.env.MCODE_SOURCE; -if (!source) throw new Error('MCODE_SOURCE is required'); -mkdirSync(resolve(here, 'dist'), { recursive: true }); -const result = await build({ entryPoints: [resolve(here, 'sandbox-entry.ts')], - outfile: resolve(here, 'dist/sandbox.mjs'), bundle: true, platform: 'node', format: 'esm', - target: 'node22', nodePaths: [resolve(here, 'node_modules')], metafile: true, - plugins: [{ name: 'sandbox-source', setup(b) { b.onResolve({ filter: /^@sandbox\// }, () => - ({ path: resolve(source, 'third_party/sandbox-runtime/src/sandbox/sandbox-manager.ts') })); } }], - banner: { js: 'import { createRequire as srtCreateRequire } from "node:module"; const require = srtCreateRequire(import.meta.url);' }, - logLevel: 'info' }); -cpSync(resolve(source, 'third_party/sandbox-runtime/vendor/seccomp'), resolve(here, 'dist/vendor/seccomp'), { recursive: true }); -cpSync(resolve(source, 'third_party/sandbox-runtime/vendor/java-proxy-agent'), resolve(here, 'dist/vendor/java-proxy-agent'), { recursive: true }); -writeFileSync(resolve(here, 'dist/sandbox-metafile.json'), JSON.stringify(result.metafile, null, 2) + '\n'); diff --git a/packages/mcode-harness/check.mjs b/packages/mcode-harness/check.mjs index ea2d489a4..fb6fede76 100644 --- a/packages/mcode-harness/check.mjs +++ b/packages/mcode-harness/check.mjs @@ -3,16 +3,16 @@ import { accessSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; -if (process.platform !== 'linux' || process.arch !== 'x64') throw new Error('Linux x86_64 is required'); +if (!['linux', 'darwin'].includes(process.platform)) throw new Error('MiniMax native execution requires Linux or macOS'); for (const file of ['bridge.mjs', 'launch.mjs', 'tool-executor.mjs', 'dist/worker.mjs', - 'dist/sandbox.mjs', 'dist/vendor/seccomp/x64/apply-seccomp', 'subagent-snapshot.mjs', + 'subagent-snapshot.mjs', 'native/cli.js', 'native-patch.json']) accessSync(new URL(file, import.meta.url)); -for (const command of ['bwrap', 'socat', 'rg']) execFileSync('which', [command], { stdio: 'ignore' }); +for (const command of ['bash', 'rg']) execFileSync('which', [command], { stdio: 'ignore' }); const pin = JSON.parse(readFileSync(new URL('source.json', import.meta.url))); const patch = JSON.parse(readFileSync(new URL('native-patch.json', import.meta.url))); if (patch.revision !== pin.revision || !/^[a-f0-9]{64}$/.test(patch.files?.['patch-native.mjs'] ?? '') || !/^[a-f0-9]{64}$/.test(patch.files?.['native-subagent-admission.mjs'] ?? '')) throw new Error('Native admission patch provenance mismatch'); const tools = JSON.parse(execFileSync(process.execPath, - [fileURLToPath(new URL('dist/worker.mjs', import.meta.url)), '/workspace', '--describe'], { maxBuffer: 1024 * 1024 })); + [fileURLToPath(new URL('dist/worker.mjs', import.meta.url)), process.cwd(), '--describe'], { maxBuffer: 1024 * 1024 })); if (tools.map(t => t.name).sort().join(',') !== 'bash,edit,glob,grep,read,write') throw new Error('Native tool inventory mismatch'); process.stdout.write(JSON.stringify({ protocol: 1, native: pin.version, source: pin.revision }) + '\n'); diff --git a/packages/mcode-harness/launch.mjs b/packages/mcode-harness/launch.mjs index 81fb52b44..d9ad7defa 100644 --- a/packages/mcode-harness/launch.mjs +++ b/packages/mcode-harness/launch.mjs @@ -1,4 +1,3 @@ -import { SandboxManager } from './dist/sandbox.mjs'; import { spawn } from 'node:child_process'; import { readFileSync, mkdirSync } from 'node:fs'; import { isIP } from 'node:net'; @@ -6,18 +5,18 @@ import { dirname, join, isAbsolute, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const profile = JSON.parse(readFileSync(process.argv[2], 'utf8')); -if (profile.workspace !== process.argv[3] || profile.workspace !== '/workspace') +if (!isAbsolute(profile.workspace) || normalize(profile.workspace) !== profile.workspace || (process.argv[3] && profile.workspace !== process.argv[3])) throw new Error('Workspace profile does not match execution binding'); -if (!['disabled','enabled','restricted'].includes(profile.network)) throw new Error('Invalid network policy'); +if (profile.network !== 'enabled') throw new Error('Invalid network policy'); const domains=profile.allowedDomains??[]; const hostname=/^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i; if (!Array.isArray(domains) || (profile.network==='restricted' ? domains.length<1 || domains.length>100 || domains.some(host=>typeof host!=='string'||!hostname.test(host)||host.trim()!==host||isIP(host)!==0) : domains.length!==0)) throw new Error('Invalid network domains'); -const baseEnv = {PATH:'/usr/local/bin:/usr/bin:/bin',HOME:profile.scratch,TMPDIR:profile.scratch,LANG:'C.UTF-8'}; -if (profile.systemPackages) { - if (!profile.toolEnvironment) throw new Error('System packages require initialized tool configuration'); - baseEnv.OAC_RUNTIME_TOOL_SCRATCH=profile.scratch; +const baseEnv = {...process.env}; +if (profile.toolEnv !== undefined) { + if (!profile.toolEnv || typeof profile.toolEnv !== 'object' || Array.isArray(profile.toolEnv) || Object.values(profile.toolEnv).some(value=>typeof value!=='string')) throw new Error('Invalid tool environment'); + Object.assign(baseEnv,profile.toolEnv); } if (profile.capabilityRoot && (typeof profile.capabilityRoot !== 'string' || !isAbsolute(profile.capabilityRoot) || normalize(profile.capabilityRoot) !== profile.capabilityRoot || profile.capabilityRoot === '/' || /[\\\x00-\x1f\x7f]/.test(profile.capabilityRoot))) throw new Error('Invalid capability root'); if (profile.skills && !profile.capabilityRoot) throw new Error('Missing capability root'); @@ -27,19 +26,10 @@ const cancel=()=>{cancelled=true;child?.kill('SIGKILL');}; process.on('SIGTERM',cancel);process.on('SIGINT',cancel); try { mkdirSync(profile.scratch,{recursive:true}); - await SandboxManager.initialize({ - network:{allowedDomains:domains,deniedDomains:profile.network==='disabled'?['*']:[],allowAll:profile.network==='enabled',strictAllowlist:true,allowAllUnixSockets:false,allowLocalBinding:false}, - filesystem:{denyRead:profile.protectedDirs,allowRead:profile.capabilityRoot?[profile.capabilityRoot]:[],allowWrite:[profile.workspace,profile.scratch,...(profile.toolEnvironment ? ['/environment/packages'] : [])],denyWrite:[...(profile.capabilityRoot ? [profile.capabilityRoot] : []),...(profile.systemPackages ? ['/environment/packages/system'] : [])]}, - seccomp:{applyPath:join(here,'dist/vendor/seccomp/x64/apply-seccomp')}, - },undefined,false); - const quote=s=>"'"+s.replaceAll("'","'\\''")+"'"; - const command=[process.execPath,join(here,'dist/worker.mjs'),profile.workspace,...(profile.toolEnvironment ? ['--tool-environment'] : []),...(profile.systemPackages ? ['--system-packages'] : [])].map(quote).join(' '); - const wrapped=await SandboxManager.wrapWithSandbox(command,'/bin/bash',undefined,undefined,{baseEnv,sandboxTempDir:profile.scratch}); if (cancelled) throw new Error('Cancelled before workspace tool start'); - child=spawn('/bin/bash',['-c','exec '+wrapped],{cwd:profile.workspace,env:baseEnv,stdio:['pipe','pipe','pipe']}); + child=spawn(process.execPath,[join(here,'dist/worker.mjs'),profile.workspace],{cwd:profile.workspace,env:baseEnv,stdio:['pipe','pipe','pipe']}); process.stdin.pipe(child.stdin);child.stdout.pipe(process.stdout);child.stderr.pipe(process.stderr); child.stdin.on('error',()=>cancel()); const status=await new Promise((resolve,reject)=>{child.on('error',reject);child.on('close',resolve);}); process.exitCode=cancelled?1:(status??1); } catch(error) {process.stderr.write(String(error)+'\n');process.exitCode=1;} -finally {await SandboxManager.reset();} diff --git a/packages/mcode-harness/launch.test.mjs b/packages/mcode-harness/launch.test.mjs new file mode 100644 index 000000000..ea930c6f4 --- /dev/null +++ b/packages/mcode-harness/launch.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {mkdtemp,mkdir,writeFile,copyFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {ToolExecutor} from './tool-executor.mjs'; + +test('Runtime directly executes host worker with bound cwd and initialized environment',async t=>{ + const root=await mkdtemp(join(tmpdir(),'mcode-host-')); + t.after(()=>rm(root,{recursive:true,force:true})); + await mkdir(join(root,'dist')); + const workspace=join(root,'workspace');await mkdir(workspace); + const launcher=join(root,'launch.mjs');await copyFile(new URL('./launch.mjs',import.meta.url),launcher); + await writeFile(join(root,'dist','worker.mjs'),`import {readFileSync} from 'node:fs';const request=JSON.parse(readFileSync(0,'utf8'));console.log(JSON.stringify({tool_name:request.tool,text:JSON.stringify({cwd:process.cwd(),value:process.env.INITIALIZED}),content:[]}));`); + const profile=join(root,'profile.json'); + await writeFile(profile,JSON.stringify({workspace,scratch:join(root,'scratch'),network:'enabled',toolEnv:{INITIALIZED:'ordinary'}})); + const executor=new ToolExecutor(profile,launcher);t.after(()=>executor.close()); + const result=await executor.execute('bash',{}); + assert.deepEqual(JSON.parse(result.text),{cwd:workspace,value:'ordinary'}); +}); diff --git a/packages/mcode-harness/sandbox-entry.ts b/packages/mcode-harness/sandbox-entry.ts deleted file mode 100644 index 75c6242d7..000000000 --- a/packages/mcode-harness/sandbox-entry.ts +++ /dev/null @@ -1 +0,0 @@ -export { SandboxManager } from '@sandbox/sandbox-manager'; diff --git a/packages/mcode-harness/tool-executor.mjs b/packages/mcode-harness/tool-executor.mjs index db0e2e36d..9e02a9109 100644 --- a/packages/mcode-harness/tool-executor.mjs +++ b/packages/mcode-harness/tool-executor.mjs @@ -19,8 +19,8 @@ export class ToolExecutor { signal?.throwIfAborted(); const request = JSON.stringify({ tool, input }); if (Buffer.byteLength(request) > limit) throw new Error('Workspace tool input exceeds limit'); - const child = spawn(process.execPath, [this.entrypoint, this.profile, '/workspace'], { - env: { PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/tmp', LANG: 'C.UTF-8' }, + const child = spawn(process.execPath, [this.entrypoint, this.profile], { + env: process.env, stdio: ['pipe', 'pipe', 'pipe'], }); const call = { stop: () => child.kill('SIGTERM') }; diff --git a/packages/mcode-harness/worker.ts b/packages/mcode-harness/worker.ts index 29ded576a..612e6418b 100644 --- a/packages/mcode-harness/worker.ts +++ b/packages/mcode-harness/worker.ts @@ -20,22 +20,6 @@ const tool = tools.find(value => value.def.name === request.tool); if (!tool || !request.input || typeof request.input !== 'object' || Array.isArray(request.input) || !isRuntimeToolInputValid(tools, request.tool, request.input)) throw new Error('invalid tool request'); -if (process.argv[3] === '--tool-environment') { - const systemShell = process.argv.includes('--system-packages') && request.tool === 'bash'; - const env = JSON.parse(readFileSync('/environment/initialization/tool-env.json', 'utf8')); - for (const [name, value] of Object.entries(env)) { - if (typeof value !== 'string') throw new Error('invalid initialized tool environment'); - if (!systemShell) process.env[name] = value; - } - // The native Bash boundary strips native identity variables even in mode:off. - // Reapply user values in the already isolated shell without changing tools. - if (request.tool === 'bash') { - const quote = (text: string) => "'" + text.replaceAll("'", "'\\''") + "'"; - request.input.command = systemShell - ? '/usr/bin/python3 -I -S /usr/local/bin/oac-tool-root ' + quote(request.input.command) - : '. /environment/initialization/tool-env.sh && eval -- ' + quote(request.input.command); - } -} const context = { sessionId: 'worker', turnId: 'call', allowBashAutoPromotion: false, canConsumeBackgroundBashOutput: false }; try { diff --git a/scripts/build-agents-runtime.sh b/scripts/build-agents-runtime.sh index b5d66d443..e4899682b 100755 --- a/scripts/build-agents-runtime.sh +++ b/scripts/build-agents-runtime.sh @@ -34,8 +34,6 @@ trap 'rm -rf "$context"' EXIT cp "$helpers_dir/oac-codex-directory" "$helpers_dir/oac-codex-write" "$helpers_dir/oac-workspace-export" "$context/" cp "$native_dir/bin/codex" "$context/codex" cp -R "$native_dir/codex-resources" "$context/codex-resources" -cp "$repo_root/services/agents-api/deploy/codex/tool-env.py" "$context/tool-env.py" -cp "$repo_root/services/agents-api/deploy/codex/requirements.toml" "$context/requirements.toml" cp "$repo_root/services/agents-api/deploy/codex/Dockerfile" "$context/Dockerfile" cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" cp "$repo_root/services/agents-api/deploy/runtime/build-system-seed.py" "$repo_root/services/agents-api/deploy/runtime/tool-root.py" "$context/" diff --git a/scripts/build-claude-runtime.sh b/scripts/build-claude-runtime.sh index 4311ccc4b..5f81b5021 100755 --- a/scripts/build-claude-runtime.sh +++ b/scripts/build-claude-runtime.sh @@ -29,7 +29,6 @@ done cp "$repo_root/services/agents-api/deploy/claude/Dockerfile" "$context/Dockerfile" cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" cp "$repo_root/services/agents-api/deploy/runtime/build-system-seed.py" "$repo_root/services/agents-api/deploy/runtime/tool-root.py" "$context/" -cp "$repo_root/services/agents-api/deploy/claude/shell-prefix.py" "$context/" mkdir -p "$output_dir" cp -R "$context/." "$output_dir/" printf 'Claude Runtime image context: %s\n' "$output_dir" diff --git a/scripts/build-mcode-harness.sh b/scripts/build-mcode-harness.sh index 9f2bfd1de..32ea55c3f 100644 --- a/scripts/build-mcode-harness.sh +++ b/scripts/build-mcode-harness.sh @@ -34,7 +34,6 @@ test "$(node "$native/cli.js" --version)" = 0.4.12 cd "$context" npm ci --no-audit --no-fund MCODE_SOURCE="$context/upstream" node build.mjs - MCODE_SOURCE="$context/upstream" node build-sandbox.mjs node dist/worker.mjs /workspace --describe > dist/tools.json npm prune --omit=dev --no-audit --no-fund ) @@ -48,7 +47,6 @@ cp -R "$context/upstream/dist/." "$artifact/native/" cp -R "$native/node_modules" "$artifact/native/" cp "$context/upstream/.oac-native-patch.json" "$artifact/native-patch.json" cp "$context/upstream/LICENSE" "$artifact/UPSTREAM_LICENSE" -cp "$context/upstream/third_party/sandbox-runtime/LICENSE" "$artifact/SANDBOX_LICENSE" cp "$context/upstream/third_party/pi-mono/LICENSE" "$artifact/PI_LICENSE" python3 - "$artifact" "$package" <<'PY' import hashlib,json,pathlib,sys diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index 2807ba9cc..78e9411dc 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -167,11 +167,6 @@ def verify_runtime(image, daemon, helpers, source): files["/usr/local/bin/oac-runtime-initialize"] = source / "services/agents-api/deploy/runtime/initialize.py" files["/usr/local/bin/oac-tool-root"] = source / "services/agents-api/deploy/runtime/tool-root.py" environment = dict(value.split("=", 1) for value in details["Config"]["Env"] if "=" in value) - if "OAC_RUNTIME_CODEX_BIN" in environment: - files["/etc/codex/requirements.toml"] = source / "services/agents-api/deploy/codex/requirements.toml" - files["/etc/codex/tool-env.py"] = source / "services/agents-api/deploy/codex/tool-env.py" - if "OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT" in environment: - files["/usr/local/bin/oac-claude-shell-prefix"] = source / "services/agents-api/deploy/claude/shell-prefix.py" if "OAC_RUNTIME_MCODE_BIN" in environment: for name in ("launch.mjs", "bridge.mjs", "check.mjs", "tool-executor.mjs", "subagent-snapshot.mjs", "source.json"): files["/opt/mcode-harness/" + name] = source / "packages/mcode-harness" / name diff --git a/services/agents-api/deploy/claude/Dockerfile b/services/agents-api/deploy/claude/Dockerfile index 40005ecc9..22ff7cd15 100644 --- a/services/agents-api/deploy/claude/Dockerfile +++ b/services/agents-api/deploy/claude/Dockerfile @@ -8,7 +8,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY build-system-seed.py /tmp/build-system-seed.py RUN python3 -I -S /tmp/build-system-seed.py && rm /tmp/build-system-seed.py COPY --chmod=0555 tool-root.py /usr/local/bin/oac-tool-root -COPY --chmod=0555 shell-prefix.py /usr/local/bin/oac-claude-shell-prefix COPY --chmod=0555 oac-daemon oac-codex-directory oac-codex-write oac-workspace-export /usr/local/bin/ COPY claude-sdk /opt/claude-sdk COPY --chmod=0444 runtime-initialize.py /usr/local/bin/oac-runtime-initialize diff --git a/services/agents-api/deploy/claude/shell-prefix.py b/services/agents-api/deploy/claude/shell-prefix.py deleted file mode 100644 index ddf5be271..000000000 --- a/services/agents-api/deploy/claude/shell-prefix.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/python3 -I -"""Keep Claude's native Bash prefix from rewrapping the isolated MCP entry.""" -import os -import shlex -import sys - - -MCP_ENTRY = ['/usr/bin/python3', '-I', '-S', - '/usr/local/bin/oac-runtime-initialize', 'stdio'] -TOOL_ROOT = '/usr/local/bin/oac-tool-root' - - -def invocation(command): - try: - args = shlex.split(command, comments=False) - except ValueError: - args = [] - if len(args) == 7 and args[:5] == MCP_ENTRY: - # The existing isolated helper authorizes the frozen package/server. - return args - # Preserve the complete native Bash command, including its cwd receipt. - return [TOOL_ROOT, command] - - -if __name__ == '__main__': - if len(sys.argv) != 2: - sys.exit(1) - argv = invocation(sys.argv[1]) - os.execv(argv[0], argv) diff --git a/services/agents-api/deploy/claude/shell_prefix_test.py b/services/agents-api/deploy/claude/shell_prefix_test.py deleted file mode 100644 index d946d4c82..000000000 --- a/services/agents-api/deploy/claude/shell_prefix_test.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Verify the boundary between a native MCP argv and a complete Bash command.""" -from pathlib import Path -import runpy -import shlex -import unittest - - -prefix = runpy.run_path(str(Path(__file__).with_name('shell-prefix.py'))) -resolve = prefix['invocation'] -entry = prefix['MCP_ENTRY'] + ['plugins/0', 'installed'] - - -class ShellPrefixTest(unittest.TestCase): - def test_native_stdio_exec_preserves_arguments(self): - self.assertEqual(resolve(shlex.join(entry)), entry) - # Never interpret shell syntax inside quoted package/server arguments. - unusual = prefix['MCP_ENTRY'] + ['plugins/$(touch marker)', 'server;exit'] - self.assertEqual(resolve(shlex.join(unusual)), unusual) - - def test_shell_commands_remain_inside_tool_root(self): - original = shlex.join(entry) - for command in [original + ' && pwd', original + '; id', original + ' extra', - original + ' # comment', 'exec ' + original, - original.replace('/usr/bin/python3', 'python3'), - original.replace(' -I -S ', ' -I '), - 'eval ' + shlex.quote(original) + '; pwd -P > /tmp/cwd', - "printf '%s\\n' 'a && b'; exit 7", "unterminated '"]: - with self.subTest(command=command): - self.assertEqual(resolve(command), [prefix['TOOL_ROOT'], command]) - - -if __name__ == '__main__': - unittest.main() diff --git a/services/agents-api/deploy/codex/Dockerfile b/services/agents-api/deploy/codex/Dockerfile index 90de93f93..482866f2a 100644 --- a/services/agents-api/deploy/codex/Dockerfile +++ b/services/agents-api/deploy/codex/Dockerfile @@ -12,19 +12,16 @@ RUN python3 -I -S /tmp/build-system-seed.py && rm /tmp/build-system-seed.py COPY --chmod=0555 tool-root.py /usr/local/bin/oac-tool-root COPY --chmod=0555 oac-daemon oac-codex-directory oac-codex-write oac-workspace-export codex /usr/local/bin/ COPY codex-resources /usr/local/codex-resources -COPY requirements.toml /etc/codex/requirements.toml -COPY --chmod=0444 tool-env.py /etc/codex/tool-env.py -RUN chmod 0555 /etc/codex && chmod 0444 /etc/codex/requirements.toml COPY --chmod=0444 runtime-initialize.py /usr/local/bin/oac-runtime-initialize ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ - OAC_RUNTIME_CODEX_BIN=/usr/local/bin/codex OAC_RUNTIME_CODEX_PERMISSION_PROFILE=managed-workspace \ + OAC_RUNTIME_CODEX_BIN=/usr/local/bin/codex \ OAC_RUNTIME_WORKSPACE=/environment/workspace \ OAC_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/oac-codex-directory \ OAC_RUNTIME_WRITE_HELPER=/usr/local/bin/oac-codex-write \ OAC_RUNTIME_EXPORT_HELPER=/usr/local/bin/oac-workspace-export \ OAC_RUNTIME_STAGING=/environment/staging USER 1000:1000 -RUN test -r /etc/codex/requirements.toml && test "$(codex --version)" = "codex-cli 0.153.4" +RUN test "$(codex --version)" = "codex-cli 0.153.4" WORKDIR /environment/workspace ENTRYPOINT ["/usr/local/bin/oac-daemon"] CMD ["connect", "--profile", "default"] diff --git a/services/agents-api/deploy/codex/requirements.toml b/services/agents-api/deploy/codex/requirements.toml deleted file mode 100644 index a51cd9bf5..000000000 --- a/services/agents-api/deploy/codex/requirements.toml +++ /dev/null @@ -1,42 +0,0 @@ -allow_managed_hooks_only = true -default_permissions = "managed-workspace" -allowed_approval_policies = ["never"] -[allowed_permission_profiles] -managed-workspace = true -managed-workspace-enabled = true -[permissions.managed-workspace.filesystem] -":minimal" = "read" -"/environment/workspace" = "write" -"/workspace" = "write" -"/environment/initialization" = "read" -"/environment/packages" = "write" -"/environment/packages/system" = "read" -"/tmp/oac-tool-root" = "read" -[permissions.managed-workspace.network] -enabled = false -[permissions.managed-workspace-enabled] -extends = "managed-workspace" -[permissions.managed-workspace-enabled.network] -enabled = true -[permissions.filesystem] -deny_read = [ - "/environment/staging", - "/home/*/.oac/daemon/*/auth.json", - "/home/*/.oac/daemon/agent-sessions/*/config.toml", - "/home/*/.oac/daemon/agent-sessions/*/sessions", - "/home/*/.oac/daemon/agent-sessions/*/archived_sessions", -] - -[features] -hooks = true - -[hooks] -managed_dir = "/etc/codex/runtime-hooks" - -[[hooks.PreToolUse]] -matcher = "^Bash$" - -[[hooks.PreToolUse.hooks]] -type = "command" -command = "/usr/bin/python3 -I -S /etc/codex/tool-env.py" -timeout = 5 diff --git a/services/agents-api/deploy/codex/tool-env.py b/services/agents-api/deploy/codex/tool-env.py deleted file mode 100644 index b720d3461..000000000 --- a/services/agents-api/deploy/codex/tool-env.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Managed native hook: transform Bash input, never execute untrusted code.""" -import json -import os -from pathlib import Path -import shlex -import sys - -try: - if os.environ.get('OAC_RUNTIME_TOOL_ENV') != '1': - print('{}') - sys.exit(0) - value = json.load(sys.stdin) - command = value['tool_input']['command'] - if value.get('hook_event_name') != 'PreToolUse' or value.get('tool_name') != 'Bash' or not isinstance(command, str): - raise ValueError('invalid hook input') - if not Path('/environment/initialization/tool-env.sh').is_file(): - raise ValueError('missing tool environment') - # POSIX sh does not accept eval --. A leading space prevents option parsing - # while preserving the native shell, cwd and command text. - rewritten = '. /environment/initialization/tool-env.sh && eval ' + shlex.quote(' ' + command) - if os.environ.get('OAC_RUNTIME_SYSTEM_PACKAGES') == '1': - rewritten = '/usr/bin/python3 -I -S /usr/local/bin/oac-tool-root ' + shlex.quote(command) - print(json.dumps({'hookSpecificOutput': {'hookEventName': 'PreToolUse', - 'permissionDecision': 'allow', 'updatedInput': { - 'command': rewritten}}})) -except Exception: - # Native exit 2 denies the tool. Never return a partial rewrite or input. - print('Initialized tool configuration unavailable', file=sys.stderr) - sys.exit(2) diff --git a/services/agents-api/deploy/runtime/initialize.py b/services/agents-api/deploy/runtime/initialize.py index 901501885..86d3e7d54 100644 --- a/services/agents-api/deploy/runtime/initialize.py +++ b/services/agents-api/deploy/runtime/initialize.py @@ -3,14 +3,11 @@ Core owns sequencing and the completion ledger. This helper executes one bounded operation; it never retries, schedules, selects a harness or interprets templates. """ -import ctypes import json import os from pathlib import Path import re import runpy -import select -import signal import shlex import subprocess import sys @@ -21,28 +18,13 @@ ENV_FILE = CONFIG / 'tool-env.sh' MAX_INPUT = 32 * 1024 * 1024 BASE_ENV = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': '/tmp', 'LANG': 'C.UTF-8'} -DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC - - -def open_directory(parent, name): - """Do not follow user-created directory aliases outside the Runtime roots.""" - return os.open(name, DIRECTORY, dir_fd=parent) +DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC def roots(workspace=None): - root = os.open('/', DIRECTORY) - try: - environment = open_directory(root, 'environment') - finally: - os.close(root) - try: - for name in (('workspace', 'packages', 'initialization') if workspace is None else ('packages', 'initialization')): - child = open_directory(environment, name) - os.close(child) - finally: - os.close(environment) - if workspace is not None: - os.close(os.open(workspace, DIRECTORY)) + for directory in (Path(workspace) if workspace else ROOT / 'workspace', PACKAGES, CONFIG): + if not directory.is_dir(): + raise ValueError('invalid Runtime directory') def configure(env): @@ -52,23 +34,23 @@ def configure(env): for name, value in env.items() ): raise ValueError('invalid environment') - # These Runtime-owned defaults are applied inside the sandbox, never to its - # launcher. Public reserved-name validation belongs to Core. + # Public reserved-name validation belongs to Core. Only the configured + # environment and these defaults are passed to initialization commands. values = { - 'PATH': '/environment/packages/npm/bin:/environment/packages/python/bin:' + BASE_ENV['PATH'], + 'PATH': str(PACKAGES / 'npm/bin') + ':' + str(PACKAGES / 'python/bin') + ':' + BASE_ENV['PATH'], **env, - 'PYTHONPATH': '/environment/packages/python' + (':' + env['PYTHONPATH'] if env.get('PYTHONPATH') else ''), + 'PYTHONPATH': str(PACKAGES / 'python') + (':' + env['PYTHONPATH'] if env.get('PYTHONPATH') else ''), } materialized = json.dumps(values, ensure_ascii=True) script = ''.join('export ' + name + '=' + shlex.quote(value) + '\n' for name, value in sorted(values.items())) directory = os.open(CONFIG, DIRECTORY) try: - fd = os.open('tool-env.sh', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400, dir_fd=directory) + fd = os.open('tool-env.sh', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o400, dir_fd=directory) with os.fdopen(fd, 'w') as stream: stream.write(script) stream.flush() os.fsync(stream.fileno()) - fd = os.open('tool-env.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o400, dir_fd=directory) + fd = os.open('tool-env.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o400, dir_fd=directory) with os.fdopen(fd, 'w') as stream: stream.write(materialized) stream.flush() @@ -78,33 +60,20 @@ def configure(env): os.close(directory) -def sandbox(network, cwd, workspace=None): - if network not in ('enabled', 'disabled') or not isinstance(cwd, str) or not cwd.startswith('/') or '\x00' in cwd: - raise ValueError('invalid execution configuration') - if (CONFIG / 'system-root.json').exists(): - tools = runpy.run_path('/usr/local/bin/oac-tool-root') - if not tools['installed'](): - raise ValueError('system tools unavailable') - return tools['initialization_sandbox'](cwd, network, workspace=workspace or str(ROOT / 'workspace')) - # One packaging contract for every Provider/harness. No native state, daemon - # credential, staging payload or parent process is visible in this mount map. - args = ['/usr/bin/bwrap', '--unshare-user', '--unshare-pid', '--unshare-ipc', '--unshare-uts', - '--die-with-parent', '--new-session', '--cap-drop', 'ALL', '--clearenv', - '--ro-bind', '/usr', '/usr', '--symlink', 'usr/bin', '/bin', - '--symlink', 'usr/sbin', '/sbin', '--symlink', 'usr/lib', '/lib', - '--symlink', 'usr/lib64', '/lib64', '--dir', '/etc', - '--ro-bind', '/etc/resolv.conf', '/etc/resolv.conf', - '--ro-bind', '/etc/hosts', '/etc/hosts', '--ro-bind', '/etc/ssl', '/etc/ssl', - '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--dir', '/home', - '--dir', '/environment', '--bind', workspace or str(ROOT / 'workspace'), '/workspace', - '--bind', str(PACKAGES), str(PACKAGES), '--ro-bind', str(CONFIG), str(CONFIG)] - if workspace is not None and workspace != '/workspace': - args += ['--bind', workspace, workspace] - if network == 'disabled': - args += ['--unshare-net'] - for key, value in BASE_ENV.items(): - args += ['--setenv', key, value] - return args + ['--chdir', cwd, '--'] +class StepFailure(Exception): + """A launched initialization command failed; never retain its text or output.""" + + def __init__(self, exit_code): + self.exit_code = exit_code + super().__init__('initialization command failed') + + +def execution_directory(cwd, workspace): + if (not isinstance(cwd, str) or os.path.normpath(cwd) != cwd + or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in cwd) + or not (cwd == '/workspace' or cwd.startswith('/workspace/'))): + raise ValueError('invalid execution directory') + return str(Path(workspace or ROOT / 'workspace') / cwd.removeprefix('/workspace').lstrip('/')) def run(request, workspace=None, capability_root=None): @@ -115,152 +84,58 @@ def run(request, workspace=None, capability_root=None): if action == 'system': runpy.run_path('/usr/local/bin/oac-tool-root')['install'](request['packages'], workspace=workspace or str(ROOT / 'workspace')) return - args = sandbox(request['network'], request.get('cwd', '/workspace'), workspace=workspace) - expose_capabilities(args, capability_root) + if action not in ('setup', 'npm', 'python'): + raise ValueError('invalid action') + if request['network'] not in ('enabled', 'disabled'): + raise ValueError('invalid execution configuration') + cwd = execution_directory(request.get('cwd', '/workspace'), workspace) if action == 'setup': command = request['command'] if not isinstance(command, str) or not command or '\x00' in command: raise ValueError('invalid setup command') - # Source confidential values only inside isolation. eval preserves the - # shell's cwd, unlike replacing the native shell with a child wrapper. - args += ['/bin/bash', '--noprofile', '--norc', '-c', - '. /environment/initialization/tool-env.sh && eval -- "$1"', '--', command] - elif action in ('npm', 'python'): + args = ['/bin/bash', '--noprofile', '--norc', '-c', command] + else: packages = request['packages'] if not isinstance(packages, list) or not packages or any( not isinstance(package, str) or not package or '\x00' in package or package.startswith('-') for package in packages ): raise ValueError('invalid packages') - command = ['npm', 'install', '--global', '--prefix', str(PACKAGES / 'npm'), '--', *packages] + args = ['npm', 'install', '--global', '--prefix', str(PACKAGES / 'npm'), '--', *packages] if action == 'python': - command = ['/usr/bin/python3', '-m', 'pip', 'install', '--disable-pip-version-check', - '--no-input', '--target', str(PACKAGES / 'python'), '--', *packages] - args += ['/bin/bash', '--noprofile', '--norc', '-c', - '. /environment/initialization/tool-env.sh && exec "$@"', '--', *command] - else: - raise ValueError('invalid action') - # Setup/package output can contain arbitrary confidential values. The public - # completion receipt deliberately contains no child stdout/stderr or command. - subprocess.run(args, env=BASE_ENV, stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + args = ['/usr/bin/python3', '-m', 'pip', 'install', '--disable-pip-version-check', + '--no-input', '--target', str(PACKAGES / 'python'), '--', *packages] + env = {**BASE_ENV, **json.loads((CONFIG / 'tool-env.json').read_text())} + # Execution uses the launching user's permissions. The outer Environment owns + # isolation; command output is still excluded from the completion receipt. + try: + subprocess.run(args, cwd=cwd, env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) + except subprocess.CalledProcessError as error: + raise StepFailure(error.returncode) from None def failed_receipt(error): - """Report only the integer exit status of a step run inside the isolation sandbox. - - Setup commands and package managers (including apt from the system tool root) - run through bwrap. Runtime-internal helpers such as seed extraction and the - Skill writer do not, so their failures stay generic. The exception itself is - never serialized: it can contain the command, input or captured output. - """ + """Expose a command's exit status, never its command, input or output.""" receipt = {'version': 1, 'outcome': 'failed'} - code = getattr(error, 'returncode', None) + code = getattr(error, 'exit_code', None) + # The system installer retains its existing implementation until its package + # ownership is migrated; other internal helper failures remain generic. if (isinstance(error, subprocess.CalledProcessError) and isinstance(error.cmd, list) - and error.cmd[:1] == ['/usr/bin/bwrap'] and type(code) is int and 0 < code < 256): + and error.cmd[:1] == ['/usr/bin/bwrap']): + code = error.returncode + if type(code) is int and 0 < code < 256: receipt['exit_code'] = code return json.dumps(receipt, separators=(',', ':')) -def stdio_lifetime(args): - """Bind sandbox lifetime to the native process, not its transient spawn thread.""" - parent = os.getppid() - if parent <= 1: - raise ValueError('native parent unavailable') - parent_fd = os.pidfd_open(parent) - child_fd = None - child = None - try: - watched = select.poll() - watched.register(parent_fd, select.POLLIN) - if os.getppid() != parent or watched.poll(0): - raise ValueError('native parent exited') - # Keep exited children waitable until their pidfd has been acquired. - signal.signal(signal.SIGCHLD, signal.SIG_DFL) - # Bind before fork. This entry is single-threaded; the child only sets - # its death signal and checks the captured parent before exec. bwrap sets - # its own signal later, leaving a demonstrated startup gap without this. - owner = os.getpid() - prctl = ctypes.CDLL(None).prctl - prctl.argtypes = [ctypes.c_int] + [ctypes.c_ulong] * 4 - prctl.restype = ctypes.c_int - - def bind_parent(): - if prctl(1, signal.SIGKILL, 0, 0, 0) != 0 or os.getppid() != owner: - os._exit(1) - - # No protocol forwarding. bwrap's parent-death signal now targets this - # stable launcher; its PID namespace owns descendants. - child = subprocess.Popen(args, env=BASE_ENV, close_fds=True, preexec_fn=bind_parent) - child_fd = os.pidfd_open(child.pid) - watched.register(child_fd, select.POLLIN) - events = dict(watched.poll()) - if parent_fd in events: - child.kill() - child.wait() - return 1 - result = child.wait() - return result if result >= 0 else 128 - result - finally: - if child is not None: - if child.poll() is None: - child.kill() - child.wait() - if child_fd is not None: - os.close(child_fd) - os.close(parent_fd) - - -def expose_capabilities(args, capability_root): - """Expose only the Runtime-owned capability tree, including custom layouts.""" - if capability_root is None: - return - path = Path(capability_root) - if (not capability_root.startswith('/') or capability_root == '/' - or os.path.normpath(capability_root) != capability_root - or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in capability_root) - or path.resolve() != path): - raise ValueError('invalid Runtime capability directory') - # Configuration may precede the first bundle import or empty finalization. - if path.exists(): - if not path.is_dir(): - raise ValueError('invalid Runtime capability directory') - args[-1:-1] = ['--ro-bind', capability_root, capability_root] - - -def stdio(installation_root, workspace, package, server): - """Preserve the native MCP descriptors while entering the existing sandbox.""" - for value in (installation_root, workspace): - if (not isinstance(value, str) or not value.startswith('/') or value == '/' - or os.path.normpath(value) != value or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in value)): - raise ValueError('invalid Runtime directory') - path = Path(value) - if path.resolve(strict=True) != path or not path.is_dir(): - raise ValueError('invalid Runtime directory') - roots(workspace) - args = sandbox('enabled', '/workspace', workspace=workspace) - helper = '/tmp/oac-mcp-exec' - # System-package roots predate daemon installation. Mount only the fixed - # static helper, never native configuration, credentials or Runtime state. - expose_capabilities(args, installation_root) - args[-1:-1] = ['--ro-bind', '/usr/local/bin/oac-daemon', helper] - args += [helper, 'runtime-mcp-exec', installation_root, package, server] - return stdio_lifetime(args) - - def main(): workspace, capability_root = None, None if len(sys.argv) == 4 and sys.argv[1] == 'initialize': workspace, capability_root = sys.argv[2:] - elif len(sys.argv) != 1: - try: - if len(sys.argv) != 6 or sys.argv[1] != 'stdio': - raise ValueError('invalid stdio invocation') - return stdio(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]) - except Exception: - print('Environment MCP unavailable', file=sys.stderr) - return 1 try: + if len(sys.argv) != 1 and not (len(sys.argv) == 4 and sys.argv[1] == 'initialize'): + raise ValueError('invalid initialization invocation') raw = sys.stdin.buffer.read(MAX_INPUT + 1) if len(raw) > MAX_INPUT: raise ValueError('input too large') @@ -271,7 +146,7 @@ def main(): path = Path(workspace) if (not workspace.startswith('/') or workspace == '/' or os.path.normpath(workspace) != workspace or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in workspace) - or path.resolve(strict=True) != path or not path.is_dir()): + or not path.is_dir()): raise ValueError('invalid Runtime workspace') if workspace is None: roots() diff --git a/services/agents-api/deploy/runtime/initialize_receipt_test.py b/services/agents-api/deploy/runtime/initialize_receipt_test.py index 2a6b51f3d..e730838a6 100644 --- a/services/agents-api/deploy/runtime/initialize_receipt_test.py +++ b/services/agents-api/deploy/runtime/initialize_receipt_test.py @@ -1,13 +1,9 @@ -"""Host-runnable checks of the initializer's failure receipt. - -The receipt may carry only the integer exit status of a sandboxed step, never its -command, input or output. Real isolation is covered by initialize_test.py inside a -packaged Runtime; these checks need no Runtime and run with plain python3. -""" +"""Host-runnable initialization and finite-receipt checks; no packages downloaded.""" import contextlib import importlib.util import io import json +import os from pathlib import Path import subprocess import tempfile @@ -18,11 +14,9 @@ initialize = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(initialize) CANARY = 'CANARY-initializer-receipt-5d1c' -SANDBOXED = ['/usr/bin/bwrap', '--unshare-user', '--', '/bin/bash', '-c', 'echo ' + CANARY + '; exit 3'] def invoke(failure): - """Run main() with a request whose step raises failure; return (code, stdout, stderr).""" request = json.dumps({'version': 1, 'action': 'setup', 'network': 'enabled', 'command': 'echo ' + CANARY}) stdin = mock.Mock() stdin.buffer = io.BytesIO(request.encode()) @@ -34,16 +28,8 @@ def invoke(failure): return code, stdout.getvalue(), stderr.getvalue() -class FailureReceiptTest(unittest.TestCase): - def assert_receipt(self, failure, expected): - code, stdout, stderr = invoke(failure) - self.assertEqual(code, 1) - self.assertEqual(stderr, '') - self.assertEqual(json.loads(stdout), expected) - self.assertNotIn(CANARY, stdout) - self.assertNotIn('echo', stdout) - - def test_configure_does_not_create_or_change_runtime_capabilities(self): +class InitializationTest(unittest.TestCase): + def test_configure_preserves_runtime_capabilities_and_freezes_environment(self): with tempfile.TemporaryDirectory() as directory: config = Path(directory) capabilities = config / 'capabilities' @@ -51,78 +37,82 @@ def test_configure_does_not_create_or_change_runtime_capabilities(self): manifest = capabilities / 'installed.json' manifest.write_bytes(b'fixed-runtime-fixture') with mock.patch.object(initialize, 'CONFIG', config): - initialize.configure({'VALUE': 'configured'}) + initialize.configure({'VALUE': "configured'\n$(false)"}) self.assertEqual(manifest.read_bytes(), b'fixed-runtime-fixture') self.assertFalse((capabilities / 'skills').exists()) - self.assertIn('configured', (config / 'tool-env.sh').read_text()) + self.assertEqual(json.loads((config / 'tool-env.json').read_text())['VALUE'], "configured'\n$(false)") with self.assertRaises(FileExistsError): initialize.configure({'VALUE': 'changed'}) - def test_skill_is_not_a_provider_initializer_action(self): - with mock.patch.object(initialize, 'sandbox', return_value=[]), \ - mock.patch.object(initialize.subprocess, 'run') as run: + def test_skill_is_not_an_initializer_action(self): + with mock.patch.object(initialize.subprocess, 'run') as run: with self.assertRaisesRegex(ValueError, 'invalid action'): initialize.run({'action': 'skill', 'network': 'enabled'}) run.assert_not_called() - def test_stdio_forwards_and_mounts_resolved_runtime_directories(self): + def test_setup_executes_directly_with_resolved_cwd_and_configured_environment(self): with tempfile.TemporaryDirectory() as directory: - base = Path(directory).resolve() - root, workspace = base / 'capabilities', base / 'actual-work' - root.mkdir() - workspace.mkdir() - root, workspace = str(root), str(workspace) - with mock.patch.object(initialize, 'roots') as roots, \ - mock.patch.object(initialize, 'sandbox', return_value=['bwrap', '--']) as sandbox, \ - mock.patch.object(initialize, 'stdio_lifetime', return_value=0) as launch: - with mock.patch.object(initialize.sys, 'argv', ['initialize', 'stdio', root, workspace, 'plugins/0', 'server']): - self.assertEqual(initialize.main(), 0) - roots.assert_called_once_with(workspace) - sandbox.assert_called_once_with('enabled', '/workspace', workspace=workspace) - argv = launch.call_args.args[0] - self.assertEqual(argv[-5:], ['/tmp/oac-mcp-exec', 'runtime-mcp-exec', root, 'plugins/0', 'server']) - self.assertIn(['--ro-bind', root, root], [argv[i:i+3] for i in range(len(argv)-2)]) - alias = base / 'alias' - alias.symlink_to(workspace, target_is_directory=True) - for invalid in ('/', 'relative', '/tmp/../secret', workspace + '/', workspace + '\n', str(alias)): - for values in ((invalid, workspace), (root, invalid)): - with self.assertRaises(ValueError): - initialize.stdio(*values, 'plugins/0', 'server') - - def test_stdio_mounts_workspace_at_native_alias_and_physical_path(self): + base = Path(directory) + workspace, config, packages, capabilities = [base / name for name in ('work', 'config', 'packages', 'caps')] + for path in (workspace / 'sub', config, packages, capabilities): + path.mkdir(parents=True) + skill = capabilities / 'proof.sh' + skill.write_text('#!/bin/sh\nprintf skill-proof') + secret = base / 'same-user-file' + secret.write_text('ordinary-user-access') + import shlex + command = ('/bin/sh ' + shlex.quote(str(skill)) + ' > skill-result; ' + 'cat ' + shlex.quote(str(secret)) + ' > readable; ' + 'printf "%s" "$VALUE" > value; pwd > cwd; ' + 'test -z "${DAEMON_PRIVATE_CANARY+x}"') + with mock.patch.object(initialize, 'CONFIG', config), mock.patch.object(initialize, 'PACKAGES', packages), \ + mock.patch.dict(os.environ, {'DAEMON_PRIVATE_CANARY': CANARY}): + initialize.configure({'VALUE': "'\n$(false)"}) + initialize.run({'action': 'setup', 'network': 'disabled', 'cwd': '/workspace/sub', 'command': command}, + workspace=str(workspace), capability_root=str(capabilities)) + self.assertEqual((workspace / 'sub/skill-result').read_text(), 'skill-proof') + self.assertEqual((workspace / 'sub/readable').read_text(), 'ordinary-user-access') + self.assertEqual((workspace / 'sub/value').read_text(), "'\n$(false)") + self.assertEqual((workspace / 'sub/cwd').read_text(), str(workspace / 'sub') + '\n') + + def test_package_managers_use_direct_argv_and_frozen_environment(self): with tempfile.TemporaryDirectory() as directory: config = Path(directory) - workspace = str(config / 'actual-work') - with mock.patch.object(initialize, 'CONFIG', config): - args = initialize.sandbox('enabled', '/workspace', workspace=workspace) - mounts = [args[i:i+3] for i in range(len(args)-2)] - self.assertIn(['--bind', workspace, '/workspace'], mounts) - self.assertIn(['--bind', workspace, workspace], mounts) - self.assertNotIn(['--bind', '/environment/workspace', '/workspace'], mounts) - self.assertIn(['--ro-bind', str(config), str(config)], mounts) - default = initialize.sandbox('enabled', '/workspace') - self.assertIn(['--bind', '/environment/workspace', '/workspace'], - [default[i:i+3] for i in range(len(default)-2)]) - tools = initialize.runpy.run_path(str(Path(__file__).with_name('tool-root.py'))) - (config / 'system-root.json').write_text('{"version":1}') - tools['installed'] = lambda: True - with mock.patch.object(initialize.runpy, 'run_path', return_value=tools): - args = initialize.sandbox('enabled', '/workspace', workspace=workspace) - mounts = [args[i:i+3] for i in range(len(args)-2)] - self.assertIn(['--bind', workspace, '/workspace'], mounts) - self.assertIn(['--bind', workspace, workspace], mounts) - self.assertIn(['--ro-bind', '/environment/initialization', '/environment/initialization'], mounts) - self.assertIn(['--chdir', '/workspace'], [args[i:i+2] for i in range(len(args)-1)]) + (config / 'tool-env.json').write_text(json.dumps({'VALUE': CANARY})) + for action, executable in (('npm', 'npm'), ('python', '/usr/bin/python3')): + with self.subTest(action=action), mock.patch.object(initialize, 'CONFIG', config), \ + mock.patch.object(initialize.subprocess, 'run') as run: + initialize.run({'action': action, 'network': 'enabled', 'packages': ['valid-package']}, workspace=directory) + self.assertEqual(run.call_args.args[0][0], executable) + self.assertEqual(run.call_args.args[0][-2:], ['--', 'valid-package']) + self.assertEqual(run.call_args.kwargs['cwd'], directory) + self.assertEqual(run.call_args.kwargs['env']['VALUE'], CANARY) + for channel in ('stdin', 'stdout', 'stderr'): + self.assertEqual(run.call_args.kwargs[channel], subprocess.DEVNULL) + with self.assertRaises(ValueError): + initialize.run({'action': action, 'network': 'enabled', 'packages': ['--bad']}, workspace=directory) + + def test_system_still_delegates_to_existing_installer(self): + install = mock.Mock() + with mock.patch.object(initialize.runpy, 'run_path', return_value={'install': install}) as load: + initialize.run({'action': 'system', 'packages': ['jq']}, workspace='/actual/work') + load.assert_called_once_with('/usr/local/bin/oac-tool-root') + install.assert_called_once_with(['jq'], workspace='/actual/work') + + def test_cwd_uses_only_logical_workspace_paths(self): + self.assertEqual(initialize.execution_directory('/workspace/sub', '/actual'), '/actual/sub') + for invalid in ('/workspace/../outside', '/workspace/', '/elsewhere', 'relative', '/workspace/x\n'): + with self.subTest(cwd=invalid), self.assertRaises(ValueError): + initialize.execution_directory(invalid, '/actual') def test_private_initialization_uses_bound_workspace_and_empty_environment(self): with tempfile.TemporaryDirectory() as directory: - workspace = str(Path(directory).resolve()) + workspace = str(Path(directory)) stdin = mock.Mock() stdin.buffer = io.BytesIO(b'{"version":1,"action":"configure"}') with mock.patch.object(initialize.sys, 'argv', ['initialize', 'initialize', workspace, workspace + '/caps']), \ mock.patch.object(initialize.sys, 'stdin', stdin), \ - mock.patch.object(initialize, 'roots') as roots, \ - mock.patch.object(initialize, 'run') as run, \ + mock.patch.object(initialize, 'roots') as roots, mock.patch.object(initialize, 'run') as run, \ contextlib.redirect_stdout(io.StringIO()) as output: self.assertEqual(initialize.main(), 0) roots.assert_called_once_with(workspace) @@ -132,63 +122,42 @@ def test_private_initialization_uses_bound_workspace_and_empty_environment(self) initialize.run({'action': 'configure'}, workspace=workspace) configure.assert_called_once_with({}) - def test_setup_mounts_custom_capabilities_read_only(self): - with tempfile.TemporaryDirectory() as directory: - base = Path(directory).resolve() - capabilities = base / 'capabilities' - capabilities.mkdir() - for system in (False, True): - with self.subTest(system=system), \ - mock.patch.object(initialize, 'sandbox', return_value=['system-root' if system else 'bwrap', '--']), \ - mock.patch.object(initialize.subprocess, 'run') as execute: - initialize.run({'action': 'setup', 'network': 'enabled', 'command': 'true'}, - workspace=str(base / 'workspace'), capability_root=str(capabilities)) - args = execute.call_args.args[0] - self.assertIn(['--ro-bind', str(capabilities), str(capabilities)], - [args[i:i+3] for i in range(len(args)-2)]) - self.assertLess(args.index(str(capabilities)), args.index('--')) - alias = base / 'alias' - alias.symlink_to(capabilities, target_is_directory=True) - for invalid in ('relative', '/', str(alias), str(base / 'capabilities' / '..')): - with self.assertRaises(ValueError): - initialize.expose_capabilities(['bwrap', '--'], invalid) - args = ['bwrap', '--'] - initialize.expose_capabilities(args, str(base / 'not-installed')) - self.assertEqual(args, ['bwrap', '--']) - - def test_sandboxed_step_reports_only_its_exit_status(self): + def test_invalid_invocation_and_oversized_input_remain_finite(self): + for argv, data in ((['initialize', 'stdio', 'unused'], b''), (['initialize'], b'x' * 33)): + stdin = mock.Mock() + stdin.buffer = io.BytesIO(data) + with mock.patch.object(initialize.sys, 'argv', argv), mock.patch.object(initialize.sys, 'stdin', stdin), \ + mock.patch.object(initialize, 'MAX_INPUT', 32), contextlib.redirect_stdout(io.StringIO()) as output: + self.assertEqual(initialize.main(), 1) + self.assertEqual(json.loads(output.getvalue()), {'version': 1, 'outcome': 'failed'}) + + def test_step_reports_only_its_exit_status(self): for status in (1, 3, 100, 255): - failure = subprocess.CalledProcessError(status, SANDBOXED, output=CANARY.encode(), stderr=CANARY.encode()) - self.assert_receipt(failure, {'version': 1, 'outcome': 'failed', 'exit_code': status}) - # The receipt keeps its compact, stable encoding. - _, stdout, _ = invoke(subprocess.CalledProcessError(3, SANDBOXED)) - self.assertEqual(stdout, '{"version":1,"outcome":"failed","exit_code":3}\n') + code, stdout, stderr = invoke(initialize.StepFailure(status)) + self.assertEqual(code, 1) + self.assertEqual(stderr, '') + self.assertEqual(json.loads(stdout), {'version': 1, 'outcome': 'failed', 'exit_code': status}) + self.assertNotIn(CANARY, stdout) + self.assertEqual(invoke(initialize.StepFailure(3))[1], '{"version":1,"outcome":"failed","exit_code":3}\n') def test_runtime_helpers_signals_and_other_errors_stay_generic(self): - generic = {'version': 1, 'outcome': 'failed'} - for failure in ( - subprocess.CalledProcessError(2, ['/usr/bin/tar', '-xzf', CANARY]), - subprocess.CalledProcessError(1, ['/usr/local/bin/oac-codex-write', CANARY]), - subprocess.CalledProcessError(-9, SANDBOXED), - subprocess.CalledProcessError(256, SANDBOXED), - subprocess.CalledProcessError(3, ' '.join(SANDBOXED)), - subprocess.TimeoutExpired(SANDBOXED, 120, output=CANARY.encode()), - ValueError(CANARY), - OSError(CANARY), - ): - with self.subTest(failure=type(failure).__name__): - self.assert_receipt(failure, generic) - _, stdout, _ = invoke(ValueError(CANARY)) - self.assertEqual(stdout, '{"version":1,"outcome":"failed"}\n') - - def test_real_child_output_never_reaches_the_receipt(self): - # A real process failure, launched like run() with discarded output. - try: - subprocess.run(['/bin/sh', '-c', 'echo ' + CANARY + '; echo ' + CANARY + ' >&2; exit 7'], - stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) - except subprocess.CalledProcessError as error: - failure = subprocess.CalledProcessError(error.returncode, ['/usr/bin/bwrap', '--', *error.cmd]) - self.assert_receipt(failure, {'version': 1, 'outcome': 'failed', 'exit_code': 7}) + for failure in (initialize.StepFailure(-9), initialize.StepFailure(256), + subprocess.CalledProcessError(2, ['/usr/bin/tar', CANARY]), + subprocess.TimeoutExpired([CANARY], 120), ValueError(CANARY), OSError(CANARY)): + code, stdout, stderr = invoke(failure) + self.assertEqual((code, stderr), (1, '')) + self.assertEqual(json.loads(stdout), {'version': 1, 'outcome': 'failed'}) + self.assertNotIn(CANARY, stdout) + + def test_real_child_failure_has_finite_receipt(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) + (config / 'tool-env.json').write_text('{}') + with mock.patch.object(initialize, 'CONFIG', config): + with self.assertRaises(initialize.StepFailure) as failure: + initialize.run({'action': 'setup', 'network': 'enabled', + 'command': 'echo ' + CANARY + '; echo ' + CANARY + ' >&2; exit 7'}, workspace=directory) + self.assertEqual(initialize.failed_receipt(failure.exception), '{"version":1,"outcome":"failed","exit_code":7}') if __name__ == '__main__': diff --git a/services/agents-api/deploy/runtime/initialize_stdio_test.py b/services/agents-api/deploy/runtime/initialize_stdio_test.py deleted file mode 100644 index d79091bd5..000000000 --- a/services/agents-api/deploy/runtime/initialize_stdio_test.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Real stdio lifetime checks: python3 -I -S in a disposable packaged Runtime. -Requires writable /environment roots and the deployed nested-sandbox profile. -No native harness or model is involved. -""" -import json -import os, sys -from pathlib import Path -import runpy -import select, signal -import subprocess -import tempfile, threading, time - -HELPER = '/usr/local/bin/oac-runtime-initialize' -SELF = ['/usr/bin/python3', '-I', '-S', str(Path(__file__).resolve())] -WRITER = "import sys,time\nfor n in range(300):\n with open(sys.argv[1],'ab') as f: f.write(b'x')\n time.sleep(.1)\n" -SERVER = """import subprocess,sys -subprocess.Popen([sys.executable,'-I','-S','-c',sys.argv[2],sys.argv[1]], - start_new_session=True,stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) -for line in sys.stdin: - print(line.rstrip(),flush=True) -""" - -def until(check): - deadline = time.monotonic() + 5 - while time.monotonic() < deadline: - value = check() - if value: - return value - time.sleep(.01) - raise AssertionError('Process condition did not settle within five seconds') - -def exited(fd): - poll = select.poll() - poll.register(fd, select.POLLIN) - assert poll.poll(5000), 'Owned process survived' - -def close_process(fd): - if fd is not None: - try: - signal.pidfd_send_signal(fd, signal.SIGKILL) - except ProcessLookupError: - pass - os.close(fd) - -def guard(directory, mode): - runtime = runpy.run_path(HELPER) - workspace = '/workspace/' + directory.name - if mode == 'startup': - # Stop the actual forked child before its parent binding, without - # replacing Popen, prctl, exec or any production process operation. - def stop_before_binding(frame, event, arg): - if event == 'call' and frame.f_code.co_name == 'bind_parent': - sys.settrace(None) - (directory / 'stopped').write_text(str(os.getpid())) - os.kill(os.getpid(), signal.SIGSTOP) - return stop_before_binding - sys.settrace(stop_before_binding) - command = ['/bin/sh', '-c', 'echo escaped > ' + workspace + '/escaped'] - elif mode.startswith('exit'): - command = ['/bin/sh', '-c', 'exit ' + mode[4:]] - else: - command = ['/usr/bin/python3', '-I', '-S', '-c', SERVER, workspace + '/ticks', WRITER] - return runtime['stdio_lifetime'](runtime['sandbox']('enabled', '/workspace') + command) - -def native_parent(directory): - signal.signal(signal.SIGUSR1, lambda *_: sys.exit(0)) - receipt = {} - def start(): - receipt['creator_tid'] = threading.get_native_id() - child = subprocess.Popen(SELF + ['guard', str(directory), 'server']) - receipt['wrapper_pid'] = child.pid - until(lambda: (directory / 'ticks').exists()) # Exit the thread only after bwrap is active. - thread = threading.Thread(target=start) - thread.start() - thread.join() - until(lambda: not Path('/proc', str(os.getpid()), 'task', str(receipt['creator_tid'])).exists()) - (directory / 'ready').write_text(json.dumps(receipt)) - signal.pause() - -def parent_exit(directory, death): - parent = subprocess.Popen(SELF + ['parent', str(directory)], stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - wrapper_fd = None - try: - until(lambda: (directory / 'ready').exists() and (directory / 'ready').stat().st_size) - receipt = json.loads((directory / 'ready').read_text()) - wrapper_fd = os.pidfd_open(receipt['wrapper_pid']) - ticks = directory / 'ticks' - until(lambda: ticks.exists() and ticks.stat().st_size >= 3) - parent.stdin.write(b'creator-thread-exited\n') - parent.stdin.flush() - assert select.select([parent.stdout], [], [], 5)[0], 'Inherited stdout closed or stalled' - assert parent.stdout.readline() == b'creator-thread-exited\n' - before = ticks.stat().st_size - until(lambda: ticks.stat().st_size > before) - parent.send_signal(death) - assert parent.wait(timeout=5) == (0 if death == signal.SIGUSR1 else -signal.SIGKILL) - exited(wrapper_fd) - time.sleep(.2) - stopped = ticks.read_bytes() - time.sleep(.4) - assert ticks.read_bytes() == stopped, 'Detached sandbox descendant survived parent exit' - finally: - if parent.poll() is None: - parent.kill() - parent.wait(timeout=5) - close_process(wrapper_fd) - for stream in (parent.stdin, parent.stdout, parent.stderr): - stream.close() - -def startup_kill(directory): - wrapper = subprocess.Popen(SELF + ['guard', str(directory), 'startup']) - child_fd = None - try: - until(lambda: (directory / 'stopped').exists() and (directory / 'stopped').stat().st_size) - child = int((directory / 'stopped').read_text()) - child_fd = os.pidfd_open(child) - until(lambda: '\nState:\tT' in Path('/proc', str(child), 'status').read_text()) - wrapper.kill() - wrapper.wait(timeout=5) - signal.pidfd_send_signal(child_fd, signal.SIGCONT) - exited(child_fd) - assert not (directory / 'escaped').exists(), 'Child executed after launcher died before exec' - finally: - if wrapper.poll() is None: - wrapper.kill() - wrapper.wait(timeout=5) - close_process(child_fd) - -def main(): - for name in ('workspace', 'packages', 'initialization'): - Path('/environment', name).mkdir(exist_ok=True) - with tempfile.TemporaryDirectory(prefix='stdio-lifetime-', dir='/environment/workspace') as work: - directory = Path(work) - for code in (0, 7): - result = subprocess.run(SELF + ['guard', work, 'exit' + str(code)], capture_output=True, timeout=5) - assert result.returncode == code and not result.stdout, 'Immediate exit or stdout changed' - for death in (signal.SIGUSR1, signal.SIGKILL): - for name in ('ready', 'ticks'): - (directory / name).unlink(missing_ok=True) - parent_exit(directory, death) - startup_kill(directory) - print(json.dumps({'stdio_lifetime': 'passed', 'creator_thread_exit': True, - 'parent_normal_and_kill': True, 'immediate_exit': True, 'preexec_launcher_kill': True})) - -if __name__ == '__main__': - if len(sys.argv) > 1: - sys.exit(guard(Path(sys.argv[2]), sys.argv[3]) if sys.argv[1] == 'guard' else native_parent(Path(sys.argv[2]))) - main() diff --git a/services/agents-api/deploy/runtime/initialize_test.py b/services/agents-api/deploy/runtime/initialize_test.py index 722d9fb7f..2410779f2 100644 --- a/services/agents-api/deploy/runtime/initialize_test.py +++ b/services/agents-api/deploy/runtime/initialize_test.py @@ -1,7 +1,7 @@ """Real Linux initialization checks; run inside a disposable packaged Runtime. The fixture must expose writable workspace/packages/initialization roots and -support the same nested isolation as its deployed Provider. No model is mocked; +execute with the launching user's permissions. No model is mocked; these checks exercise initialization only, not public native-model acceptance. """ import json @@ -9,7 +9,6 @@ from pathlib import Path import subprocess import sys -import time HELPER = '/usr/local/bin/oac-runtime-initialize' CANARY = 'private-initialization-canary-47a8' @@ -21,7 +20,7 @@ def invoke(action, *, succeeds=True, exit_code=None, **fields): text=True, capture_output=True, timeout=120) expected = {'version': 1, 'outcome': 'completed' if succeeds else 'failed'} if exit_code is not None: - # A failed sandboxed step reports only its exit status, never its output. + # A failed initialization step reports only its exit status, never its output. expected['exit_code'] = exit_code assert result.returncode == (0 if succeeds else 1), (action, result.returncode) assert result.stderr == '', (action, 'unexpected stderr') @@ -47,67 +46,38 @@ def main(): (skill / 'SKILL.md').chmod(0o400) (skill / 'scripts/check.sh').write_text('#!/bin/sh\nprintf skill-proof') (skill / 'scripts/check.sh').chmod(0o500) - invoke('setup', command='/environment/initialization/capabilities/skills/proof/scripts/check.sh > /workspace/skill-result') + invoke('setup', command='/environment/initialization/capabilities/skills/proof/scripts/check.sh > skill-result') assert Path('/environment/workspace/skill-result').read_text() == 'skill-proof' if '--system' in sys.argv: invoke('system', packages=['jq', 'build-essential', 'libpq-dev']) invoke('system', succeeds=False, packages=['jq']) - invoke('setup', command='''set -eu -test ! -e /usr/local/bin/oac-daemon -test ! -e /usr/local/bin/oac-tool-root -test ! -e /opt/agents-runtime/system-root.tar.gz -printf '{"value":42}' | jq -e '.value == 42' -printf '#include \nint main(void){return PQlibVersion() > 0 ? 0 : 1;}\n' > /workspace/link.c -cc -I/usr/include/postgresql /workspace/link.c -lpq -o /workspace/link -/workspace/link -! touch /usr/bin/changed -! touch /environment/packages/system/usr/bin/changed -node -e 'if (1 + 1 !== 2) process.exit(1)' -''') + # The system installer currently retains its own package-root contract. + # Its native execution checks are migrated with that installer. + # Re-entry must not replace confidential configuration after any effects. invoke('configure', succeeds=False, env={'INITIALIZATION_VALUE': 'changed'}) invoke('setup', command='printf "%s" "$INITIALIZATION_VALUE" > first; printf secret; printf secret >&2') assert Path('/environment/workspace/first').read_text() == CANARY - check = '''import os, pathlib, socket -for path in ('/environment/private/credential', '/environment/staging/request', '/home/runtime/.oac'): - assert not pathlib.Path(path).exists(), path + check = '''import os, pathlib +for path in ('/environment/private/credential', '/environment/staging/request'): + assert pathlib.Path(path).read_text() == 'private-initialization-canary-47a8' assert 'DAEMON_PRIVATE_CANARY' not in os.environ assert os.environ['INITIALIZATION_VALUE'] == 'private-initialization-canary-47a8' assert os.environ['WITH_QUOTES'] == "'\\n$(false)" -for p in pathlib.Path('/proc').glob('[0-9]*/environ'): - assert b'DAEMON_PRIVATE_CANARY=' not in p.read_bytes() -for path in ('/usr/bin/untrusted', '/environment/initialization/tool-env.sh', '/environment/initialization/capabilities/skills/proof/SKILL.md'): - try: pathlib.Path(path).write_text('bad') - except OSError: pass - else: raise AssertionError(path) pathlib.Path('/environment/packages/visible').write_text('ok') -assert len(socket.if_nameindex()) == 1 ''' Path('/environment/workspace/check.py').write_text(check) - invoke('setup', network='disabled', command='/usr/bin/python3 /workspace/check.py') + invoke('setup', network='disabled', command='/usr/bin/python3 check.py') # Shell cwd is explicit and ordered effects survive between invocations. Path('/environment/workspace/sub').mkdir() - if '--system' in sys.argv: - for cwd in ['/environment/workspace', '/environment/workspace/sub']: - result = subprocess.run( - ['/usr/bin/bwrap', '--bind', '/', '/', - '--bind', '/environment/workspace', '/workspace', '--', - '/usr/bin/python3', '-I', '/usr/local/bin/oac-tool-root', - "pwd; printf '{\"value\":42}' | jq -r .value"], - cwd=cwd, capture_output=True, text=True, timeout=15) - assert result.returncode == 0, result.stderr - assert result.stdout == cwd + '\n42\n', result.stdout invoke('setup', cwd='/workspace/sub', command='test -f ../first && pwd > second') - assert Path('/environment/workspace/sub/second').read_text() == '/workspace/sub\n' + assert Path('/environment/workspace/sub/second').read_text() == '/environment/workspace/sub\n' invoke('setup', succeeds=False, exit_code=7, command='echo secret; echo secret >&2; exit 7') invoke('setup', succeeds=False, exit_code=3, command='echo "$INITIALIZATION_VALUE"; echo "$INITIALIZATION_VALUE" >&2; exit 3') - # bwrap reports its own failure to enter the missing cwd as status 1. - invoke('setup', succeeds=False, exit_code=1, cwd='/missing', command='touch /workspace/should-not-exist') + # Failure to enter the requested cwd is a generic launcher failure. + invoke('setup', succeeds=False, cwd='/workspace/missing', command='touch should-not-exist') assert not Path('/environment/workspace/should-not-exist').exists() - invoke('setup', command='setsid /bin/bash -c "sleep 2; touch /workspace/descendant" >/dev/null 2>&1 &') - time.sleep(3) - assert not Path('/environment/workspace/descendant').exists(), 'detached setup descendant survived' if '--packages' in sys.argv: # Actual public registries, not synthetic package fixtures. invoke('npm', packages=['is-number@7.0.0']) diff --git a/services/agents-api/internal/execution/support.go b/services/agents-api/internal/execution/support.go index 9982f8f81..37f68a25f 100644 --- a/services/agents-api/internal/execution/support.go +++ b/services/agents-api/internal/execution/support.go @@ -130,9 +130,6 @@ func (p Policy) engineCapabilities(peer *gateway.Session, engine string, snapsho if !caps.Preparation || !caps.LocalEnvironment || !caps.WorkspaceReadPreparation || !caps.WorkspaceOutputExport { return fail("device must advertise local preparation, workspace reads and output export") } - if !caps.LocalEnvironmentNetworkPolicy { - return fail("device must advertise local_environment_network_policy") - } } if snapshot.Environment != nil && snapshot.Environment.Type == "none" && !caps.EnvironmentNone { return fail("device must advertise environment_none") From f441745dd19e14348b0232008a6c2d0597ff1339 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:02:15 +0000 Subject: [PATCH 36/78] Measure allocation creation before background admission --- .../agents-api/internal/store/runtime_node_lifecycle_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/agents-api/internal/store/runtime_node_lifecycle_test.go b/services/agents-api/internal/store/runtime_node_lifecycle_test.go index 3ba6bdfa6..1fc086b7f 100644 --- a/services/agents-api/internal/store/runtime_node_lifecycle_test.go +++ b/services/agents-api/internal/store/runtime_node_lifecycle_test.go @@ -54,10 +54,12 @@ func TestManagedNodesIsolateBlockedProviderAndInitialization(t *testing.T) { // Same-node direct callers and the scan all consume the same one-shot // allocation receipt. A's provider or Runtime operation remains blocked. - tenant, _, env := f.session(f.nodeB, true) + // The background scan can provision as soon as the Session is saved. + // Capture the baseline before making the new Environment visible. f.provider.mu.Lock() before := f.provider.creates f.provider.mu.Unlock() + tenant, _, env := f.session(f.nodeB, true) var callers sync.WaitGroup results := make(chan store.RuntimeAllocation, 12) failures := make(chan error, 12) From 507124f722e652fbbb85c61ce6ba33fc5c49f572 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:02:19 +0000 Subject: [PATCH 37/78] Measure allocation creation before background admission --- .../agents-api/internal/store/runtime_node_lifecycle_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/agents-api/internal/store/runtime_node_lifecycle_test.go b/services/agents-api/internal/store/runtime_node_lifecycle_test.go index 3ba6bdfa6..1fc086b7f 100644 --- a/services/agents-api/internal/store/runtime_node_lifecycle_test.go +++ b/services/agents-api/internal/store/runtime_node_lifecycle_test.go @@ -54,10 +54,12 @@ func TestManagedNodesIsolateBlockedProviderAndInitialization(t *testing.T) { // Same-node direct callers and the scan all consume the same one-shot // allocation receipt. A's provider or Runtime operation remains blocked. - tenant, _, env := f.session(f.nodeB, true) + // The background scan can provision as soon as the Session is saved. + // Capture the baseline before making the new Environment visible. f.provider.mu.Lock() before := f.provider.creates f.provider.mu.Unlock() + tenant, _, env := f.session(f.nodeB, true) var callers sync.WaitGroup results := make(chan store.RuntimeAllocation, 12) failures := make(chan error, 12) From f2c89fc2fadca5acab7d0e47f4eb75c602e2a44f Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:16:52 +0000 Subject: [PATCH 38/78] refactor(runtime): share user-level preparation and remove system installation --- .github/workflows/native-daemon.yml | 23 +- CONTRIBUTING.md | 218 ++++-------- Makefile | 1 - .../internal/agent/claudesdk/local.go | 4 - .../internal/agent/claudesdk/workspace.go | 15 +- .../agent/clirunner/process_group_windows.go | 41 ++- .../agent/clirunner/process_job_windows.go | 67 ++++ .../agent/codex/permission_profile_test.go | 37 +- .../internal/agent/codex/session_plan.go | 4 + .../internal/agent/mcode/workspace.go | 10 +- .../internal/cli/native_install.go | 1 - .../internal/daemonize/fork_windows.go | 20 +- .../dispatch/workspace_directory_test.go | 9 +- .../internal/localworkspace/binding.go | 6 +- .../internal/localworkspace/capabilities.go | 2 - .../internal/localworkspace/initialization.go | 115 ++++--- .../internal/localworkspace/mcp.go | 2 - .../internal/localworkspace/native_binding.go | 10 +- .../localworkspace/runtime_initialization.go | 202 ++++++++++- .../runtime_initialization_linux.go | 174 ---------- .../runtime_initialization_linux_test.go | 194 ----------- .../runtime_initialization_other.go | 16 - .../runtime_initialization_process.go | 147 ++++++++ .../runtime_initialization_test.go | 318 ++++++++++++++++++ contracts/agents-api/core.openapi.yaml | 5 - contracts/agents-api/openapi.yaml | 81 ++--- .../agents-api/v1/environment_templates.go | 1 - contracts/agents-api/v1/environments.go | 1 - docs/self-hosted-native.md | 26 +- internal/agentdaemon/proto/environment.go | 4 - internal/agentdaemon/proto/runtime_prepare.go | 2 +- .../agentdaemon/proto/runtime_prepare_test.go | 3 +- packages/claude-sdk-adapter/src/workspace.ts | 9 +- .../tests/workspace.test.mjs | 7 +- packages/mcode-harness/launch.test.mjs | 4 +- scripts/build-agents-api.sh | 2 +- scripts/build-agents-runtime.sh | 2 - scripts/build-claude-runtime.sh | 2 - scripts/build-mcode-runtime.sh | 2 - scripts/core-distribution-manifest.py | 2 - scripts/native-harness-smoke.mjs | 116 +++++++ services/agents-api/deploy/claude/Dockerfile | 8 +- services/agents-api/deploy/claude/README.md | 26 +- services/agents-api/deploy/codex/Dockerfile | 8 +- services/agents-api/deploy/codex/README.md | 169 ++++------ services/agents-api/deploy/e2b/README.md | 16 +- .../agents-api/deploy/e2b/build-template.py | 2 +- services/agents-api/deploy/e2b/init.py | 1 - services/agents-api/deploy/mcode/Dockerfile | 8 +- services/agents-api/deploy/mcode/README.md | 26 +- .../agents-api/deploy/microsandbox/README.md | 11 +- .../deploy/runtime/build-system-seed.py | 46 --- .../agents-api/deploy/runtime/initialize.py | 165 --------- .../deploy/runtime/initialize_receipt_test.py | 164 --------- .../deploy/runtime/initialize_test.py | 93 ----- .../agents-api/deploy/runtime/tool-root.py | 118 ------- .../internal/api/environment_packages_test.go | 63 ++++ .../internal/api/environment_request.go | 7 + .../internal/api/environment_setup.go | 25 +- .../internal/api/environment_templates.go | 4 +- .../api/environment_templates_test.go | 2 +- services/agents-api/internal/api/handler.go | 2 +- .../internal/api/hosted_environment_test.go | 5 +- .../internal/api/session_template.go | 4 - .../api/session_template_composition_test.go | 10 +- .../execution/environment_placement.go | 7 +- .../execution/environment_placement_test.go | 53 +-- .../internal/execution/runtime_setup.go | 4 +- .../internal/execution/runtime_setup_test.go | 2 +- .../store/environment_packages_test.go | 39 +++ .../internal/store/environment_setup.go | 20 +- .../internal/store/environment_setup_test.go | 4 +- .../internal/store/environment_templates.go | 2 +- .../store/provisioning_failure_detail.go | 2 +- .../store/runtime_environment_terminal.go | 4 +- .../runtime_environment_terminal_test.go | 1 - .../store/runtime_initialization_test.go | 4 +- .../store/template_composition_public_test.go | 2 +- 78 files changed, 1461 insertions(+), 1571 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/clirunner/process_job_windows.go delete mode 100644 apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go delete mode 100644 apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux_test.go delete mode 100644 apps/parsar-daemon/internal/localworkspace/runtime_initialization_other.go create mode 100644 apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go create mode 100644 apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go create mode 100644 scripts/native-harness-smoke.mjs delete mode 100644 services/agents-api/deploy/runtime/build-system-seed.py delete mode 100644 services/agents-api/deploy/runtime/initialize.py delete mode 100644 services/agents-api/deploy/runtime/initialize_receipt_test.py delete mode 100644 services/agents-api/deploy/runtime/initialize_test.py delete mode 100644 services/agents-api/deploy/runtime/tool-root.py create mode 100644 services/agents-api/internal/api/environment_packages_test.go create mode 100644 services/agents-api/internal/store/environment_packages_test.go diff --git a/.github/workflows/native-daemon.yml b/.github/workflows/native-daemon.yml index 1c93ddd2b..65c667800 100644 --- a/.github/workflows/native-daemon.yml +++ b/.github/workflows/native-daemon.yml @@ -14,7 +14,7 @@ jobs: matrix: os: [ubuntu-22.04, macos-14, windows-2022] runs-on: ${{ matrix.os }} - timeout-minutes: 20 + timeout-minutes: 30 defaults: run: shell: bash @@ -38,7 +38,26 @@ jobs: go test -race -count=1 ./apps/parsar-daemon/internal/localworkspace ./apps/parsar-daemon/internal/cli - -run 'TestNative|TestPreparationFreezesLocalContentsAcrossReconnect|TestSnapshotMarker' + -run 'TestNative|TestRuntimeInit|TestRuntimePreparationRejects|TestPreparationFreezesLocalContentsAcrossReconnect|TestSnapshotMarker' + - uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Install pinned native Harness dependencies + run: | + npm install --global pnpm@10.30.3 + pnpm install --frozen-lockfile --filter @parsar/claude-sdk-adapter... + pnpm --filter @parsar/claude-sdk-adapter build + npm install --prefix "$RUNNER_TEMP/native-tools" --no-save @openai/codex@0.153.4 + mkdir -p "$RUNNER_TEMP/claude-runtime" + cp packages/claude-sdk-adapter/package.json "$RUNNER_TEMP/claude-runtime/" + cp -R packages/claude-sdk-adapter/dist "$RUNNER_TEMP/claude-runtime/" + npm install --prefix "$RUNNER_TEMP/claude-runtime" --omit=dev --ignore-scripts --package-lock=false + - name: Verify native Harness startup without model requests + run: | + if [[ "$RUNNER_OS" == Windows ]]; then + export CLAUDE_CODE_GIT_BASH_PATH='C:\Program Files\Git\bin\bash.exe' + fi + node scripts/native-harness-smoke.mjs --codex-package "$RUNNER_TEMP/native-tools/node_modules/@openai/codex" --claude-runtime "$RUNNER_TEMP/claude-runtime" - uses: actions/upload-artifact@v6 with: name: oac-daemon-${{ runner.os }}-${{ runner.arch }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36658674e..9a21c40a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -664,14 +664,14 @@ installation directories are not portable public paths. Inline and referenced Skill ZIPs use the same confidential initialization snapshot and installer. Core validates portable manifests and bounded regular-file archives, returns only safe Skill metadata, and freezes content before native preparation. The Runtime -owns `/environment/initialization/capabilities/skills/`; setup and native tools may read but -not modify this tree. Public Plugin ZIPs preserve their complete package +owns `/environment/initialization/capabilities/skills/`; the manifest validates the installed snapshot before reuse. This is state +consistency, not protection from the launching user. Public Plugin ZIPs preserve their complete package layout and reuse the shared archive and portable Skill parsers. Core keeps safe Plugin metadata separate from encrypted archives. Templates inherit or replace Plugin and capability-directory lists through the same hosted resolver. After ordered setup, the existing initializer snapshots declared workspace-contained -capability directories into protected storage and writes one installed manifest. +capability directories into Runtime storage and writes one installed manifest. This is an initialization artifact, not a second lifecycle owner or database ledger. Directory bytes are observed after setup; they are not frozen at Session creation. The common daemon resolves the manifest only for executable preparation and passes @@ -690,40 +690,21 @@ fails explicitly, without silent partial activation or a generic plugin framewor Environment-origin MCP declarations use the shared Plugin parser and frozen installed packages. The installation manifest retains selected MCP package roots; -Runtime re-parses those protected packages without another configuration copy, +Runtime re-parses those installed packages without another configuration copy, credential cache or lifecycle ledger. Native adapters must explicitly qualify and project supported declarations before public admission. Parent-directory Skill discovery does not activate nested Plugin MCP configuration. -The packaged stdio entry enters the existing initialization sandbox before parsing -or executing a server. It mounts only the fixed static daemon helper, which resolves -the selected installed declaration, reads explicitly selected initialized user -variables, applies package-relative cwd and replaces itself with the server. Native -MCP stdin/stdout pass directly to the sandbox; no protocol forwarding loop, -Provider command or second Plugin parser is introduced. The existing Python stdio -entry remains a single-threaded launcher and monitors the native parent process -through Linux pidfds. Native Codex recycles spawning threads, so binding bwrap's -parent-death signal directly to those threads kills live MCP connections. The -launcher gives bwrap a stable parent, retains its parent-death/PID namespace cleanup, -and kills and reaps only that child when the native process exits. A pre-exec -parent-death signal and expected-parent PID check close the reproduced fork-to-exec -orphan window; bind libc before fork and keep this entry single-threaded. Missing -pidfd support fails closed. This fixes the OS process lifetime boundary without adding a -Core execution owner, retry or reconnect loop. -Model/daemon launch variables are never credential sources. Hosted stdio requires -enabled network; native HTTP requires its own qualified network and redirect behavior. -Claude composes the existing MCP identity/observation profile with its workspace -profile. Verify the complete native inventory before admitting tool identities; -MCP allowlist patterns never grant local Bash or file authority. Only declared -random bearer references enter native query env, with native Bash denial retained. -With system packages, Claude uses its packaged shell-prefix dispatcher. Only the -exact fixed Runtime stdio invocation bypasses the tool root; it immediately execs -the existing isolated launcher. All other full command strings pass unchanged to -the tool root. The native Bash sandbox and cwd receipt remain in their original -order. Do not move wrapping into Bash input hooks or add a directory-state owner. -Keep this native distinction in the adapter; Core and the common launcher do not -select an engine. Per-server env cannot disable the native global prefix because -Claude selects the executable before merging that env. +The common daemon stdio entry resolves the installed declaration and launches its +server under the same user permissions as the Harness. Unix replaces the helper +process; Windows forwards native stdio within the owned process tree. Explicit +initialized values override the selected declaration's variables. There is no +Python sandbox launcher, mount policy, shell prefix or separate credential sandbox. +Process groups and Windows Jobs own cancellation and descendant cleanup only. +Claude composes MCP identity and observation with the same workspace profile; +MiniMax keeps its existing supported transport qualification. A public capability +advertisement must still match the installed engine's actual supported transport. + Environment-origin literal HTTP headers remain rejected for the pinned Claude client because its interpolation and cross-origin forwarding change their meaning. MiniMax accepts environment stdio only. Its adapter reads the existing Session-private @@ -733,7 +714,7 @@ never fabricate a delayed start event, guess normalized identities or add a regi of our own. Its unqualified HTTP transport remains rejected. These mechanisms alone do not establish public MCP support or full compatibility. -Name, enabled/disabled/exact-domain restricted network, initial files, inline/referenced Skills, Plugins, workspace capability directories and env/setup/system/npm/Python are +Name, initial files, inline/referenced Skills, Plugins, workspace capability directories and env/setup/npm/Python are implemented independently of remaining installation fields. Reject unsupported inputs rather than persisting them for silent omission; expand inline and template initialization together in separately qualified @@ -1254,13 +1235,12 @@ continue bounded scans for late resources without issuing another Create. This conservative internal lifecycle does not define user-managed enrollment or prove complete upstream expiry/error semantics. -Qualify the actual Docker/native sandbox before default cutover: real model -execution, file access, owned cancellation, restart with retained native history -and files, and rejection when required history is missing. Generated code and file -tools must not read daemon/model credentials, foreign Session history or another -tenant's workspace. Same-container placement, matching UID, mode bits, directory -bindings and capability flags do not prove isolation. Retain failed probes and -unverified limits; private functionality is not public hosted acceptance. +Qualify the actual Runtime before cutover: execution, file access, owned +cancellation, restart with retained history and files, and clear failure when +required history is missing. Daemon tools run with the launching account's full +permissions. Managed isolation is provided by the outer Environment. A user who +installs on a host does not receive a sandbox or protection from their own tools. +Keep failed probes and unverified platform combinations explicit. Before migration, archive existing edits and validation evidence. Reuse verified authorization, resource/lifecycle ownership and safe filesystem primitives as @@ -1269,38 +1249,16 @@ mechanisms. Remove superseded unused code, configuration, tests, scripts and task-owned temporary resources as each replacement is accepted. Preserve necessary regressions, still-used official capabilities, product data and others' work. -The opt-in Codex deployment selector `OAC_RUNTIME_CODEX_PERMISSION_PROFILE` chooses a -native named profile at harness startup and on both new/resumed threads, omitting -the legacy sandbox override. It is operator configuration, never a prompt option, -and rejects remote, none and temporary read preparations. Native managed -requirements own allowed profiles and deny-read enforcement. Keep the selector -unset for existing deployments. Managed native shells disable shell snapshots, -whose private files are inaccessible to tool execution; retain normal native shell -startup without granting tools access to harness state. -The [co-location qualification inputs](services/agents-api/deploy/codex/README.md) -record the pinned native/Docker prerequisites and limits; this switch alone does -not admit hosted Environments or authorize a workspace. - -A managed Runtime's network policy is immutable deployment input, transferred -through the provider-neutral bootstrap and checked against execution preparation. -The shared policy includes enabled, disabled and an exact-host restricted allowlist. -Core preserves public spelling/order/duplicates and only permits Template overrides -that narrow authority. Adapters translate a normalized copy into native settings; -Core and Docker never select native profile names. Every hosted execution peer -must support `local_environment_network_policy` and receive the complete bound -policy; missing policy never falls back to enabled or an older peer path. Read-only -workspace access does not require execution network policy. A declaration alone -does not qualify an image or admit public hosted creation. - -Codex restricted networking uses its native managed network requirements and proxy. -Its adapter preserves the image's filesystem, approval and hook requirements and -adds the frozen exact-host ceiling in Session-private state. The existing RPC -client owns a bubblewrap child that mounts those requirements read-only and runs -the stock native app-server in a PID namespace; teardown retains the same owner. -Preparation regenerates the non-secret requirements from the frozen policy. Keep -the file with Session state so cleanup cannot race a running child's mount. -Claude and MiniMax translate the same policy into their native sandbox allowlists. -These translations do not add a Core network service or model/tool loop. +All Harness adapters use bypass execution. Do not restore named Codex permission +profiles, bubblewrap wrappers, native Claude sandbox settings or MiniMax +SandboxManager branches. There is one execution path for every Environment origin. +Resource paths are ordinary operator configuration, not a permission boundary. + +The daemon does not enforce disabled or restricted network policies. Such a +combination must be rejected unless its outer Environment implementation provides +and qualifies the requested behavior. Do not advertise daemon-level network +isolation or silently run a restricted request with unrestricted semantics. +The normal self-hosted combination uses the host's existing network access. A dedicated local Runtime uses one Environment-scoped device credential and an immutable binding to that Environment's Session. It is excluded from general @@ -1315,8 +1273,7 @@ freezes the Environment, Session and workspace root; requests cannot supply a replacement root. The V1 path uses only the exact local Environment reference. Use the same preparation/start lifecycle for native execution and the existing bounded workspace controls for directory access. Local idle directory -reads use the existing filesystem helper directly, with no model credentials or -temporary harness. These private capabilities alone do not authorize public requests or establish +reads use the common Go filesystem implementation without a temporary Harness. These private capabilities alone do not authorize public requests or establish Provider lifecycle. Self-hosted enrollment supplies the exact local binding. Core rechecks the persisted Environment/device binding for preparation and active reads; capability discovery cannot select or authorize a general device for this @@ -3357,8 +3314,8 @@ runs its lifecycle tests and script checks, while its exact-source Linux build a Docker qualification (including `native.test.mjs` under both network policies) are required when the companion changes. -Claude hosted functions compose the existing SDK function bridge with the native -workspace sandbox. Only declared function tools and the verified native tool +Claude hosted functions compose the existing SDK function bridge with the common +workspace execution profile. Only declared function tools and the verified native tool inventory are available. The bundle advertises this combination separately from basic workspace execution; function preparations require that verified combination. Service-origin hosted MCP remains unqualified; Environment Plugin declarations @@ -3368,38 +3325,20 @@ credential, history, subagent or network authority. ### Claude dedicated Docker Runtime Build the pinned SDK bundle with `scripts/build-claude-sdk-runtime.sh`, then use -`scripts/build-claude-runtime.sh` with the existing shared workspace helper build. -See [deployment and engine onboarding](services/agents-api/deploy/claude/README.md). -The helper executables retain their historical Codex names; their local directory, -write and export operations are shared and do not launch an engine. - -`OAC_RUNTIME_CLAUDE_SDK_WORKSPACE=managed` requires the shared dedicated local binding, -canonical workspace and its same-inode `/workspace` mount, and explicit immutable -network policy. Native history, home and scratch live separately under -`OAC_RUNTIME_HOME/runtime/claude-sdk`; daemon authentication stays under -`OAC_RUNTIME_HOME/daemon`. The trusted image and protected staging directory -remain outside writable workspace roots. No product state or native user profile -is imported. The separate unbound `environment:none` profile keeps its behavior. - -The Docker operator option `nested_sandbox` is false by default. The qualified -Claude image requires it: Docker supplies an init process and permits nested procfs -mounting by removing its outer `/proc` masks/read-only submounts. `/sys/firmware` -and powercap remain masked; the root and sysfs mounts remain read-only, capabilities -remain dropped, and the existing seccomp/no-new-privileges policy remains enabled. -Do not enable privileged mode, weaken the native sandbox, mount host process state, -or apply host-global policy changes. This is an image deployment prerequisite, -not a public API option or an engine-name branch in the Provider. - -The SDK adapter advertises `local_runtime_v1` only for its Linux bridge contract. -Registration combines that contract with the verified operator binding. Core uses -an explicit accepted engine profile independently of advertisements. This profile -supports native Bash/Read/Edit, preparation, shared Files/Artifacts, cancellation -and same-history continuation. The separately advertised `workspace_functions` -combination supports declared public functions with text results. Service-origin -HTTP MCP remains unqualified here; its existing `none` support is retained. -Environment Plugin MCP follows the separately qualified transport path above. -Native Bash network access uses the harness's HTTP proxy; no alternate networking -or tool loop is implemented by Core. +`scripts/build-claude-runtime.sh`. Native self-hosted installations use the same +bundle and daemon protocol. See [engine setup](services/agents-api/deploy/claude/README.md). +The shared local binding selects the actual workspace. SDK history, home and +scratch remain under `OAC_RUNTIME_HOME/runtime/claude-sdk` for session ownership, +without restricting tools. Authentication remains under `OAC_RUNTIME_HOME/daemon`. +The daemon requires neither nested sandbox privileges nor host security changes. + +The adapter advertises `local_runtime_v1` after checking the installed bridge and +native binary. Windows additionally needs Git Bash for its Bash tool. Registration +combines that check with the local binding; Core consumes the same readiness +contract on every platform. The profile supports Bash/Read/Edit, preparation, +shared Files/Artifacts, cancellation and history continuation. Other capabilities +remain subject to their actual engine qualification; bypass execution does not +silently qualify a new MCP or function combination. Recovery uses the SDK's history APIs. An explicitly supplied native identity must exist. If Core requires existing history without having recorded an identity, the @@ -3452,7 +3391,7 @@ features, not new Core lifecycle or public protocol variants. The current qualified path is Claude SDK, `environment:none` or Core-managed Docker `openai_hosted`, medium verbosity, single Agent, with optional ordinary function tools and text results. The workspace uses its existing preparation and -native sandbox with only the SDK's configured `StructuredOutput` tool added to +bypass execution profile with the SDK's configured `StructuredOutput` tool added to inventory and permission checks. Frozen schemas reach preparation before the input handoff; Start cannot replace them. Skills, Plugins, capability directories, HTTP MCP, Subagent/tool-discovery combinations and schemas without an explicit @@ -3578,24 +3517,17 @@ The qualified live workspace fixture also checks binary, empty and bounded reads before input and during real execution, plus effects before cancellation and reads on fresh-process history continuation. -The optional private `agent.WorkspaceDirectoryLister` requires the Linux-only -`workspace_directory` bridge feature on the same Executor. -The pinned SDK has no directory-enumeration control; its fuzzy file suggestions are -not an inventory. This narrow adapter operation therefore reads metadata inside the -already qualified co-located mount/process boundary. It pins the configured workspace -root and opens each relative directory component with `O_DIRECTORY | O_NOFOLLOW`, -using `/proc/self/fd` paths anchored to held descriptors. It rejects directory symlink -traversal; `lstat` reports a symlink entry itself without following its target. -The fixed workspace policy requires canonical, disjoint protected roots and excludes -dynamic permission replacement and remote-workspace fallback. It does not implement -an additional permission engine or authorize an unqualified placement. +The optional private `agent.WorkspaceDirectoryLister` uses the portable +`workspace_directory` bridge feature. Node reads directory metadata under the +selected workspace; there is no Linux `/proc/self/fd` dependency. Public daemon +Files operations share the Go Binding implementation on all platforms. Their +relative path and result bounds define the Files API, not Harness permissions. Directory requests are bounded to 8 KiB and 1,000 immediate entries, with explicit truncation, literal names, kinds, and sizes only for regular files. They do not promise ordering, snapshots, recursion, or public pagination. Missing and permission errors are returned only from distinguishable filesystem outcomes; unknown results stop the -owner. Each operation closes its directory and intermediate descriptors before a -successful receipt; the root descriptor remains owned until bridge release. +owner. Each operation closes its directory before a successful receipt. Caller cancellation, Turn transitions and owner shutdown retain the existing workspace read settlement rules. This adapter gap fill alone does not enable public Claude Files; the dedicated Runtime integration supplies public placement and ownership. @@ -3945,15 +3877,14 @@ Providers only place, create, bootstrap, inspect, renew and reclaim resources; t do not execute Core initialization commands. The common runner uses only neutral Environment/Session identity and a Runtime peer, with no Provider, deployment or OS branch. Harness differences belong to native adapters. Package installation and -setup retain enabled provisioning network access; the requested network policy -constrains native execution after setup. +setup use the host network; the daemon does not introduce a network sandbox. A missing authenticated Runtime connection waits before capability initialization is claimed; an operation whose effect is unknown is not replayed. Self-hosted capability directories remain in immutable Environment configuration and never create a managed initialization row or allocation. The private `runtime_prepare`/`runtime_prepare_result` exchange carries typed -initial files, configure/system/npm/python/setup operations, inert Skill/Plugin +initial files, configure/npm/python/setup operations, inert Skill/Plugin archives and finalization selections, with canonical Session and Environment identities. Files and setup working directories use logical `/workspace` addresses; setup commands are explicit typed input, while executable selection and physical @@ -3966,25 +3897,22 @@ filesystem work through settlement, including disconnect or cancellation. The source-selection protocol accepts portable absolute Unix, Windows drive and UNC paths without consulting Core's filesystem. Runtime interprets and authorizes -local paths. The current implementation is Linux-only, with its existing packaged -helpers and layout; the interface imposes no Linux executable or platform field. -The common Linux binding authorizes local directories, excluding aliases, Runtime -credentials, native history and initialization roots or their ancestors. One shared -parser writes `installed.json` below the operator-owned capability directory -(default `/environment/initialization/capabilities`, selected at startup through -`OAC_RUNTIME_CAPABILITY_DIRECTORY`). The root cannot overlap the workspace or -private Runtime state and is never a public input. All adapters consume the same -resolved installation root. The manifest -binds Session/Environment identities and the ordered source-selection digest. -A private completion receipt records only the installation root outside the -installed tree, so a deleted snapshot cannot be mistaken for first preparation. -It is written durably before execution, contains no capability configuration and -is never exposed as a second inventory. Filesystem locking prevents overlapping -installation. A partial, invalid or foreign -snapshot fails closed without deleting files or silently reinstalling. Directory -sources are copied only during first preparation; reconnect loads protected installed -contents and validates the selection without rereading source directories. A new -Session uses its own Environment and fresh snapshot. +local paths. Linux, Windows and macOS use the same preparation implementation, +parser and installed manifest. Runtime paths are chosen by the operator and never +by a transfer request. Installation and package directories default under +`OAC_RUNTIME_HOME`; packaged images can select their existing directories through +explicit Runtime configuration. The manifest binds Session/Environment identities +and the ordered source-selection digest. A completion receipt prevents a deleted +snapshot from being mistaken for first preparation, while filesystem locking +prevents concurrent installation. Invalid, partial or foreign snapshots fail +without deleting data or silently reinstalling. Reconnection loads installed +contents without rereading sources; a new Session captures a fresh snapshot. + +The daemon runs as its launching account and never uses sudo or elevates its +permissions. Only user-directory dependencies are installed during preparation. +System dependencies belong in managed image/template builds or must be installed +by the self-hosted user. `packages.system` is rejected explicitly, including in +templates; it is not ignored or translated into a privileged operation. Synchronous executor admission validates the frozen descriptor only. The admitted asynchronous preparation owner ensures capabilities are ready before invoking the diff --git a/Makefile b/Makefile index 0fc278baf..7ecc7e6dc 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,6 @@ check-agents-api: build-agents-api # Persistence integration tests include bounded lifecycle waits that together exceed Go's 10m default. go test ./services/agents-api/... ./packages/agents-client/... -count=1 -timeout=20m PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s services/agents-api/tests -p 'official_diagnostics_test.py' - PYTHONDONTWRITEBYTECODE=1 python3 services/agents-api/deploy/runtime/initialize_receipt_test.py PYTHONDONTWRITEBYTECODE=1 python3 services/agents-api/deploy/e2b/managed_init_test.py docker-build-agents-api: diff --git a/apps/parsar-daemon/internal/agent/claudesdk/local.go b/apps/parsar-daemon/internal/agent/claudesdk/local.go index 1a0bb2ab8..35a3d6f42 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/local.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/local.go @@ -6,7 +6,6 @@ import ( "path/filepath" "strings" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentnetwork" ) @@ -19,9 +18,6 @@ func ConfigureLocal(config Config, root, workspace string, network agentnetwork. HomeDir: filepath.Join(root, "runtime", "claude-sdk", "home"), ScratchDir: filepath.Join(root, "runtime", "claude-sdk", "scratch"), } - if binding, err := localworkspace.Load(); err == nil && binding != nil { - config.Workspace.ExecutionMode = binding.ExecutionMode() - } if network.Validate() != nil { return Config{}, fmt.Errorf("claudesdk: dedicated Runtime requires an explicit network policy") } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go index 195581069..c6cc95c09 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/workspace.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/workspace.go @@ -18,7 +18,6 @@ import ( // State directories must already exist. // PublicDirectory may name a second mount of the same workspace inode. type WorkspaceConfig struct { - ExecutionMode string Directory string PublicDirectory string NetworkAccess string @@ -29,7 +28,6 @@ type WorkspaceConfig struct { type workspaceProfile struct { ToolEnv map[string]string `json:"tool_env,omitempty"` - ExecutionMode string `json:"execution_mode,omitempty"` CapabilityRoot string `json:"capability_root,omitempty"` MCP []environmentMCPServer `json:"mcp,omitempty"` Skills []agentcapabilities.InstalledSkill `json:"skills,omitempty"` @@ -42,7 +40,7 @@ type workspaceProfile struct { } func prepareWorkspace(config Config, req proto.PromptRequestPayload) (*workspaceProfile, []string, error) { - if req.DisableExecutionEnvironment || req.MCPHTTPServers != nil || (req.LocalEnvironment != nil && req.LocalEnvironment.ExecutionMode != config.Workspace.ExecutionMode) { + if req.DisableExecutionEnvironment || req.MCPHTTPServers != nil { return nil, nil, fmt.Errorf("claudesdk: workspace profile does not support the requested execution combination") } if req.WorkDir != "" && req.WorkDir != config.Workspace.Directory { @@ -85,14 +83,13 @@ func workspaceCwd(w *WorkspaceConfig) string { return w.Directory } -// Workspace Env is a replacement, unlike the existing none profile's overlay. -// Only explicitly selected provider settings reach either readiness or execution. +// Harness state paths and selected model credentials overlay the user environment. func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { fail := func() (*workspaceProfile, []string, error) { return nil, nil, fmt.Errorf("claudesdk: invalid trusted workspace configuration") } w := config.Workspace - if w == nil || (w.ExecutionMode != "" && w.ExecutionMode != "native") || (w.NetworkAccess != "" && w.NetworkAccess != "enabled") || len(w.AllowedDomains) != 0 { + if w == nil || (w.NetworkAccess != "" && w.NetworkAccess != "enabled") || len(w.AllowedDomains) != 0 { return fail() } for _, path := range []string{config.Node, config.Entrypoint, filepath.Join(filepath.Dir(config.Entrypoint), "runtime_check.js")} { @@ -113,7 +110,7 @@ func workspaceEnvironment(config Config) (*workspaceProfile, []string, error) { return fail() } } - profile := &workspaceProfile{ExecutionMode: w.ExecutionMode, Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, EnvNames: []string{}, NetworkAccess: w.NetworkAccess, AllowedDomains: []string{}} + profile := &workspaceProfile{Home: w.HomeDir, State: config.StateDir, Scratch: w.ScratchDir, EnvNames: []string{}, NetworkAccess: w.NetworkAccess, AllowedDomains: []string{}} env := []string{} for _, entry := range os.Environ() { name, _, _ := strings.Cut(entry, "=") @@ -156,7 +153,3 @@ func canonicalWorkspaceDir(dir string) bool { func workspacePathSyntax(dir string) bool { return filepath.IsAbs(dir) && filepath.Clean(dir) == dir && strings.IndexFunc(dir, func(r rune) bool { return r < 32 || r == 127 }) == -1 } -func pathContains(parent, child string) bool { - rel, err := filepath.Rel(parent, child) - return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) -} diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go index 796079db9..4604a23ef 100644 --- a/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go +++ b/apps/parsar-daemon/internal/agent/clirunner/process_group_windows.go @@ -101,8 +101,9 @@ func startProcessGroup(opts StartOptions) (*Process, error) { waitErr = cmd.Wait() // A successful leader exit cannot leave detached descendants behind. _ = group.cancel() - group.finish() - close(p.done) + if group.finish() { + close(p.done) + } }() return p, nil } @@ -130,10 +131,12 @@ func resumeInitialThread(pid uint32) error { } type ownedJob struct { - mu sync.Mutex - handle windows.Handle - finished bool - cancelled bool + mu sync.Mutex + handle windows.Handle + finished bool + cancelled bool + processes []windows.Handle + observationErr error } func (g *ownedJob) cancel() error { @@ -142,13 +145,29 @@ func (g *ownedJob) cancel() error { if g.finished { return os.ErrProcessDone } + if !g.cancelled { + // Hold process handles before termination removes them from the Job list. + g.processes, g.observationErr = jobProcessHandles(g.handle) + } g.cancelled = true return windows.TerminateJobObject(g.handle, 1) } -// Querying the Job, rather than waiting only for its leader, confirms cleanup. -// An observation failure retains ownership and blocks reconnect. -func (g *ownedJob) finish() { +// Job accounting can reach zero before the process objects become signaled. +// Keep ownership until both observations confirm cleanup; never wait with mu held. +func (g *ownedJob) finish() bool { + g.mu.Lock() + processes, observationErr := g.processes, g.observationErr + g.mu.Unlock() + if observationErr != nil { + return false + } + for _, process := range processes { + status, err := windows.WaitForSingleObject(process, windows.INFINITE) + if err != nil || status != windows.WAIT_OBJECT_0 { + return false + } + } type accounting struct { UserTime, KernelTime, PeriodUserTime, PeriodKernelTime int64 PageFaults, TotalProcesses, ActiveProcesses, TerminatedProcesses uint32 @@ -164,5 +183,9 @@ func (g *ownedJob) finish() { g.mu.Lock() defer g.mu.Unlock() g.finished = true + for _, process := range processes { + windows.CloseHandle(process) + } windows.CloseHandle(g.handle) + return true } diff --git a/apps/parsar-daemon/internal/agent/clirunner/process_job_windows.go b/apps/parsar-daemon/internal/agent/clirunner/process_job_windows.go new file mode 100644 index 000000000..ccc3bae11 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/clirunner/process_job_windows.go @@ -0,0 +1,67 @@ +//go:build windows + +package clirunner + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +var isProcessInJob = windows.NewLazySystemDLL("kernel32.dll").NewProc("IsProcessInJob") + +// A PID list alone cannot confirm exit: retain handles until termination signals +// each process. Check membership after opening to avoid a recycled foreign PID. +func jobProcessHandles(job windows.Handle) ([]windows.Handle, error) { + pids, err := jobProcessIDs(job) + if err != nil { + return nil, err + } + var handles []windows.Handle + for _, pid := range pids { + process, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_QUERY_INFORMATION, false, uint32(pid)) + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + continue + } + if err != nil { + return handles, err + } + var belongs int32 + ok, _, queryErr := isProcessInJob.Call(uintptr(process), uintptr(job), uintptr(unsafe.Pointer(&belongs))) + if ok == 0 { + windows.CloseHandle(process) + return handles, queryErr + } + if belongs == 0 { + windows.CloseHandle(process) + continue + } + handles = append(handles, process) + } + return handles, nil +} + +func jobProcessIDs(job windows.Handle) ([]uintptr, error) { + type processList struct { + Assigned, Count uint32 + First uintptr + } + for size := 64; ; size *= 2 { + // uintptr storage keeps the variable-length Windows structure aligned. + storage := make([]uintptr, size+2) + list := (*processList)(unsafe.Pointer(&storage[0])) + err := windows.QueryInformationJobObject(job, windows.JobObjectBasicProcessIdList, uintptr(unsafe.Pointer(list)), uint32(len(storage)*int(unsafe.Sizeof(uintptr(0)))), nil) + if errors.Is(err, windows.ERROR_MORE_DATA) || (err == nil && list.Count < list.Assigned) { + continue + } + if err != nil { + return nil, err + } + if list.Count > uint32(size) { + return nil, fmt.Errorf("clirunner: invalid Job process count") + } + return append([]uintptr(nil), unsafe.Slice(&list.First, int(list.Count))...), nil + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go index 56e80fd55..3d597d6a8 100644 --- a/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go +++ b/apps/parsar-daemon/internal/agent/codex/permission_profile_test.go @@ -2,13 +2,16 @@ package codex import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "os" + "path/filepath" + "strings" "testing" ) func TestRuntimeUsesHostPermissions(t *testing.T) { t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) - for _, mode := range []string{"", "native"} { - req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{ExecutionMode: mode, NetworkAccess: "enabled"}} + { + req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled"}} plan, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) if err != nil { t.Fatal(err) @@ -30,3 +33,33 @@ func TestRuntimeUsesHostPermissions(t *testing.T) { } } } + +func TestInitializedEnvironmentCannotRedirectNativeHistory(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + workspace := filepath.Join(root, "workspace") + if err := os.Mkdir(workspace, 0700); err != nil { + t.Fatal(err) + } + config := filepath.Join(root, "tool-env.json") + if err := os.WriteFile(config, []byte(`{"CODEX_HOME":"wrong","HOME":"wrong","USERPROFILE":"wrong","USER_VALUE":"ready"}`), 0600); err != nil { + t.Fatal(err) + } + for key, value := range map[string]string{"OAC_RUNTIME_ENVIRONMENT_ID": "b3d154b8-543b-4248-97b1-665f9f418d52", "OAC_RUNTIME_SESSION_ID": "33e02e0d-6fc8-4904-9d7a-4b61b9094ae0", "OAC_RUNTIME_WORKSPACE": workspace, "OAC_RUNTIME_CAPABILITY_DIRECTORY": filepath.Join(root, "capabilities"), "OAC_RUNTIME_NETWORK_ACCESS": "enabled", "OAC_RUNTIME_TOOL_ENV_FILE": config} { + t.Setenv(key, value) + } + req := proto.PromptRequestPayload{AgentStateKey: "session", DisableSubagents: true, LocalEnvironment: &proto.LocalEnvironment{NetworkAccess: "enabled", ToolEnvironment: true}} + plan, _, err := prepareSessionPlan(t.Context(), req, sessionConfig{}) + if err != nil { + t.Fatal(err) + } + defer plan.Cleanup() + values := map[string]string{} + for _, entry := range plan.Env { + key, value, _ := strings.Cut(entry, "=") + values[key] = value + } + if values["CODEX_HOME"] == "" || values["CODEX_HOME"] == "wrong" || values["HOME"] == "wrong" || values["USERPROFILE"] == "wrong" || values["USER_VALUE"] != "ready" { + t.Fatal("initialization changed native state identity") + } +} diff --git a/apps/parsar-daemon/internal/agent/codex/session_plan.go b/apps/parsar-daemon/internal/agent/codex/session_plan.go index 8b7c1bfb8..263aa3bd9 100644 --- a/apps/parsar-daemon/internal/agent/codex/session_plan.go +++ b/apps/parsar-daemon/internal/agent/codex/session_plan.go @@ -3,6 +3,7 @@ package codex import ( "context" "fmt" + "strings" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/localworkspace" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" @@ -47,6 +48,9 @@ func prepareSessionPlan(ctx context.Context, req proto.PromptRequestPayload, cfg return SessionPlan{}, nil, err } for key, value := range values { + if strings.EqualFold(key, "CODEX_HOME") || strings.EqualFold(key, "HOME") || strings.EqualFold(key, "USERPROFILE") { + continue + } plan.Env = append(plan.Env, key+"="+value) } } diff --git a/apps/parsar-daemon/internal/agent/mcode/workspace.go b/apps/parsar-daemon/internal/agent/mcode/workspace.go index 78a72e57d..9af591662 100644 --- a/apps/parsar-daemon/internal/agent/mcode/workspace.go +++ b/apps/parsar-daemon/internal/agent/mcode/workspace.go @@ -15,7 +15,6 @@ import ( // WorkspaceConfig is frozen deployment input, separate from public Agent options. type WorkspaceConfig struct { - ExecutionMode string Binary, Node, Bridge, Directory, Network, Scratch string AllowedDomains []string } @@ -23,17 +22,13 @@ type WorkspaceConfig struct { func ConfigureLocal(binary, node, bridge, root, workspace string, network agentnetwork.Policy, staging string) (WorkspaceConfig, error) { c := WorkspaceConfig{Binary: binary, Node: node, Bridge: bridge, Directory: workspace, Network: network.Access, AllowedDomains: network.Hosts(), Scratch: filepath.Join(root, "runtime", "mcode-tools", "scratch")} - binding, bindingErr := localworkspace.Load() - if bindingErr == nil && binding != nil { - c.ExecutionMode = binding.ExecutionMode() - } if runtime.GOOS == "windows" || network.Access != "enabled" || len(network.AllowedDomains) != 0 { return c, fmt.Errorf("mcode: native execution requires Linux or macOS and unrestricted host access") } if network.Validate() != nil { return c, fmt.Errorf("mcode: explicit workspace network policy is required") } - for _, path := range []string{binary, node, bridge, root, workspace, staging} { + for _, path := range []string{binary, node, bridge, root, workspace} { if !filepath.IsAbs(path) || filepath.Clean(path) != path || path == "/" { return c, fmt.Errorf("mcode: canonical absolute deployment paths are required") } @@ -56,9 +51,6 @@ func ConfigureLocal(binary, node, bridge, root, workspace string, network agentn } func prepareWorkspaceOptions(ctx context.Context, c WorkspaceConfig, req proto.PromptRequestPayload) (launchOptions, error) { - if req.LocalEnvironment != nil && req.LocalEnvironment.ExecutionMode != c.ExecutionMode { - return launchOptions{}, fmt.Errorf("mcode: execution mode mismatch") - } if c.Network != "enabled" || len(c.AllowedDomains) != 0 { return launchOptions{}, fmt.Errorf("mcode: Runtime does not implement network isolation") } diff --git a/apps/parsar-daemon/internal/cli/native_install.go b/apps/parsar-daemon/internal/cli/native_install.go index 2b55e8cf2..e1273a284 100644 --- a/apps/parsar-daemon/internal/cli/native_install.go +++ b/apps/parsar-daemon/internal/cli/native_install.go @@ -127,7 +127,6 @@ func runStart(rc *runContext, args []string) error { return errors.New("start: this installation version is unsupported; reinstall explicitly") } for key, value := range map[string]string{ - "OAC_RUNTIME_EXECUTION_MODE": "native", "OAC_RUNTIME_WORKSPACE": config.Workspace, "OAC_RUNTIME_CAPABILITY_DIRECTORY": config.CapabilityDirectory, "OAC_RUNTIME_TOOL_ENV_FILE": config.ToolEnvironmentFile, diff --git a/apps/parsar-daemon/internal/daemonize/fork_windows.go b/apps/parsar-daemon/internal/daemonize/fork_windows.go index 6217b80bf..727d707a1 100644 --- a/apps/parsar-daemon/internal/daemonize/fork_windows.go +++ b/apps/parsar-daemon/internal/daemonize/fork_windows.go @@ -9,7 +9,6 @@ import ( "os/exec" "strings" "syscall" - "unsafe" "golang.org/x/sys/windows" ) @@ -20,22 +19,13 @@ func configureBackground(cmd *exec.Cmd) (func(), func() error, error) { return nil, nil, err } name := `Local\OpenAgentCore-daemon-` + hex.EncodeToString(nonce[:]) - user, err := windows.GetCurrentProcessToken().GetTokenUser() - if err != nil { - return nil, nil, err - } - sd, err := windows.SecurityDescriptorFromString("D:P(A;;GA;;;" + user.User.Sid.String() + ")") - if err != nil { - return nil, nil, err - } - attr := windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd} stopName, _ := windows.UTF16PtrFromString(name) - event, err := windows.CreateEvent(&attr, 1, 0, stopName) + event, err := windows.CreateEvent(nil, 1, 0, stopName) if err != nil { return nil, nil, err } readyName, _ := windows.UTF16PtrFromString(name + "-ready") - ready, err := windows.CreateEvent(&attr, 1, 0, readyName) + ready, err := windows.CreateEvent(nil, 1, 0, readyName) if err != nil { windows.CloseHandle(event) return nil, nil, err @@ -48,9 +38,9 @@ func configureBackground(cmd *exec.Cmd) (func(), func() error, error) { } } cmd.Env = append(filtered, stopEventEnv+"="+name) - // A short-lived installer must not own the detached daemon through its Job. - // If an enclosing Job forbids breakaway, Start fails instead of losing ownership. - cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_BREAKAWAY_FROM_JOB} + // Detach from the console without escaping a CI or service host Job. + // Such a host can still end the daemon when its own Job is closed. + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP} return clean, func() error { status, err := windows.WaitForSingleObject(ready, 60000) if err != nil { diff --git a/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go b/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go index 28a14c02a..9af962dd9 100644 --- a/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go +++ b/apps/parsar-daemon/internal/dispatch/workspace_directory_test.go @@ -3,6 +3,8 @@ package dispatch_test import ( "context" "fmt" + "os" + "path/filepath" "testing" "time" @@ -17,9 +19,14 @@ func TestWorkspaceDirectoryRetainsEnvironmentAndTransferredOwner(t *testing.T) { return &fakeSession{out: out, ctx: ctx, closeOutOnCancel: true}, nil } r := preparationRouter(t, sender, time.Minute, func(context.Context, proto.PromptRequestPayload) (agent.Prepared, error) { return p, nil }) + for _, name := range []string{"file", "second"} { + if err := os.WriteFile(filepath.Join(os.Getenv("OAC_RUNTIME_WORKSPACE"), name), []byte("abc"), 0600); err != nil { + t.Fatal(err) + } + } _ = r.Handle(t.Context(), mustEnv(t, proto.TypeExecutionPrepare, "prepare", preparationRequest())) ready := waitPreparationStatus(t, sender, "prepare", "ready", "") - request := proto.WorkspaceReadPayload{Operation: "directory", Handle: ready.Handle, EnvironmentID: preparationEnvironmentID, MaxEntries: 2} + request := proto.WorkspaceReadPayload{Operation: "directory", Handle: ready.Handle, EnvironmentID: preparationEnvironmentID, MaxEntries: 1} for _, phase := range []string{"idle", "active"} { _ = r.Handle(t.Context(), mustEnv(t, proto.TypeWorkspaceRead, phase, request)) result := waitWorkspaceRead(t, sender, phase) diff --git a/apps/parsar-daemon/internal/localworkspace/binding.go b/apps/parsar-daemon/internal/localworkspace/binding.go index bd1a90aa2..e8702b689 100644 --- a/apps/parsar-daemon/internal/localworkspace/binding.go +++ b/apps/parsar-daemon/internal/localworkspace/binding.go @@ -16,7 +16,6 @@ import ( // Binding freezes operator-owned identity and paths for one Runtime lifetime. type Binding struct { - executionMode string environment string networkAccess string allowedDomains []string @@ -39,7 +38,6 @@ func New(environment, session, workspace, helper string) (*Binding, error) { return nil, err } b.helper = helper - b.executionMode = os.Getenv("OAC_RUNTIME_EXECUTION_MODE") return b, nil } @@ -50,7 +48,6 @@ func NewWithCapabilityDirectory(environment, session, workspace, helper, directo return nil, err } b.helper = helper - b.executionMode = os.Getenv("OAC_RUNTIME_EXECUTION_MODE") return b, nil } @@ -100,7 +97,6 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa } if !r.WorkspaceReadOnly { local := *r.LocalEnvironment - local.ExecutionMode = b.ExecutionMode() if local.WorkspaceDirectory != "/workspace" && local.WorkspaceDirectory != b.workspace { return r, errors.New("request does not match the local workspace selection") } @@ -109,7 +105,7 @@ func (b *Binding) Configure(r proto.PromptRequestPayload) (proto.PromptRequestPa } sources := *local.CapabilitySources present := len(sources.Skills)+len(sources.Plugins)+len(sources.Directories) > 0 - if present != local.Capabilities || local.SystemPackages && !local.ToolEnvironment { + if present != local.Capabilities { return r, agentcapabilities.ErrInvalid } local.Skills, local.MCP, local.CapabilityRoot = nil, nil, "" diff --git a/apps/parsar-daemon/internal/localworkspace/capabilities.go b/apps/parsar-daemon/internal/localworkspace/capabilities.go index c2b79390c..9cf8b9ae9 100644 --- a/apps/parsar-daemon/internal/localworkspace/capabilities.go +++ b/apps/parsar-daemon/internal/localworkspace/capabilities.go @@ -45,7 +45,6 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr } local := *r.LocalEnvironment local.Skills, local.MCP, local.CapabilityRoot = manifest.Skills, nil, b.capabilityRoot - local.ExecutionMode = b.ExecutionMode() for i := range local.Skills { local.Skills[i].InstallationRoot = b.capabilityRoot } @@ -66,7 +65,6 @@ func (b *Binding) Prepare(ctx context.Context, r proto.PromptRequestPayload) (pr for i := range local.MCP { local.MCP[i].InstallationRoot = b.capabilityRoot local.MCP[i].WorkspaceRoot = b.workspace - local.MCP[i].ExecutionMode = b.ExecutionMode() } if err != nil { return r, err diff --git a/apps/parsar-daemon/internal/localworkspace/initialization.go b/apps/parsar-daemon/internal/localworkspace/initialization.go index f7d4612e5..338ad7c78 100644 --- a/apps/parsar-daemon/internal/localworkspace/initialization.go +++ b/apps/parsar-daemon/internal/localworkspace/initialization.go @@ -5,81 +5,86 @@ import ( "errors" "os" "path/filepath" + "runtime" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) -// ReadToolEnvironment selects the immutable user configuration. It never reads -// process environment or introduces live-execution prerequisites for Files. +// InitializationDirectory resolves the operator's local resource layout. The +// protocol and preparation operations do not distinguish Environment sources. +func InitializationDirectory() (string, error) { + return initializationPath("OAC_RUNTIME_INITIALIZATION_DIRECTORY", "initialization") +} +func PackageDirectory() (string, error) { + return initializationPath("OAC_RUNTIME_PACKAGE_DIRECTORY", "packages") +} +func initializationPath(setting, name string) (string, error) { + if path := os.Getenv(setting); path != "" { + if runtimefs.ValidateLocalPath(path) != nil { + return "", errors.New("invalid Runtime initialization directory") + } + return path, nil + } + root, err := paths.Root() + if err != nil { + return "", err + } + return filepath.Join(root, name), nil +} + +// ReadToolEnvironment reads bounded explicit configuration, never ambient secrets. func ReadToolEnvironment() (map[string]string, error) { - info, err := os.Stat(toolEnvironmentPath()) - if err != nil || !info.Mode().IsRegular() || info.Size() > 1<<20 { - return nil, errors.New("initialized user environment unavailable") + path, err := toolEnvironmentPath() + if err != nil { + return nil, err } - body, err := os.ReadFile(toolEnvironmentPath()) + body, err := runtimefs.ReadPrivatePath(path, 1<<20) var values map[string]string - if err != nil || json.Unmarshal(body, &values) != nil || values == nil { + if err != nil || json.Unmarshal(body, &values) != nil || values == nil || !validToolEnvironment(values) { return nil, errors.New("initialized user environment unavailable") } return values, nil } - -// These paths belong to the packaged Runtime, not a harness or public template. -const ( - InitializationDirectory = "/environment/initialization" - ToolEnvironmentShell = InitializationDirectory + "/tool-env.sh" - ToolEnvironmentJSON = InitializationDirectory + "/tool-env.json" - PackageDirectory = "/environment/packages" - SystemPackageDirectory = PackageDirectory + "/system" - SystemPackageReceipt = InitializationDirectory + "/system-root.json" - SystemToolLauncher = "/usr/local/bin/oac-tool-root" -) - -// VerifyToolEnvironment is required only for execution consuming initialized -// tool configuration. It never makes Files reads depend on execution setup. -func VerifyToolEnvironment(systemPackages bool) error { - paths := []string{InitializationDirectory, PackageDirectory, ToolEnvironmentShell, ToolEnvironmentJSON} - if systemPackages { - paths = append(paths, SystemPackageDirectory, SystemPackageReceipt, SystemToolLauncher) - } - for _, path := range paths { - actual, err := filepath.EvalSymlinks(path) - info, statErr := os.Lstat(path) - if err != nil || statErr != nil || actual != path { - return errors.New("initialized tool configuration unavailable") - } - if path == InitializationDirectory || path == PackageDirectory || path == SystemPackageDirectory { - if !info.IsDir() { - return errors.New("initialized tool directory unavailable") +func validToolEnvironment(values map[string]string) bool { + seen := map[string]bool{} + for key, value := range values { + if runtime.GOOS == "windows" { + folded := strings.ToUpper(key) + if seen[folded] { + return false } - } else if !info.Mode().IsRegular() || info.Size() > 1024*1024 { - return errors.New("initialized tool configuration is not immutable") - } - } - if systemPackages { - raw, err := os.ReadFile(SystemPackageReceipt) - var receipt struct { - Version int `json:"version"` + seen[folded] = true } - if err != nil || json.Unmarshal(raw, &receipt) != nil || receipt.Version != 1 { - return errors.New("installed system tools unavailable") + if key == "" || strings.ContainsAny(key, "=\x00\r\n") || strings.ContainsRune(value, 0) { + return false } } - return nil + return true } -// ReadOptionalToolEnvironment permits a directory-only Runtime without user -// variables. Declared MCP variables still require the immutable explicit file. +// ReadOptionalToolEnvironment permits a Runtime without explicit user variables. func ReadOptionalToolEnvironment() (map[string]string, error) { - if _, err := os.Stat(toolEnvironmentPath()); errors.Is(err, os.ErrNotExist) { + path, err := toolEnvironmentPath() + if err != nil { + return nil, err + } + if _, err = os.Stat(path); errors.Is(err, os.ErrNotExist) { return map[string]string{}, nil } return ReadToolEnvironment() } - -// Native installations can select an explicit tool configuration file. Packaged -// installations retain their existing resource layout, without a sandbox. -func toolEnvironmentPath() string { +func toolEnvironmentPath() (string, error) { if path := os.Getenv("OAC_RUNTIME_TOOL_ENV_FILE"); path != "" { - return path + if runtimefs.ValidateLocalPath(path) != nil { + return "", errors.New("invalid Runtime tool environment file") + } + return path, nil + } + directory, err := InitializationDirectory() + if err != nil { + return "", err } - return ToolEnvironmentJSON + return filepath.Join(directory, "tool-env.json"), nil } diff --git a/apps/parsar-daemon/internal/localworkspace/mcp.go b/apps/parsar-daemon/internal/localworkspace/mcp.go index 146d1e812..38040e2d5 100644 --- a/apps/parsar-daemon/internal/localworkspace/mcp.go +++ b/apps/parsar-daemon/internal/localworkspace/mcp.go @@ -9,8 +9,6 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -const MCPInitializer = "/usr/local/bin/oac-runtime-initialize" - // MCPStdioCommand resolves the common installed manifest in the daemon. func MCPStdioCommand(server proto.EnvironmentMCP) (string, []string) { executable, err := os.Executable() diff --git a/apps/parsar-daemon/internal/localworkspace/native_binding.go b/apps/parsar-daemon/internal/localworkspace/native_binding.go index 1dcf54df3..bf39d35b5 100644 --- a/apps/parsar-daemon/internal/localworkspace/native_binding.go +++ b/apps/parsar-daemon/internal/localworkspace/native_binding.go @@ -9,14 +9,6 @@ import ( "github.com/google/uuid" ) -// ExecutionMode selects the installation layout, never a permission boundary. -func (b *Binding) ExecutionMode() string { - if b == nil { - return "" - } - return b.executionMode -} - func newNativeBinding(environment, session, workspace, capabilities string) (*Binding, error) { for _, value := range []string{environment, session} { id, err := uuid.Parse(value) @@ -33,7 +25,7 @@ func newNativeBinding(environment, session, workspace, capabilities string) (*Bi if err != nil || !info.IsDir() { return nil, errors.New("local workspace root must be an existing directory") } - return &Binding{environment: environment, stateKey: "agents-api-" + session, workspace: workspace, capabilityRoot: capabilities, executionMode: "native", writer: &fileWriter{}}, nil + return &Binding{environment: environment, stateKey: "agents-api-" + session, workspace: workspace, capabilityRoot: capabilities, writer: &fileWriter{}}, nil } // ReadToolEnvironment reads explicit initialization values for this installation. diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go index c8c639eb1..6f3925aa4 100644 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization.go @@ -1,11 +1,209 @@ package localworkspace -import "errors" +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" +) // InitializationFailure contains only a confirmed step's safe exit status. type InitializationFailure struct{ ExitCode *int } func (*InitializationFailure) Error() string { return "Runtime initialization failed" } -// ErrInitializationUnconfirmed means execution or its receipt was not confirmed. var ErrInitializationUnconfirmed = errors.New("Runtime initialization unconfirmed") + +func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInitialization) error { + raw, err := json.Marshal(input) + if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { + return agentcapabilities.ErrInvalid + } + if ctx.Err() != nil { + return ErrInitializationUnconfirmed + } + if input.Action == "configure" { + return configureRuntime(input.Env) + } + if input.Action != "setup" && input.Action != "npm" && input.Action != "python" { + return agentcapabilities.ErrInvalid + } + if input.Network != "enabled" && input.Network != "disabled" { + return agentcapabilities.ErrInvalid + } + directory, err := b.initializationCWD(input.CWD) + if err != nil { + return err + } + values, err := ReadToolEnvironment() + if err != nil { + return &InitializationFailure{} + } + packages, err := PackageDirectory() + if err != nil { + return &InitializationFailure{} + } + var binary string + var args []string + switch input.Action { + case "setup": + if input.Command == "" || strings.ContainsRune(input.Command, 0) { + return agentcapabilities.ErrInvalid + } + binary, err = initializationBash() + args = []string{"--noprofile", "--norc", "-c", input.Command} + case "npm", "python": + if len(input.Packages) == 0 { + return agentcapabilities.ErrInvalid + } + for _, value := range input.Packages { + if value == "" || strings.HasPrefix(value, "-") || strings.ContainsAny(value, "\x00\r\n") { + return agentcapabilities.ErrInvalid + } + } + if err = os.MkdirAll(packages, 0700); err != nil { + return &InitializationFailure{} + } + if input.Action == "npm" { + binary, args, err = initializationNPM() + args = append(args, "install", "--global", "--prefix", filepath.Join(packages, "npm"), "--") + } else { + binary, err = initializationPython() + args = []string{"-m", "pip", "install", "--disable-pip-version-check", "--no-input", "--target", filepath.Join(packages, "python"), "--"} + } + args = append(args, input.Packages...) + } + if err != nil { + return err + } + return runInitializationProcess(ctx, binary, args, directory, initializationEnvironment(values)) +} + +func (b *Binding) initializationCWD(value string) (string, error) { + if value == "" || value == "/workspace" { + return b.workspace, nil + } + if !strings.HasPrefix(value, "/workspace/") { + return "", agentcapabilities.ErrInvalid + } + relative, err := nativeAPIPath(strings.TrimPrefix(value, "/workspace/")) + if err != nil { + return "", agentcapabilities.ErrInvalid + } + return filepath.Join(b.workspace, relative), nil +} + +func configureRuntime(values map[string]string) error { + if !validToolEnvironment(values) { + return agentcapabilities.ErrInvalid + } + path, err := toolEnvironmentPath() + if err != nil { + return &InitializationFailure{} + } + packages, err := PackageDirectory() + if err != nil { + return &InitializationFailure{} + } + if os.MkdirAll(filepath.Dir(path), 0700) != nil || os.MkdirAll(packages, 0700) != nil { + return &InitializationFailure{} + } + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + return &InitializationFailure{} + } + defer root.Close() + unlock, err := runtimefs.LockDirectory(root) + if err != nil { + return &InitializationFailure{} + } + defer unlock() + if _, err = root.Stat(filepath.Base(path)); !errors.Is(err, os.ErrNotExist) { + return &InitializationFailure{} + } + configured := make(map[string]string, len(values)+2) + for key, value := range values { + if runtime.GOOS == "windows" { + key = strings.ToUpper(key) + } + configured[key] = value + } + separator := string(os.PathListSeparator) + npmBin := filepath.Join(packages, "npm", "bin") + pythonBin := filepath.Join(packages, "python", "bin") + if runtime.GOOS == "windows" { + npmBin = filepath.Join(packages, "npm") + pythonBin = filepath.Join(packages, "python", "Scripts") + } + pathValue, exists := configured["PATH"] + if !exists { + pathValue = os.Getenv("PATH") + } + configured["PATH"] = npmBin + separator + pythonBin + separator + pathValue + pythonPath := configured["PYTHONPATH"] + configured["PYTHONPATH"] = filepath.Join(packages, "python") + if pythonPath != "" { + configured["PYTHONPATH"] += separator + pythonPath + } + raw, err := json.Marshal(configured) + if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { + return agentcapabilities.ErrInvalid + } + if runtimefs.WritePrivateAtomic(root, filepath.Base(path), raw) != nil { + return ErrInitializationUnconfirmed + } + return nil +} + +func (b *Binding) installInitialFile(ctx context.Context, input proto.RuntimeInitialFile, data []byte) (err error) { + if b.writer == nil || !strings.HasPrefix(input.Path, "/workspace/") || len(data) > proto.RuntimePrepareMaxBytes { + return agentcapabilities.ErrInvalid + } + relative, err := nativeAPIPath(strings.TrimPrefix(input.Path, "/workspace/")) + if err != nil { + return agentcapabilities.ErrInvalid + } + if ctx.Err() != nil { + return ErrInitializationUnconfirmed + } + w := b.writer + if !w.mu.TryLock() { + return agentcapabilities.ErrInvalid + } + defer w.mu.Unlock() + if w.uncertain { + return ErrInitializationUnconfirmed + } + defer func() { w.uncertain = errors.Is(err, ErrInitializationUnconfirmed) }() + root, err := os.OpenRoot(b.workspace) + if err != nil { + return &InitializationFailure{} + } + defer root.Close() + if root.MkdirAll(filepath.Dir(relative), 0700) != nil { + return &InitializationFailure{} + } + parent, err := root.OpenRoot(filepath.Dir(relative)) + if err != nil { + return &InitializationFailure{} + } + defer parent.Close() + // Initial files replace existing contents, unlike public Files create. Finish + // the synchronous atomic write before returning even if cancellation arrives. + if runtimefs.WritePrivateAtomic(parent, filepath.Base(relative), data) != nil { + return ErrInitializationUnconfirmed + } + return nil +} + +func initializationDependency(name string) error { + return fmt.Errorf("Runtime initialization requires %s: %w", name, &InitializationFailure{}) +} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go deleted file mode 100644 index 34603e821..000000000 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux.go +++ /dev/null @@ -1,174 +0,0 @@ -//go:build linux - -package localworkspace - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/json" - "errors" - "io" - "strconv" - "strings" - "time" - - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func (b *Binding) initializeRuntime(ctx context.Context, input proto.RuntimeInitialization) error { - payload := struct { - Version int `json:"version"` - proto.RuntimeInitialization - }{Version: 1, RuntimeInitialization: input} - raw, err := json.Marshal(payload) - if err != nil || len(raw) > proto.RuntimePrepareMaxFrameBytes { - return agentcapabilities.ErrInvalid - } - result, err := runInitializationProcess(ctx, "/usr/bin/python3", []string{"-I", "-S", MCPInitializer, "initialize", b.workspace, b.capabilityRoot}, bytes.NewReader(raw)) - if err != nil { - return err - } - return decodeInitialization(result) -} - -func (b *Binding) installInitialFile(ctx context.Context, input proto.RuntimeInitialFile, data []byte) (err error) { - if b.writer == nil || !strings.HasPrefix(input.Path, "/workspace/") || len(data) > proto.RuntimePrepareMaxBytes { - return agentcapabilities.ErrInvalid - } - w := b.writer - if !w.mu.TryLock() { - return agentcapabilities.ErrInvalid - } - defer w.mu.Unlock() - if w.uncertain { - return ErrInitializationUnconfirmed - } - defer func() { w.uncertain = errors.Is(err, ErrInitializationUnconfirmed) }() - digest := sha256.Sum256(data) - result, err := runInitializationProcess(ctx, "/usr/bin/python3", []string{"-I", "-S", "-c", initialFileInstaller, - b.workspace, strings.TrimPrefix(input.Path, "/workspace/"), strconv.Itoa(len(data)), w.staging, w.helper}, - io.MultiReader(bytes.NewReader(data), bytes.NewReader(digest[:]))) - if err != nil { - return err - } - if result.exit != 0 || len(result.stderr) != 0 { - return ErrInitializationUnconfirmed - } - _, err = decodeWrite(result.stdout, len(data)) - if errors.Is(err, agent.ErrWorkspaceWriteRejected) { - return &InitializationFailure{} - } - if err != nil { - return ErrInitializationUnconfirmed - } - return nil -} - -// This only creates fd-anchored parents, then delegates replacement to the -// existing atomic writer. Deployment paths come exclusively from Binding. -const initialFileInstaller = `import os, sys -workspace, path, size, staging, helper = sys.argv[1:] -parts = path.split('/') -if any(not p or p in ('.', '..') or any(c in p for c in ('\\', '\x00', '\r', '\n')) for p in parts): - raise SystemExit(2) -flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC -fd = os.open('/', flags) -for component in workspace.lstrip('/').split('/'): - child = os.open(component, flags, dir_fd=fd) - os.close(fd) - fd = child -for component in parts[:-1]: - try: - child = os.open(component, flags, dir_fd=fd) - except FileNotFoundError: - os.mkdir(component, mode=0o700, dir_fd=fd) - child = os.open(component, flags, dir_fd=fd) - os.close(fd) - fd = child -os.close(fd) -os.execv(helper, [helper, workspace, path, size, staging]) -` - -type initializationResult struct { - stdout, stderr []byte - exit int -} - -// runInitializationProcess uses the existing process-group owner and joins all -// readers/writers before returning. No child output becomes a public error. -func runInitializationProcess(ctx context.Context, binary string, args []string, input io.Reader) (initializationResult, error) { - result := initializationResult{exit: -1} - operation, cancel := context.WithTimeout(ctx, 30*time.Minute) - defer cancel() - if operation.Err() != nil { - return result, ErrInitializationUnconfirmed - } - process, err := clirunner.Start(clirunner.StartOptions{Parent: operation, Binary: binary, Args: args, - Dir: "/", Env: []string{"PATH=/usr/bin:/bin", "LANG=C.UTF-8"}, NeedStdin: true, - OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) - if err != nil { - return result, ErrInitializationUnconfirmed - } - defer process.Cancel() - inputDone := make(chan error, 1) - go func() { - _, err := io.Copy(process.Stdin, input) - closeErr := process.Stdin.Close() - if err == nil { - err = closeErr - } - inputDone <- err - }() - type output struct { - data []byte - err error - } - read := func(stream io.Reader, done chan<- output) { - raw, err := io.ReadAll(io.LimitReader(stream, 1025)) - if err != nil || len(raw) > 1024 { - process.Cancel() - } - _, _ = io.Copy(io.Discard, stream) - done <- output{raw, err} - } - stdout, stderr := make(chan output, 1), make(chan output, 1) - go read(process.Stdout, stdout) - go read(process.Stderr, stderr) - out, diagnostic := <-stdout, <-stderr - inputErr := <-inputDone - _ = process.Wait() - result.stdout, result.stderr = out.data, diagnostic.data - result.exit = process.Cmd.ProcessState.ExitCode() - if operation.Err() != nil || inputErr != nil || out.err != nil || diagnostic.err != nil || - len(out.data) > 1024 || len(diagnostic.data) > 1024 || result.exit < 0 { - return result, ErrInitializationUnconfirmed - } - return result, nil -} - -func decodeInitialization(result initializationResult) error { - var receipt struct { - Version int `json:"version"` - Outcome string `json:"outcome"` - ExitCode *int `json:"exit_code"` - } - decoder := json.NewDecoder(bytes.NewReader(result.stdout)) - decoder.DisallowUnknownFields() - if len(result.stderr) != 0 || decoder.Decode(&receipt) != nil || decoder.Decode(new(any)) != io.EOF || receipt.Version != 1 { - return ErrInitializationUnconfirmed - } - if result.exit == 0 && receipt.Outcome == "completed" && receipt.ExitCode == nil { - return nil - } - if result.exit == 1 && receipt.Outcome == "failed" { - if receipt.ExitCode != nil && (*receipt.ExitCode < 1 || *receipt.ExitCode > 255) { - return ErrInitializationUnconfirmed - } - return &InitializationFailure{ExitCode: receipt.ExitCode} - } - return ErrInitializationUnconfirmed -} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux_test.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux_test.go deleted file mode 100644 index c318ae0f8..000000000 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_linux_test.go +++ /dev/null @@ -1,194 +0,0 @@ -//go:build linux - -package localworkspace - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func TestRuntimeInitializationSafeReceipts(t *testing.T) { - for _, tc := range []struct { - raw string - exit int - failed bool - code int - }{ - {`{"version":1,"outcome":"completed"}`, 0, false, 0}, - {`{"version":1,"outcome":"failed","exit_code":3}`, 1, true, 3}, - {`{"version":1,"outcome":"failed"}`, 1, true, 0}, - } { - err := decodeInitialization(initializationResult{stdout: []byte(tc.raw), exit: tc.exit}) - var failure *InitializationFailure - if tc.failed { - if !errors.As(err, &failure) || tc.code != 0 && (failure.ExitCode == nil || *failure.ExitCode != tc.code) { - t.Fatal("failed receipt", err) - } - } else if err != nil { - t.Fatal(err) - } - } - for _, raw := range []string{ - `{"version":1,"outcome":"failed","exit_code":0}`, - `{"version":1,"outcome":"failed","exit_code":256}`, - `{"version":1,"outcome":"failed","exit_code":3,"stderr":"secret"}`, - `{"version":1,"outcome":"completed","exit_code":3}`, - `{"version":1,"outcome":"failed"} {}`, - } { - if !errors.Is(decodeInitialization(initializationResult{stdout: []byte(raw), exit: 1}), ErrInitializationUnconfirmed) { - t.Fatal("unsafe receipt") - } - } -} - -func TestRuntimeInitializationProcessDrainsAndCancels(t *testing.T) { - t.Setenv("RUNTIME_TEST_PRIVATE", "do-not-inherit") - result, err := runInitializationProcess(t.Context(), "/bin/sh", []string{"-c", `test -z "$RUNTIME_TEST_PRIVATE" || exit 4; cat >/dev/null; printf '{"version":1,"outcome":"completed"}'`}, strings.NewReader("{}")) - if err != nil || decodeInitialization(result) != nil { - t.Fatal("clean process", err) - } - _, err = runInitializationProcess(t.Context(), "/bin/sh", []string{"-c", "head -c 2048 /dev/zero; sleep 5"}, strings.NewReader("")) - if !errors.Is(err, ErrInitializationUnconfirmed) { - t.Fatal("unbounded output accepted", err) - } - directory := t.TempDir() - ctx, cancel := context.WithCancel(t.Context()) - done := make(chan error, 1) - go func() { - _, err := runInitializationProcess(ctx, "/bin/sh", []string{"-c", `(trap '' TERM; sleep 1; touch "$1/late") & touch "$1/started"; wait`, "fixture", directory}, strings.NewReader("{}")) - done <- err - }() - deadline := time.Now().Add(3 * time.Second) - for { - if _, err := os.Stat(filepath.Join(directory, "started")); err == nil { - break - } - if time.Now().After(deadline) { - cancel() - <-done - t.Fatal("child did not start") - } - time.Sleep(5 * time.Millisecond) - } - cancel() - if err := <-done; !errors.Is(err, ErrInitializationUnconfirmed) { - t.Fatal("cancellation confirmed", err) - } - time.Sleep(1100 * time.Millisecond) - if _, err := os.Stat(filepath.Join(directory, "late")); !os.IsNotExist(err) { - t.Fatal("child survived return", err) - } -} - -func TestRuntimeInitializationPreservesSetupNetworkPhase(t *testing.T) { - for _, policy := range []string{"disabled", "restricted"} { - t.Run(policy, func(t *testing.T) { - b, _ := testBinding(t) - b.networkAccess = policy - t.Setenv("OAC_RUNTIME_NETWORK_ACCESS", policy) - // Initialization's typed network remains independent of native policy. - script := "import json, os, sys; request=json.load(sys.stdin); assert request['network']=='enabled'; assert 'OAC_RUNTIME_NETWORK_ACCESS' not in os.environ; print('{\"version\":1,\"outcome\":\"completed\"}')" - result, err := runInitializationProcess(t.Context(), "/usr/bin/python3", []string{"-I", "-S", "-c", script}, - strings.NewReader(`{"version":1,"action":"setup","network":"enabled","command":"true","cwd":"/workspace"}`)) - if err != nil || decodeInitialization(result) != nil || b.NetworkPolicy().Access != policy { - t.Fatal("initialization changed native network policy", err) - } - marker, err := b.openSnapshotMarker() - if err != nil { - t.Fatal(err) - } - if err = marker.complete(); err != nil { - marker.close() - t.Fatal(err) - } - marker.close() - input := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: b.environment, SessionID: b.capabilityIdentity().SessionID, - Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", Network: "enabled", Command: "true", CWD: "/workspace"}} - if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { - t.Fatal("initialization reopened after native admission", err) - } - }) - } -} - -func TestRuntimeInitialFileUsesBoundAtomicWriterAndAnchoredParents(t *testing.T) { - b := writableBinding(t, `[ "$#" = 4 ] || exit 7 -[ -z "$RUNTIME_TEST_PRIVATE" ] || exit 8 -cat >/dev/null -printf '{"version":1,"outcome":"completed","size_bytes":3}' -`) - t.Setenv("RUNTIME_TEST_PRIVATE", "secret") - if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: "/workspace/nested/child/file"}, []byte("abc")); err != nil { - t.Fatal(err) - } - info, err := os.Stat(filepath.Join(b.workspace, "nested/child")) - if err != nil || !info.IsDir() || info.Mode().Perm() != 0700 { - t.Fatal("parents", err) - } - outside := t.TempDir() - if err := os.Symlink(outside, filepath.Join(b.workspace, "escape")); err != nil { - t.Fatal(err) - } - if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: "/workspace/escape/new/file"}, []byte("abc")); err == nil { - t.Fatal("alias accepted") - } - if _, err := os.Stat(filepath.Join(outside, "new")); !os.IsNotExist(err) { - t.Fatal("escaped workspace") - } -} - -func TestRuntimePreparationRejectsFilesAfterFinalization(t *testing.T) { - b, req := testBinding(t) - configured, err := b.Configure(req) - if err != nil { - t.Fatal(err) - } - if _, err = b.Prepare(t.Context(), configured); err != nil { - t.Fatal(err) - } - digest := sha256.Sum256(nil) - input := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: b.environment, SessionID: b.capabilityIdentity().SessionID, - Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/file"}, SHA256: hex.EncodeToString(digest[:])} - if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { - t.Fatal("finalized Runtime accepted file", err) - } - input.Action = "initialize" - input.File = nil - input.SHA256 = "" - input.Initialization = &proto.RuntimeInitialization{Action: "configure", Env: map[string]string{}} - if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { - t.Fatal("finalized Runtime accepted initialize", err) - } -} - -func TestRuntimeInitialFileNativeAtomicReplacement(t *testing.T) { - helper := os.Getenv("OAC_TEST_LOCAL_WRITE_HELPER") - if helper == "" { - t.Skip("requires built native installer") - } - b := writableBinding(t, "exit 1\n") - if err := b.bindWriter(helper, b.writer.staging); err != nil { - t.Fatal(err) - } - path := "/workspace/n1/n2/file" - for _, body := range [][]byte{nil, []byte("first"), bytes.Repeat([]byte("x"), 1<<20), []byte("replacement")} { - if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: path}, body); err != nil { - t.Fatal(err) - } - actual, err := os.ReadFile(filepath.Join(b.workspace, "n1/n2/file")) - if err != nil || !bytes.Equal(actual, body) { - t.Fatal("atomic replacement", err) - } - } -} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_other.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_other.go deleted file mode 100644 index 16ba0a627..000000000 --- a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_other.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build !linux - -package localworkspace - -import ( - "context" - "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" - "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" -) - -func (b *Binding) initializeRuntime(context.Context, proto.RuntimeInitialization) error { - return agentcapabilities.ErrInvalid -} -func (b *Binding) installInitialFile(context.Context, proto.RuntimeInitialFile, []byte) error { - return agentcapabilities.ErrInvalid -} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go new file mode 100644 index 000000000..73ad59d35 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_process.go @@ -0,0 +1,147 @@ +package localworkspace + +import ( + "context" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" +) + +func initializationBash() (string, error) { + if runtime.GOOS != "windows" { + if binary, err := exec.LookPath("bash"); err == nil { + return binary, nil + } + return "", initializationDependency("bash") + } + // Git Bash preserves setup's Bash syntax. Never substitute cmd.exe, PowerShell + // or the Windows WSL launcher for a missing Bash implementation. + candidates := []string{} + if explicit := os.Getenv("CLAUDE_CODE_GIT_BASH_PATH"); explicit != "" { + candidates = append(candidates, explicit) + } else if git, err := exec.LookPath("git.exe"); err == nil { + candidates = append(candidates, filepath.Join(filepath.Dir(git), "..", "bin", "bash.exe"), filepath.Join(filepath.Dir(git), "bash.exe")) + } + for _, candidate := range candidates { + candidate = filepath.Clean(candidate) + if runtimefs.ValidateLocalPath(candidate) != nil { + continue + } + if info, err := os.Stat(candidate); err == nil && info.Mode().IsRegular() { + return candidate, nil + } + } + return "", initializationDependency("Git Bash") +} + +func initializationPython() (string, error) { + for _, name := range []string{"python3", "python"} { + if binary, err := exec.LookPath(name); err == nil { + return binary, nil + } + } + return "", initializationDependency("Python with pip") +} + +func initializationNPM() (string, []string, error) { + node, err := exec.LookPath("node") + if err != nil { + return "", nil, initializationDependency("Node.js") + } + npm, err := exec.LookPath("npm") + if err != nil { + return "", nil, initializationDependency("npm") + } + if runtime.GOOS != "windows" { + return npm, nil, nil + } + // npm's Windows launcher is a .cmd file. Use the installed CLI with Node rather + // than reinterpret package arguments through cmd.exe. + cli := filepath.Join(filepath.Dir(npm), "node_modules", "npm", "bin", "npm-cli.js") + if info, err := os.Stat(cli); err != nil || !info.Mode().IsRegular() { + return "", nil, initializationDependency("npm CLI") + } + return node, []string{cli}, nil +} + +func initializationEnvironment(configured map[string]string) []string { + values := map[string]string{} + for _, key := range []string{"PATH", "HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "PATHEXT", "LANG"} { + if value := os.Getenv(key); value != "" { + values[key] = value + } + } + if runtime.GOOS != "windows" && values["LANG"] == "" { + values["LANG"] = "C.UTF-8" + } + for key, value := range configured { + if runtime.GOOS == "windows" { + for existing := range values { + if strings.EqualFold(existing, key) { + delete(values, existing) + } + } + } + values[key] = value + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]string, 0, len(keys)) + for _, key := range keys { + result = append(result, key+"="+values[key]) + } + return result +} + +// The shared process owner settles the leader and descendants. Readers finish +// before return; output is discarded with constant memory, never put in errors. +func runInitializationProcess(ctx context.Context, binary string, args []string, directory string, env []string) error { + operation, cancel := context.WithTimeout(ctx, 30*time.Minute) + defer cancel() + if operation.Err() != nil { + return ErrInitializationUnconfirmed + } + if info, err := os.Stat(directory); err != nil || !info.IsDir() { + return &InitializationFailure{} + } + process, err := clirunner.Start(clirunner.StartOptions{Parent: operation, Binary: binary, Args: args, + Dir: directory, Env: env, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) + if err != nil { + return ErrInitializationUnconfirmed + } + defer process.Cancel() + finished := make(chan error, 2) + for _, stream := range []io.Reader{process.Stdout, process.Stderr} { + go func(stream io.Reader) { + _, err := io.Copy(io.Discard, stream) + if err != nil { + process.Cancel() + } + finished <- err + }(stream) + } + first, second := <-finished, <-finished + _ = process.Wait() + if operation.Err() != nil || first != nil || second != nil || process.Cmd.ProcessState == nil { + return ErrInitializationUnconfirmed + } + code := process.Cmd.ProcessState.ExitCode() + if code == 0 { + return nil + } + if code < 1 || code > 255 { + return ErrInitializationUnconfirmed + } + return &InitializationFailure{ExitCode: &code} +} diff --git a/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go new file mode 100644 index 000000000..762ec6854 --- /dev/null +++ b/apps/parsar-daemon/internal/localworkspace/runtime_initialization_test.go @@ -0,0 +1,318 @@ +package localworkspace + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentcapabilities" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +// The test executable doubles as a native package-manager fixture on all OSes. +// Dispatch before testing parses npm/pip arguments; no dependencies are fetched. +func init() { + if os.Getenv("OAC_INITIALIZATION_PACKAGE_FIXTURE") != "1" { + return + } + raw, _ := json.Marshal(os.Args[1:]) + if os.WriteFile(os.Getenv("OAC_INITIALIZATION_PACKAGE_RECEIPT"), raw, 0600) != nil { + os.Exit(9) + } + os.Exit(0) +} + +func TestRuntimeInitializationPackageTargets(t *testing.T) { + b := initializationFixture(t) + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + source, err := os.ReadFile(binary) + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + suffix := "" + if runtime.GOOS == "windows" { + suffix = ".exe" + } + for _, name := range []string{"node", "npm", "python3"} { + if err = os.WriteFile(filepath.Join(bin, name+suffix), source, 0700); err != nil { + t.Fatal(err) + } + } + if runtime.GOOS == "windows" { + cli := filepath.Join(bin, "node_modules", "npm", "bin", "npm-cli.js") + if err = os.MkdirAll(filepath.Dir(cli), 0700); err != nil { + t.Fatal(err) + } + if err = os.WriteFile(cli, nil, 0600); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", bin) + receipt := filepath.Join(t.TempDir(), "args.json") + if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"OAC_INITIALIZATION_PACKAGE_FIXTURE": "1", "OAC_INITIALIZATION_PACKAGE_RECEIPT": receipt}}); err != nil { + t.Fatal(err) + } + packages, _ := PackageDirectory() + for _, action := range []string{"npm", "python"} { + if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: action, Network: "enabled", Packages: []string{"name with spaces", "second"}}); err != nil { + t.Fatal(action, err) + } + raw, err := os.ReadFile(receipt) + if err != nil { + t.Fatal(err) + } + var args []string + if json.Unmarshal(raw, &args) != nil { + t.Fatal("invalid fixture receipt") + } + flag, target := "--prefix", filepath.Join(packages, "npm") + if action == "python" { + flag, target = "--target", filepath.Join(packages, "python") + } + found := false + for i := 0; i+1 < len(args); i++ { + if args[i] == flag && args[i+1] == target { + found = true + } + } + if !found || len(args) < 3 || args[len(args)-3] != "--" || args[len(args)-2] != "name with spaces" || args[len(args)-1] != "second" { + t.Fatal("package argv or local target mismatch", args) + } + } +} + +func TestRuntimeInitializationChild(t *testing.T) { + mode := os.Getenv("OAC_INITIALIZATION_FIXTURE") + if mode == "" { + return + } + directory := os.Getenv("OAC_INITIALIZATION_FIXTURE_DIR") + switch mode { + case "complete": + if os.Getenv("RUNTIME_TEST_PRIVATE") != "" { + os.Exit(9) + } + os.Stdout.Write(bytes.Repeat([]byte("private-output"), 10000)) + os.Stderr.Write(bytes.Repeat([]byte("private-error"), 10000)) + case "fail": + os.Exit(7) + case "child": + time.Sleep(time.Second) + _ = os.WriteFile(filepath.Join(directory, "late"), []byte("bad"), 0600) + case "parent": + binary, _ := os.Executable() + child := exec.Command(binary, "-test.run=^TestRuntimeInitializationChild$") + child.Env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=child", "OAC_INITIALIZATION_FIXTURE_DIR="+directory) + child.Stdout, child.Stderr = os.Stdout, os.Stderr + if child.Start() != nil { + os.Exit(8) + } + _ = os.WriteFile(filepath.Join(directory, "started"), []byte("ready"), 0600) + _ = child.Wait() + } + os.Exit(0) +} + +func initializationFixture(t *testing.T) *Binding { + t.Helper() + t.Setenv("OAC_RUNTIME_HOME", t.TempDir()) + t.Setenv("OAC_RUNTIME_INITIALIZATION_DIRECTORY", "") + t.Setenv("OAC_RUNTIME_PACKAGE_DIRECTORY", "") + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", "") + return &Binding{workspace: t.TempDir(), writer: &fileWriter{}} +} + +func TestRuntimeInitializationConfigurationAndDirectories(t *testing.T) { + b := initializationFixture(t) + config, err := InitializationDirectory() + if err != nil || config != filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "initialization") { + t.Fatal(config, err) + } + packages, err := PackageDirectory() + if err != nil || packages != filepath.Join(os.Getenv("OAC_RUNTIME_HOME"), "packages") { + t.Fatal(packages, err) + } + env := map[string]string{"VALUE": "'\n$(not-a-command)", "PYTHONPATH": "operator-path"} + if err = b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: env}); err != nil { + t.Fatal(err) + } + values, err := ReadToolEnvironment() + if err != nil || values["VALUE"] != env["VALUE"] || !strings.HasPrefix(values["PYTHONPATH"], filepath.Join(packages, "python")) { + t.Fatal("configuration mismatch", err) + } + if b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"VALUE": "changed"}}) == nil { + t.Fatal("configuration replaced") + } + values, _ = ReadToolEnvironment() + if values["VALUE"] != env["VALUE"] { + t.Fatal("configuration changed") + } + for setting, resolver := range map[string]func() (string, error){"OAC_RUNTIME_INITIALIZATION_DIRECTORY": InitializationDirectory, "OAC_RUNTIME_PACKAGE_DIRECTORY": PackageDirectory} { + selected := t.TempDir() + t.Setenv(setting, selected) + if actual, err := resolver(); err != nil || actual != selected { + t.Fatal("operator directory ignored", err) + } + } +} + +func TestRuntimeInitializationExplicitToolEnvironment(t *testing.T) { + initializationFixture(t) + file := filepath.Join(t.TempDir(), "explicit.json") + if err := os.WriteFile(file, []byte(`{"DECLARED":"value"}`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("OAC_RUNTIME_TOOL_ENV_FILE", file) + env, err := ReadOptionalToolEnvironment() + if err != nil || env["DECLARED"] != "value" { + t.Fatal("explicit tool environment", err) + } +} + +func TestRuntimeInitializationSetupUsesBashAndPhysicalWorkspace(t *testing.T) { + b := initializationFixture(t) + if _, err := initializationBash(); err != nil { + t.Skip("Bash unavailable; native dependency failure is tested separately") + } + if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure", Env: map[string]string{"VALUE": "configured"}}); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(b.workspace, "sub"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(b.workspace, "proof.sh"), []byte("printf skill-proof"), 0600); err != nil { + t.Fatal(err) + } + err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "setup", Network: "enabled", CWD: "/workspace/sub", Command: `. ../proof.sh > proof; printf '%s' "$VALUE" > value`}) + if err != nil { + t.Fatal(err) + } + for name, want := range map[string]string{"proof": "skill-proof", "value": "configured"} { + raw, err := os.ReadFile(filepath.Join(b.workspace, "sub", name)) + if err != nil || string(raw) != want { + t.Fatal(name, err) + } + } +} + +func TestRuntimeInitializationMissingDependenciesAndInvalidRequests(t *testing.T) { + b := initializationFixture(t) + t.Setenv("PATH", t.TempDir()) + t.Setenv("CLAUDE_CODE_GIT_BASH_PATH", "") + if err := b.initializeRuntime(t.Context(), proto.RuntimeInitialization{Action: "configure"}); err != nil { + t.Fatal(err) + } + for _, input := range []proto.RuntimeInitialization{{Action: "setup", Network: "enabled", Command: "true"}, {Action: "npm", Network: "enabled", Packages: []string{"valid"}}, {Action: "python", Network: "enabled", Packages: []string{"valid"}}} { + var failed *InitializationFailure + if err := b.initializeRuntime(t.Context(), input); !errors.As(err, &failed) || !strings.Contains(err.Error(), "requires") { + t.Fatal("missing dependency was not explicit", input.Action, err) + } + } + for _, input := range []proto.RuntimeInitialization{{Action: "system"}, {Action: "setup", Network: "enabled", Command: "true", CWD: "/workspace/../outside"}, {Action: "npm", Network: "enabled", Packages: []string{"--unsafe"}}} { + if !errors.Is(b.initializeRuntime(t.Context(), input), agentcapabilities.ErrInvalid) { + t.Fatal("invalid request accepted", input.Action) + } + } +} + +func TestRuntimeInitializationProcessSettlesAndDiscardsOutput(t *testing.T) { + t.Setenv("RUNTIME_TEST_PRIVATE", "do-not-inherit") + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + env := append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=complete") + if err = runInitializationProcess(t.Context(), binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env); err != nil { + t.Fatal(err) + } + env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=fail") + err = runInitializationProcess(t.Context(), binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env) + var failed *InitializationFailure + if !errors.As(err, &failed) || failed.ExitCode == nil || *failed.ExitCode != 7 { + t.Fatal("exit status", err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + env = append(initializationEnvironment(nil), "OAC_INITIALIZATION_FIXTURE=parent", "OAC_INITIALIZATION_FIXTURE_DIR="+directory) + done := make(chan error, 1) + go func() { + done <- runInitializationProcess(ctx, binary, []string{"-test.run=^TestRuntimeInitializationChild$"}, directory, env) + }() + deadline := time.Now().Add(5 * time.Second) + for { + if _, err = os.Stat(filepath.Join(directory, "started")); err == nil { + break + } + if time.Now().After(deadline) { + cancel() + <-done + t.Fatal("fixture startup timeout") + } + time.Sleep(5 * time.Millisecond) + } + cancel() + if err = <-done; !errors.Is(err, ErrInitializationUnconfirmed) { + t.Fatal("cancel should be unknown", err) + } + time.Sleep(1100 * time.Millisecond) + if _, err = os.Stat(filepath.Join(directory, "late")); !os.IsNotExist(err) { + t.Fatal("descendant survived initialization") + } +} + +func TestRuntimeInitialFileAtomicReplacement(t *testing.T) { + b := initializationFixture(t) + path := "/workspace/nested/child/file" + for _, body := range [][]byte{nil, []byte("first"), bytes.Repeat([]byte("x"), 1<<20), []byte("replacement")} { + if err := b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: path}, body); err != nil { + t.Fatal(err) + } + actual, err := os.ReadFile(filepath.Join(b.workspace, "nested", "child", "file")) + if err != nil || !bytes.Equal(actual, body) { + t.Fatal("atomic replacement", err) + } + } + for _, invalid := range []string{"/elsewhere/file", "/workspace/../outside", "/workspace/a\\b"} { + if !errors.Is(b.installInitialFile(t.Context(), proto.RuntimeInitialFile{Path: invalid}, []byte("x")), agentcapabilities.ErrInvalid) { + t.Fatal("invalid path accepted") + } + } +} +func TestRuntimePreparationRejectsFilesAfterFinalization(t *testing.T) { + b, req := testBinding(t) + configured, err := b.Configure(req) + if err != nil { + t.Fatal(err) + } + if _, err = b.Prepare(t.Context(), configured); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(nil) + input := proto.RuntimePreparePayload{Step: "begin", EnvironmentID: b.environment, SessionID: b.capabilityIdentity().SessionID, + Action: "file", File: &proto.RuntimeInitialFile{Path: "/workspace/file"}, SHA256: hex.EncodeToString(digest[:])} + if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { + t.Fatal("finalized Runtime accepted file", err) + } + input.Action = "initialize" + input.File = nil + input.SHA256 = "" + input.Initialization = &proto.RuntimeInitialization{Action: "configure", Env: map[string]string{}} + if err = b.ApplyRuntimePreparation(t.Context(), input, nil); !errors.Is(err, agentcapabilities.ErrInvalid) { + t.Fatal("finalized Runtime accepted initialize", err) + } +} diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index d8dcda463..57bf5cdda 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -1697,14 +1697,9 @@ definitions: items: type: string type: array - system: - items: - type: string - type: array required: - npm - python - - system type: object v1.EnvironmentTemplate: properties: diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index c24705ceb..4cd47065e 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -532,14 +532,9 @@ definitions: items: type: string type: array - system: - items: - type: string - type: array required: - npm - python - - system type: object v1.EnvironmentPackagesInput: properties: @@ -553,11 +548,6 @@ definitions: type: string type: array x-nullable: true - system: - items: - type: string - type: array - x-nullable: true type: object v1.EnvironmentTemplate: properties: @@ -2674,13 +2664,15 @@ paths: - application/json description: Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id - files, confidential env, ordered setup_commands, system/npm/Python packages - inline/referenced Skill ZIPs, Plugin ZIPs and workspace-contained capability - directories. Omitted/null network defaults to enabled. Restricted network - requires 1–100 exact ASCII hostnames; other host forms and populated unsupported - installations are rejected before persistence without echoing input. Network - policy rejections return invalid_request_error with a null param. No compute - is allocated. Exact hosted error/retry semantics remain unverified. + files, confidential env, ordered setup_commands, npm/Python packages inline/referenced + Skill ZIPs, Plugin ZIPs and workspace-contained capability directories. Omitted/null + network defaults to enabled. Restricted network requires 1–100 exact ASCII + hostnames; other host forms and populated unsupported installations are rejected + before persistence without echoing input. Network policy rejections return + invalid_request_error with a null param. System dependencies must be preinstalled + in the sandbox image or template, or on the host machine; packages.system + is rejected. No compute is allocated. Exact hosted error/retry semantics remain + unverified. parameters: - description: agents=v1 in: header @@ -3028,34 +3020,35 @@ paths: provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported - hostname forms and startup installations are rejected. Confidential env, system/npm/Python + hostname forms and startup installations are rejected. Confidential env, npm/Python packages and ordered setup commands use the shared initialization lifecycle; - requested network applies after setup. Initial inline and tenant-owned file_id - files freeze encrypted bytes before provisioning, then install through the - common Core lifecycle before native execution or live Files access. With a - template reference, omitted/null files, env, packages and setup_commands inherit. - Non-null files and command lists replace; env overlays by key; each package - manager inherits on omission/null and otherwise replaces its list. Empty lists - clear their selected field. Tenant-owned environment_template_id references - inherit omitted/null network and allow only narrowing overrides. Inline hosted - network:null retains the enabled default; updating a Template with network:null - resets its saved policy to enabled. Core freezes effective configuration; - template updates/deletion do not alter Session snapshots or same-intent creation - retries. Inline or tenant-owned skill_reference Skills share initialization. - Templates preserve default/latest/explicit selectors; Session creation freezes - concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory - list omission/null inherit; a non-null list replaces, including empty-list - clearing. Omitted/null Skill version selectors resolve the default version. - Source deletion/default updates cannot change committed Session Skill contents. - Deferred function discovery uses type-only tool_search and per-function defer_loading - in the qualified single-agent Claude environment:none function profile, including - qualified inline image messages and text results. Explicit web_search mode - disabled and programmatic_tool_calling enabled false use frozen common Runtime - controls. Enabled forms, including those saved on an Agent, remain unqualified - and reject before any write unless the Session replaces tools. Omitted programmatic - configuration preserves native behavior, a documented difference from the - official default-on behavior. Other combinations remain unqualified; see the - operation coverage. + requested network applies after setup. System dependencies must be preinstalled + in the sandbox image or template, or on the host machine; packages.system + is rejected. Initial inline and tenant-owned file_id files freeze encrypted + bytes before provisioning, then install through the common Core lifecycle + before native execution or live Files access. With a template reference, omitted/null + files, env, packages and setup_commands inherit. Non-null files and command + lists replace; env overlays by key; each package manager inherits on omission/null + and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned + environment_template_id references inherit omitted/null network and allow + only narrowing overrides. Inline hosted network:null retains the enabled default; + updating a Template with network:null resets its saved policy to enabled. + Core freezes effective configuration; template updates/deletion do not alter + Session snapshots or same-intent creation retries. Inline or tenant-owned + skill_reference Skills share initialization. Templates preserve default/latest/explicit + selectors; Session creation freezes concrete metadata and encrypted content + atomically. Skill, Plugin and capability-directory list omission/null inherit; + a non-null list replaces, including empty-list clearing. Omitted/null Skill + version selectors resolve the default version. Source deletion/default updates + cannot change committed Session Skill contents. Deferred function discovery + uses type-only tool_search and per-function defer_loading in the qualified + single-agent Claude environment:none function profile, including qualified + inline image messages and text results. Explicit web_search mode disabled + and programmatic_tool_calling enabled false use frozen common Runtime controls. + Enabled forms, including those saved on an Agent, remain unqualified and reject + before any write unless the Session replaces tools. Omitted programmatic configuration + preserves native behavior, a documented difference from the official default-on + behavior. Other combinations remain unqualified; see the operation coverage. parameters: - description: agents=v1 in: header diff --git a/contracts/agents-api/v1/environment_templates.go b/contracts/agents-api/v1/environment_templates.go index 05e013312..b2710750e 100644 --- a/contracts/agents-api/v1/environment_templates.go +++ b/contracts/agents-api/v1/environment_templates.go @@ -20,7 +20,6 @@ type EnvironmentTemplateRequest struct { type EnvironmentPackagesInput struct { NPM []string `json:"npm,omitempty" extensions:"x-nullable"` Python []string `json:"python,omitempty" extensions:"x-nullable"` - System []string `json:"system,omitempty" extensions:"x-nullable"` } // EnvironmentTemplate returns safe configuration metadata only. diff --git a/contracts/agents-api/v1/environments.go b/contracts/agents-api/v1/environments.go index 85f1861a5..50ccbada8 100644 --- a/contracts/agents-api/v1/environments.go +++ b/contracts/agents-api/v1/environments.go @@ -28,5 +28,4 @@ type EnvironmentNetworkInput struct { type EnvironmentPackages struct { NPM []string `json:"npm" binding:"required"` Python []string `json:"python" binding:"required"` - System []string `json:"system" binding:"required"` } diff --git a/docs/self-hosted-native.md b/docs/self-hosted-native.md index cf8c9e364..b536e3811 100644 --- a/docs/self-hosted-native.md +++ b/docs/self-hosted-native.md @@ -92,6 +92,22 @@ For example, a tool environment file can contain `{"EXAMPLE_SETTING":"value"}`. Treat files containing credentials as secrets. Retain the credential and tool configuration files at their configured paths; installation records those paths. +Runtime preparation uses one Go implementation on all platforms. Its local +initialization and package directories default to `initialization` and `packages` +under `OAC_RUNTIME_HOME`. Operators can select absolute paths with +`OAC_RUNTIME_INITIALIZATION_DIRECTORY` and `OAC_RUNTIME_PACKAGE_DIRECTORY`; +managed images set these to `/environment/initialization` and +`/environment/packages`. They change storage layout, not execution permissions. +`OAC_RUNTIME_TOOL_ENV_FILE` selects an explicit tool-variable JSON file; preparation +does not overwrite an existing configuration. + +npm and Python packages install only into the Runtime package directory using +npm's prefix and pip's target options. Node/npm and Python/pip must already be +installed. Setup requires Bash; on Windows it requires Git Bash, found from Git's +installation or `CLAUDE_CODE_GIT_BASH_PATH`. Missing dependencies fail preparation; +Runtime does not substitute PowerShell, cmd.exe or WSL for Bash setup. + + `oac-daemon start --foreground` stays attached to the terminal instead of starting in the background. An operator can use their own service manager to run it; the native installer does not configure one. @@ -133,7 +149,9 @@ upgrade, adoption of a historical container installation, or cross-version nativ history migration. Preserve an old installation's files and use a fresh Runtime home and Session when moving versions. -Managed system-package installation remains under its existing contract while the -ordinary apt ownership decision is pending. This guide does not claim that apt has -been migrated or that a native daemon can install packages without the launching -user's required permissions. +System dependencies must be present before Runtime execution: install them while +building the managed image/template, or prepare them as the self-hosted user. +Runtime does not run apt, request sudo or elevate the daemon's privileges. +`system_packages` is unsupported; a missing dependency fails the operation that +requires it. npm/Python package installation and setup retain their supported +initialization flow using the launching user's existing permissions. diff --git a/internal/agentdaemon/proto/environment.go b/internal/agentdaemon/proto/environment.go index 11712269f..5507b0f18 100644 --- a/internal/agentdaemon/proto/environment.go +++ b/internal/agentdaemon/proto/environment.go @@ -8,7 +8,6 @@ import ( // LocalEnvironment names a frozen workspace selection. Runtime must verify it // against the bound local root before resolving capabilities or native execution. type LocalEnvironment struct { - ExecutionMode string `json:"-"` CapabilityRoot string `json:"-"` ID string `json:"id"` WorkspaceDirectory string `json:"workspace_directory"` @@ -22,8 +21,6 @@ type LocalEnvironment struct { MCP []EnvironmentMCP `json:"-"` // ToolEnvironment consumes Core-completed confidential initialization. ToolEnvironment bool `json:"tool_environment,omitempty"` - // SystemPackages requires the installed Runtime tool root during execution. - SystemPackages bool `json:"system_packages,omitempty"` // NetworkAccess must match the immutable Runtime policy for execution. NetworkAccess string `json:"network_access,omitempty"` AllowedDomains []string `json:"allowed_domains,omitempty"` @@ -32,7 +29,6 @@ type LocalEnvironment struct { // EnvironmentMCP is transient Runtime configuration. Do not log it: HTTP headers // and the selected user bearer may be confidential. It is not agent.tools MCP. type EnvironmentMCP struct { - ExecutionMode string `json:"-"` InstallationRoot string WorkspaceRoot string PackageRoot string diff --git a/internal/agentdaemon/proto/runtime_prepare.go b/internal/agentdaemon/proto/runtime_prepare.go index 921f75efc..c5e17b0f9 100644 --- a/internal/agentdaemon/proto/runtime_prepare.go +++ b/internal/agentdaemon/proto/runtime_prepare.go @@ -196,7 +196,7 @@ func validRuntimeInitialization(p RuntimeInitialization) bool { } } return true - case "system", "npm", "python": + case "npm", "python": if p.Env != nil || p.Command != "" || p.CWD != "" || len(p.Packages) == 0 || len(p.Packages) > 1000 || p.Network == "" { return false } diff --git a/internal/agentdaemon/proto/runtime_prepare_test.go b/internal/agentdaemon/proto/runtime_prepare_test.go index 588520ca8..51efa53cc 100644 --- a/internal/agentdaemon/proto/runtime_prepare_test.go +++ b/internal/agentdaemon/proto/runtime_prepare_test.go @@ -176,7 +176,6 @@ func TestRuntimePreparationInitialActions(t *testing.T) { base := RuntimePreparePayload{Step: "begin", EnvironmentID: uuid.NewString(), SessionID: uuid.NewString()} for _, initialization := range []RuntimeInitialization{ {Action: "configure", Env: map[string]string{"EXAMPLE": "value"}}, - {Action: "system", Network: "enabled", Packages: []string{"git"}}, {Action: "npm", Network: "disabled", Packages: []string{"typescript"}}, {Action: "python", Network: "enabled", Packages: []string{"requests"}}, {Action: "setup", Network: "enabled", Command: "echo done"}, @@ -204,7 +203,7 @@ func TestRuntimePreparationInitialActions(t *testing.T) { {Action: "exec", Command: "echo done"}, {Action: "configure", Packages: []string{"git"}}, {Action: "configure", Env: map[string]string{"A=B": "value"}}, - {Action: "system", Network: "enabled"}, + {Action: "system", Network: "enabled", Packages: []string{"git"}}, {Action: "npm", Network: "invalid", Packages: []string{"a"}}, {Action: "python", Network: "enabled", Packages: []string{"--help"}}, {Action: "setup", Network: "enabled", Command: "x", CWD: "/environment"}, diff --git a/packages/claude-sdk-adapter/src/workspace.ts b/packages/claude-sdk-adapter/src/workspace.ts index 0fa9ed9d0..5e7cb5078 100644 --- a/packages/claude-sdk-adapter/src/workspace.ts +++ b/packages/claude-sdk-adapter/src/workspace.ts @@ -7,7 +7,6 @@ import { realpathSync, statSync } from "node:fs"; import { isAbsolute, parse, resolve } from "node:path"; export type Workspace = { - execution_mode?: "native"; tool_env?: Record; home: string; state: string; @@ -45,8 +44,7 @@ export function parseWorkspace(value: unknown, cwd: string): Workspace | undefin if (value === undefined) return undefined; if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const config = value as Record; - if (Object.keys(config).some(key => !["tool_env", "execution_mode", "home", "state", "scratch", "env_names", "network_access", "allowed_domains", "skills", "mcp", "capability_root"].includes(key)) || - (config.execution_mode !== undefined && config.execution_mode !== "native") || + if (Object.keys(config).some(key => !["tool_env", "home", "state", "scratch", "env_names", "network_access", "allowed_domains", "skills", "mcp", "capability_root"].includes(key)) || (config.network_access !== undefined && config.network_access !== "enabled" && config.network_access !== "disabled" && config.network_access !== "restricted") || !Array.isArray(config.env_names) || config.env_names.some(name => typeof name !== "string" || !environmentNames.has(name)) || @@ -79,7 +77,10 @@ export class WorkspaceProfile { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", DISABLE_TELEMETRY: "1", DISABLE_ERROR_REPORTING: "1", DISABLE_AUTOUPDATER: "1", CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1", }; - Object.assign(env, config.tool_env ?? {}); + for (const [name, value] of Object.entries(config.tool_env ?? {})) { + // Initialization cannot redirect the native Session history lookup. + if (!["HOME", "USERPROFILE", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_PROJECT_DIR_NAME"].includes(name.toUpperCase())) env[name] = value; + } for (const name of config.env_names) { const value = process.env[name]; if (value === undefined) throw new Error("invalid_request"); diff --git a/packages/claude-sdk-adapter/tests/workspace.test.mjs b/packages/claude-sdk-adapter/tests/workspace.test.mjs index 36f51f77a..53b669d56 100644 --- a/packages/claude-sdk-adapter/tests/workspace.test.mjs +++ b/packages/claude-sdk-adapter/tests/workspace.test.mjs @@ -183,9 +183,12 @@ test("workspace functions retain native sandbox and exact tool authority", async test("initialized environment is ordinary child environment on every layout", t => { const {dirs,config}=fixture(t); - for(const execution_mode of [undefined,"native"]) { - const profile=new WorkspaceProfile(dirs.workspace,{...config,execution_mode,tool_env:{USER_VALUE:"initialized"}}); + { + const profile=new WorkspaceProfile(dirs.workspace,{...config,tool_env:{USER_VALUE:"initialized",HOME:"/wrong",CLAUDE_CONFIG_DIR:"/wrong",home:"/wrong"}}); assert.equal(profile.options.env.USER_VALUE,"initialized"); + assert.equal(profile.options.env.HOME,dirs.home); + assert.equal(profile.options.env.CLAUDE_CONFIG_DIR,dirs.state); + assert.equal(profile.options.env.home,undefined); assert.deepEqual(profile.options.sandbox,{enabled:false}); } }); diff --git a/packages/mcode-harness/launch.test.mjs b/packages/mcode-harness/launch.test.mjs index ea930c6f4..647c8fcf3 100644 --- a/packages/mcode-harness/launch.test.mjs +++ b/packages/mcode-harness/launch.test.mjs @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import {mkdtemp,mkdir,writeFile,copyFile,rm} from 'node:fs/promises'; +import {mkdtemp,mkdir,writeFile,copyFile,rm,realpath} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {ToolExecutor} from './tool-executor.mjs'; test('Runtime directly executes host worker with bound cwd and initialized environment',async t=>{ - const root=await mkdtemp(join(tmpdir(),'mcode-host-')); + const root=await realpath(await mkdtemp(join(tmpdir(),'mcode-host-'))); t.after(()=>rm(root,{recursive:true,force:true})); await mkdir(join(root,'dist')); const workspace=join(root,'workspace');await mkdir(workspace); diff --git a/scripts/build-agents-api.sh b/scripts/build-agents-api.sh index e6bb8add7..e675ada78 100755 --- a/scripts/build-agents-api.sh +++ b/scripts/build-agents-api.sh @@ -27,7 +27,7 @@ tar -C "$repo_root" -cf - \ go.mod go.sum \ contracts/agents-api/v1 \ internal/agentdaemon/device internal/agentdaemon/gateway internal/agentdaemon/proto \ - internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/harnessconfig internal/obs/log services/agents-api \ + internal/runtimefs internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/harnessconfig internal/obs/log services/agents-api \ | tar -C "$build_context" -xf - ( diff --git a/scripts/build-agents-runtime.sh b/scripts/build-agents-runtime.sh index e4899682b..3f812dafc 100755 --- a/scripts/build-agents-runtime.sh +++ b/scripts/build-agents-runtime.sh @@ -35,8 +35,6 @@ cp "$helpers_dir/oac-codex-directory" "$helpers_dir/oac-codex-write" "$helpers_d cp "$native_dir/bin/codex" "$context/codex" cp -R "$native_dir/codex-resources" "$context/codex-resources" cp "$repo_root/services/agents-api/deploy/codex/Dockerfile" "$context/Dockerfile" -cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" -cp "$repo_root/services/agents-api/deploy/runtime/build-system-seed.py" "$repo_root/services/agents-api/deploy/runtime/tool-root.py" "$context/" # Preserve the previous bundle if compilation or validation failed. mkdir -p "$output_dir" cp -R "$context/." "$output_dir/" diff --git a/scripts/build-claude-runtime.sh b/scripts/build-claude-runtime.sh index 5f81b5021..025f5b9ee 100755 --- a/scripts/build-claude-runtime.sh +++ b/scripts/build-claude-runtime.sh @@ -27,8 +27,6 @@ for helper in oac-codex-directory oac-codex-write oac-workspace-export; do cp "$helpers_dir/$helper" "$context/" done cp "$repo_root/services/agents-api/deploy/claude/Dockerfile" "$context/Dockerfile" -cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" -cp "$repo_root/services/agents-api/deploy/runtime/build-system-seed.py" "$repo_root/services/agents-api/deploy/runtime/tool-root.py" "$context/" mkdir -p "$output_dir" cp -R "$context/." "$output_dir/" printf 'Claude Runtime image context: %s\n' "$output_dir" diff --git a/scripts/build-mcode-runtime.sh b/scripts/build-mcode-runtime.sh index 3126f7875..49412c48f 100644 --- a/scripts/build-mcode-runtime.sh +++ b/scripts/build-mcode-runtime.sh @@ -28,8 +28,6 @@ for helper in oac-codex-directory oac-codex-write oac-workspace-export; do cp "$helpers/$helper" "$context/" done cp "$repo_root/services/agents-api/deploy/mcode/Dockerfile" "$context/Dockerfile" -cp "$repo_root/services/agents-api/deploy/runtime/initialize.py" "$context/runtime-initialize.py" -cp "$repo_root/services/agents-api/deploy/runtime/build-system-seed.py" "$repo_root/services/agents-api/deploy/runtime/tool-root.py" "$context/" mkdir -p "$output" cp -R "$context/." "$output/" printf 'MiniMax Code Runtime image context: %s\n' "$output" diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index 78e9411dc..cd8aa7c29 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -164,8 +164,6 @@ def verify_runtime(image, daemon, helpers, source): files = {"/usr/local/bin/oac-daemon": pathlib.Path(daemon)} for name in ("oac-codex-directory", "oac-codex-write", "oac-workspace-export"): files["/usr/local/bin/" + name] = helpers / name - files["/usr/local/bin/oac-runtime-initialize"] = source / "services/agents-api/deploy/runtime/initialize.py" - files["/usr/local/bin/oac-tool-root"] = source / "services/agents-api/deploy/runtime/tool-root.py" environment = dict(value.split("=", 1) for value in details["Config"]["Env"] if "=" in value) if "OAC_RUNTIME_MCODE_BIN" in environment: for name in ("launch.mjs", "bridge.mjs", "check.mjs", "tool-executor.mjs", "subagent-snapshot.mjs", "source.json"): diff --git a/scripts/native-harness-smoke.mjs b/scripts/native-harness-smoke.mjs new file mode 100644 index 000000000..76d48c757 --- /dev/null +++ b/scripts/native-harness-smoke.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// No model requests: validate installed native engines and their startup protocols. +import { spawn, spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { createInterface } from 'node:readline'; + +const options = {}; +for (let i = 2; i < process.argv.length; i += 2) { + const key = process.argv[i]; + if (!['--codex-package', '--codex-binary', '--claude-runtime', '--only'].includes(key) || !process.argv[i + 1]) { + throw new Error('Expected --codex-package PATH, --codex-binary PATH, --claude-runtime PATH or --only codex|claude'); + } + options[key] = process.argv[i + 1]; +} +if (options['--only'] && !['codex', 'claude'].includes(options['--only'])) throw new Error('Invalid --only selection'); +const limit = 1024 * 1024; +const failure = code => Object.assign(new Error(code), { safeCode: code }); +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +async function codexSmoke(root) { + let binary = options['--codex-binary'] ?? 'codex'; + let prefix = []; + if (options['--codex-package']) { + const directory = resolve(options['--codex-package']); + const manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')); + if (manifest.version !== '0.153.4') throw failure('codex_package_version'); + binary = process.execPath; + prefix = [join(directory, 'bin', 'codex.js')]; + } + const home = join(root, 'codex-home'); + const workspace = join(root, 'workspace'); + await mkdir(home); await mkdir(workspace); + const env = { ...process.env, CODEX_HOME: home }; + delete env.OPENAI_API_KEY; delete env.CODEX_API_KEY; + const version = spawnSync(binary, [...prefix, '--version'], { env, encoding: 'utf8', timeout: 15000, maxBuffer: limit }); + if (version.error || version.status !== 0 || version.stdout.trim() !== 'codex-cli 0.153.4') throw failure('codex_native_version'); + const child = spawn(binary, [...prefix, 'app-server', '--listen', 'stdio://'], { env, cwd: workspace, stdio: ['pipe', 'pipe', 'pipe'] }); + const closed = new Promise(resolve => child.once('close', resolve)); + let pending; + let bytes = 0; + let broken; + const rejectPending = code => { broken = failure(code); pending?.reject(broken); }; + child.on('error', () => rejectPending('codex_spawn')); + child.stdin.on('error', () => rejectPending('codex_stdin')); + child.stderr.resume(); + child.stdout.on('data', chunk => { bytes += chunk.length; if (bytes > limit) rejectPending('codex_output_limit'); }); + const lines = createInterface({ input: child.stdout }); + lines.on('line', line => { + try { + const message = JSON.parse(line); + if (pending && message.id === pending.id) { + if (message.error) pending.reject(failure('codex_rpc')); + else pending.resolve(message.result); + } + } catch { rejectPending('codex_invalid_json'); } + }); + child.on('close', () => rejectPending('codex_early_exit')); + let sequence = 0; + const request = async (method, params) => { + if (broken) throw broken; + const id = ++sequence; + let timer; + try { + return await new Promise((resolve, reject) => { + pending = { id, resolve, reject }; + timer = setTimeout(() => reject(failure('codex_rpc_timeout')), 15000); + child.stdin.write(JSON.stringify({ id, method, params }) + '\n'); + }); + } finally { clearTimeout(timer); pending = undefined; } + }; + try { + await request('initialize', { clientInfo: { name: 'parsar-native-smoke', version: '1' }, capabilities: { experimentalApi: true } }); + child.stdin.write(JSON.stringify({ method: 'initialized' }) + '\n'); + const result = await request('thread/start', { cwd: workspace, approvalPolicy: 'never', sandbox: 'danger-full-access', persistExtendedHistory: true }); + if (typeof result?.thread?.id !== 'string' || !result.thread.id) throw failure('codex_thread_identity'); + return { status: 'passed', version: '0.153.4', initialize: true, thread_start: true }; + } finally { + child.stdin.end(); + if (await Promise.race([closed.then(() => true), delay(5000).then(() => false)]) === false) { + if (process.platform === 'win32' && child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000 }); + else child.kill('SIGKILL'); + await Promise.race([closed, delay(5000)]); + } + lines.close(); + } +} + +async function claudeSmoke() { + const root = resolve(options['--claude-runtime'] ?? 'packages/claude-sdk-adapter'); + const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); + if (manifest.dependencies?.['@anthropic-ai/claude-agent-sdk']?.split('(')[0] !== '0.3.269') throw failure('claude_package_version'); + const result = spawnSync(process.execPath, [join(root, 'dist', 'runtime_check.js')], { + cwd: root, encoding: 'utf8', timeout: 20000, maxBuffer: limit, + }); + if (result.error || result.status !== 0) throw failure('claude_runtime_unavailable'); + let info; + try { info = JSON.parse(result.stdout); } catch { throw failure('claude_invalid_receipt'); } + if (info.type !== 'runtime_ready' || info.sdk !== '0.3.269' || info.native !== '2.1.269 (Claude Code)' || !info.features?.includes('local_runtime_v1')) throw failure('claude_runtime_identity'); + return { status: 'passed', sdk: info.sdk, native: info.native }; +} + +const root = await realpath(await mkdtemp(join(tmpdir(), 'parsar-native-smoke-'))); +const report = { platform: process.platform, arch: process.arch, model_requests: 0 }; +try { + for (const [name, run] of [['codex', () => codexSmoke(root)], ['claude', claudeSmoke]]) { + if (options['--only'] && options['--only'] !== name) continue; + try { report[name] = await run(); } + catch (error) { report[name] = { status: 'failed', code: error.safeCode ?? 'native_smoke_unavailable' }; process.exitCode = 1; } + } +} finally { + await rm(root, { recursive: true, force: true }); +} +// Deliberately exclude child stdout/stderr and request contents from CI output. +console.log(JSON.stringify(report)); diff --git a/services/agents-api/deploy/claude/Dockerfile b/services/agents-api/deploy/claude/Dockerfile index 22ff7cd15..e33d13ed2 100644 --- a/services/agents-api/deploy/claude/Dockerfile +++ b/services/agents-api/deploy/claude/Dockerfile @@ -2,20 +2,18 @@ FROM node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27 USER root RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates bash git python3 python3-pip ripgrep bubblewrap socat \ + ca-certificates bash git python3 python3-pip ripgrep \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /environment/workspace /workspace /home/runtime -COPY build-system-seed.py /tmp/build-system-seed.py -RUN python3 -I -S /tmp/build-system-seed.py && rm /tmp/build-system-seed.py -COPY --chmod=0555 tool-root.py /usr/local/bin/oac-tool-root COPY --chmod=0555 oac-daemon oac-codex-directory oac-codex-write oac-workspace-export /usr/local/bin/ COPY claude-sdk /opt/claude-sdk -COPY --chmod=0444 runtime-initialize.py /usr/local/bin/oac-runtime-initialize ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ OAC_RUNTIME_CLAUDE_SDK_NODE=/usr/local/bin/node \ OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT=/opt/claude-sdk/dist/main.js \ OAC_RUNTIME_CLAUDE_SDK_WORKSPACE=managed \ OAC_RUNTIME_WORKSPACE=/environment/workspace \ + OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization \ + OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages \ OAC_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/oac-codex-directory \ OAC_RUNTIME_WRITE_HELPER=/usr/local/bin/oac-codex-write \ OAC_RUNTIME_EXPORT_HELPER=/usr/local/bin/oac-workspace-export \ diff --git a/services/agents-api/deploy/claude/README.md b/services/agents-api/deploy/claude/README.md index 7dec49947..72a87aa21 100644 --- a/services/agents-api/deploy/claude/README.md +++ b/services/agents-api/deploy/claude/README.md @@ -17,11 +17,18 @@ docker build --platform linux/amd64 -t agents-runtime:claude \ The bundle pins SDK `0.3.269` and native Claude Code `2.1.269`. The Dockerfile pins Node's Linux amd64 manifest. Keep the exported SDK bundle immutable. Configure -Core's existing managed Runtime file with the resulting immutable image ID, a -private Docker network, the existing `deploy/codex/seccomp.json`, and -`"nested_sandbox": true`. This option enables child reaping and the outer procfs -layout required by native bubblewrap; all native sandbox restrictions remain on. -It is disabled for existing deployments unless explicitly selected. +Core's database-owned managed deployment with the resulting immutable Runtime +image. Existing Docker outer security settings are unchanged; the daemon and +native adapter do not add an inner sandbox. Tools run as UID/GID 1000 and can +access files available to that user. + +Install required system dependencies while building the image/template. Runtime +has no automatic apt installation, sudo or elevated daemon permissions; +`system_packages` is unsupported and missing dependencies cause operation failure. +npm/Python package and setup commands run directly with the existing user's +permissions. The packaged image no longer needs bubblewrap or socat for an inner +sandbox. For native self-hosting and platform limits, see the +[native guide](../../../../docs/self-hosted-native.md). Set `OAC_DEFAULT_HARNESS=claude_sdk`. Configure the deployment default model provider with the Core key, in Web or through Core's API: @@ -35,15 +42,16 @@ curl -fsS -X PUT http://127.0.0.1:8091/core/v1/harnesses/claude_sdk/model-provid A Session may instead supply its own `x_agents_core.model_provider`. Use the endpoint and model supported by your actual provider. Do not bake keys into the image. Core freezes the bundle in the Session's encrypted snapshot and delivers it -to the adapter; it is not a public Agent field. Workspace tool processes cannot -inherit the provider secret. +to the adapter; it is not a public Agent field. Ordinary environment projection +does not isolate locally stored credentials from tools running as the same user. Follow the existing Core setup for independent PostgreSQL credentials, migrations, API authentication and managed Runtime enrollment. Parsar is not a dependency. The supported hosted profile accepts text execution with medium verbosity and native Bash/Read/Edit and declared public functions with text results. Files and Artifacts use the shared public interfaces. -Workspace HTTP MCP, multi-agent, structured output, user-managed enrollment -and other environment installations remain explicit gaps. The existing +Check the current qualified engine profile for MCP, subagents and structured +output combinations. Historical hosted-profile acceptance does not qualify native +self-hosted platforms or every feature combination. The existing `environment:none` function/MCP profile is separate. This is not complete upstream protocol compatibility. diff --git a/services/agents-api/deploy/codex/Dockerfile b/services/agents-api/deploy/codex/Dockerfile index 482866f2a..32b7304c6 100644 --- a/services/agents-api/deploy/codex/Dockerfile +++ b/services/agents-api/deploy/codex/Dockerfile @@ -2,20 +2,18 @@ # Native package and resources must both be Codex 0.153.4 (linux/amd64). FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates bash git python3 python3-pip nodejs npm ripgrep bubblewrap \ + ca-certificates bash git python3 python3-pip nodejs npm ripgrep \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /environment/workspace /workspace /home/runtime \ && groupadd --gid 1000 runtime \ && useradd --uid 1000 --gid 1000 --home-dir /home/runtime --no-create-home runtime -COPY build-system-seed.py /tmp/build-system-seed.py -RUN python3 -I -S /tmp/build-system-seed.py && rm /tmp/build-system-seed.py -COPY --chmod=0555 tool-root.py /usr/local/bin/oac-tool-root COPY --chmod=0555 oac-daemon oac-codex-directory oac-codex-write oac-workspace-export codex /usr/local/bin/ COPY codex-resources /usr/local/codex-resources -COPY --chmod=0444 runtime-initialize.py /usr/local/bin/oac-runtime-initialize ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ OAC_RUNTIME_CODEX_BIN=/usr/local/bin/codex \ OAC_RUNTIME_WORKSPACE=/environment/workspace \ + OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization \ + OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages \ OAC_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/oac-codex-directory \ OAC_RUNTIME_WRITE_HELPER=/usr/local/bin/oac-codex-write \ OAC_RUNTIME_EXPORT_HELPER=/usr/local/bin/oac-workspace-export \ diff --git a/services/agents-api/deploy/codex/README.md b/services/agents-api/deploy/codex/README.md index 0b63baab9..134caa46c 100644 --- a/services/agents-api/deploy/codex/README.md +++ b/services/agents-api/deploy/codex/README.md @@ -1,102 +1,43 @@ # Codex colocated Runtime -These operator inputs select the V1 native isolation profile. The explicit service -configuration below enables basic Docker-hosted admission; it does not replace -the accepted caller-managed `self_hosted` path. Use one dedicated sandbox and retained -home/workspace per Session. Core stays outside it. Do not mount another Session's -history, host home, Docker socket, or product/Core credentials. - -The shared Runtime and its two management modes are defined in -[the contributor guide](../../../../CONTRIBUTING.md#product-and-execution-service-separation). -User-managed installation and enrollment are outside this qualification batch. - -Use Codex 0.153.4 and its matching `codex-resources` directory. Install the immutable -requirements file at `/etc/codex/requirements.toml`, mount only the authorized -Environment parent at `/environment`, containing `workspace`, private `staging`, tool `initialization` and `packages` -directories on one mount for trusted writes. Retain daemon/native state beneath `/home`. Set -`OAC_RUNTIME_CODEX_PERMISSION_PROFILE=managed-workspace` on the daemon. This operator -setting enables adapter selection of the immutable Runtime network profile at -startup and on both new/resumed threads; -it also filters native shell inheritance to process essentials, retaining default -secret exclusions. Native shell snapshots are disabled for this managed path: -their private storage is unavailable inside the tool sandbox, so loading one would -abort the pinned harness's login-shell wrapper. Ordinary shell startup remains -native. Model credentials remain available to the trusted harness; -it is not a request option or a public capability. Missing/invalid native profiles -must fail, without falling back to an unrestricted run. Ordinary deployments leave -this setting unset. It is incompatible with remote/none/read-only preparations. - -The requirements file is outside the workspace and read-only. It fixes the allowed -profile and denies reads of daemon authentication, generated provider configuration -and native history under the declared daemon state layout. Minimal native reads -exclude other home contents; native helper aliases under the Session tmp directory -remain readable so the pinned harness can start its sandbox and apply_patch. -The workspace and initialized package prefix are writable by native tools; -initialized tool configuration is read-only. Staging and its ancestors -are unavailable for native tool writes; staging is also explicitly denied for reads. -The image includes enabled and disabled native network profiles with the same -filesystem restrictions. Bootstrap freezes `OAC_RUNTIME_NETWORK_ACCESS`; -preparation must match it, and the adapter selects the native profile. Public -omission defaults to enabled. Restricted domains are unimplemented and rejected. -The trusted harness and daemon retain model/Core connectivity with either policy. -Do not expose private stock filesystem RPC as public Files. -Public file access needs the existing bounded, authorized filesystem primitives. - -The Docker candidate uses a non-root user, no capabilities, no-new-privileges, -read-only root, private PID namespace, dedicated bridge networking, bounded tmpfs -and process/memory/CPU limits. Stock Codex's inner bubblewrap sandbox needs user, -mount and PID namespaces. `seccomp.json` retains the Moby default restrictions and -adds clone, unshare, setns, mount, umount2 and pivot_root for that inner sandbox. -No host capability or privileged container is required. On the tested AppArmor -host, Docker's default profile denies namespaced mounts; the candidate uses -container-specific `apparmor=unconfined`. This removes that outer LSM layer, so the -native sandbox, outer namespace/capability restrictions and actual isolation -checks remain required. Do not alter the host-wide AppArmor or seccomp policy. +A managed Session runs the daemon, Codex and workspace in a dedicated outer +Environment. Core stays outside it. Use Codex 0.153.4 and its matching +`codex-resources`. The same daemon supports native self-hosted installations; see +[the native guide](../../../../docs/self-hosted-native.md) for platform status. + +Codex tools run with the daemon user's existing permissions. There is no inner +filesystem, permission or network sandbox, no immutable Codex requirements file, +and no managed Bash wrapper. Tools may read Runtime state that the user can read. +Model credentials still belong in authenticated Runtime configuration, never image +layers. Do not mount another Session's history, host home, Docker socket or Core +credentials into the Environment. + +The existing outer Docker configuration is unchanged: non-root user, dropped +capabilities, no-new-privileges, read-only root, private PID namespace, dedicated +bridge networking, bounded tmpfs and process/memory/CPU limits. Its existing +`nested_sandbox` setting and seccomp file are deployment settings, not evidence +that the daemon adds inner isolation. The container-specific AppArmor exception +in that configuration removes an outer LSM layer; do not change host-wide policy. Seccomp source: [Moby profiles, revision 65adc7e](https://github.com/moby/profiles/blob/65adc7e022c97f55e45c054ff012988027733b87/seccomp/default.json), Apache-2.0. Unmodified source SHA-256: `785b2429264afba4d594320337cb17f144f3c7d51585f9805eef72e28f4f9334`. -The final extra syscall rule is the only change to the parsed upstream profile. - -Before treating this as a qualified deployment, verify native workspace execution, -credential/symlink/process-metadata denial, permission downgrade rejection, real -model execution, cancellation, daemon/container restart with retained history and -files, and missing-history rejection. An alive container or a selected profile is -not an isolation or public API acceptance result. Qualification applies only to -the selected deployment profile, not complete protocol compatibility or another -engine/provider. - -The optional local file writer uses the existing `oac-codex-write` binary -outside `/environment`, with `OAC_RUNTIME_WRITE_HELPER` selecting that immutable -executable and `OAC_RUNTIME_STAGING=/environment/staging`. Set -`OAC_RUNTIME_WORKSPACE=/environment/workspace`; the public file path remains -`/workspace/...` and Core sends only the relative path to the bound Runtime. -Docker mounts the existing volume's `workspace` subdirectory at `/workspace` as -a second view of the same files. The image contains the initial directory before -volume population, so bootstrap works with an empty volume. Native tools receive -only that additional workspace root; staging remains private. Trusted atomic -rename stays within `/environment`. A symlink is insufficient because stock Codex -mounts canonical roots. The qualified Docker version is 29.1.3; the engine must -support volume subpath mounts. Other versions need their own deployment checks. -Workspace and staging must share the same mount for atomic rename. Do not mount -them separately or put daemon/model credentials, native history or other tenants -inside `/environment`. The read-only Runtime can omit both writer settings. - -Hosted Turn output publication additionally requires the immutable -`oac-workspace-export` executable selected by -`OAC_RUNTIME_EXPORT_HELPER`. The Runtime bundle includes it outside the workspace. -It exports regular files below `outputs` through the authenticated daemon connection; -Core stores immutable copies and publishes them with successful Turn completion. -Use a matched Core/Runtime release: older Runtime images without bounded output -export are ineligible for hosted execution. Listing and downloading already -published artifacts uses the execution database and requires no running Environment. -The export capability does not replace the deployment isolation checks above. - -The installer runs as a trusted bounded daemon child with a minimal environment. -Native tools retain their narrower filesystem policy. Qualify direct reads, -symlink and process-root aliases, attempted staging modification, real uploaded -bytes consumed by Codex, cancellation and retained-history restart before using -this writer profile for public admission. Configuration alone is not that proof. +The retained extra syscall rule is unchanged by removal of the inner sandbox. +Historical qualification of private-file denial under the former native sandbox +does not describe current tool permissions or qualify the new deployment. + +The packaged image retains `/environment/workspace`, initialization, package and +staging directories plus daemon/native state under `/home/runtime`. Docker also +mounts the workspace at `/workspace`. Runtime binds public Files/Artifacts to its +configured workspace; native tools execute with ordinary user permissions. The +shared helper artifacts remain in the matched Runtime bundle. The Docker engine +must support volume subpath mounts; its recorded qualification used 29.1.3. + +Published Artifacts are immutable Core-owned copies of successful Turn outputs; +listing and downloading them requires no running Environment. Validate actual +execution, file operations, cancellation, process settlement and retained native +history using the current binary and outer deployment. An alive container alone +is not acceptance. ## Managed Runtime image and Docker adapter @@ -135,11 +76,10 @@ the container, explicitly removes its named volumes and confirms absence. Keep t reference and retry cleanup when an operation fails; an HTTP timeout is not proof that a resource disappeared. Never use broad container or volume pruning. -RunCommand is for trusted initialization, using an explicit context deadline, -argument vector and nonroot user. It preserves nonzero status and limits each -output stream to1MiB. Disconnecting an exec stream does not stop the command: -an unconfirmed result requires allocation cleanup before reuse. Routine agent -execution, cancellation and Files continue through Core/daemon/Runtime. +Provider commands are limited to bootstrap and resource lifecycle. Runtime owns +initial files, configuration, npm/Python packages, setup and capabilities over its +authenticated protocol. An unconfirmed bootstrap result requires allocation +cleanup before reuse; compute readiness does not prove Runtime readiness. ## Standalone operator configuration @@ -185,19 +125,26 @@ With the qualified Codex image and Docker provider configured, create an idle or initial-text Session using `environment: {"type": "openai_hosted"}`. Core commits its identity before automatically provisioning it. Queries expose durable connection status; execution separately prepares the native harness. Session -deletion revokes authority before owned container/volume cleanup. Supported network -policies are enabled, disabled and restricted to exact ASCII hostnames. Templates -and inline configuration share initial files, env, packages, ordered setup and inline -Skills; see the [supported fields and limits](../../../../contracts/agents-api/environment-templates.md). -Other hostname forms, Plugins, Skill references, capability-directory imports and -hosted MCP combinations remain explicit gaps. +deletion revokes authority before owned container/volume cleanup. The daemon does +not enforce `disabled` or `restricted` networking; combinations without matching +outer enforcement are unsupported. Templates and inline configuration share +initial files, env, npm/Python packages, ordered setup and capabilities; see the +[supported fields and limits](../../../../contracts/agents-api/environment-templates.md). ### Environment initialization -Templates and inline env/setup/npm/Python configuration share the packaged Runtime -initializer. Enable the existing `nested_sandbox: true` Docker provider setting -when admitting these configurations: user commands and package hooks require -bubblewrap user/PID/mount isolation. Runtime execution still uses native Codex -isolation. The image includes the trusted initializer and managed native Bash -hook; do not inject user env into the daemon or app-server launcher. -See [initialization contract and limits](../../../../contracts/agents-api/environment-templates.md). +Templates and inline env/setup/npm/Python configuration share the daemon's Go +Runtime preparation implementation on all platforms. The packaged image sets +`OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization` and +`OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages` to retain its resource layout. +No Python initialization wrapper is shipped. It runs commands directly as UID/GID 1000 using configured tool +variables; it does not inject those variables into the daemon or native harness +launcher. Cancellation and finite receipts remain Runtime responsibilities. + +System dependencies must be installed during image/template construction. The +daemon never runs apt, sudo or another privilege escalation, and +`system_packages` is unsupported. A missing executable or library fails the +operation requiring it. The image no longer contains a system-root seed or +system-package launcher. Native self-hosted users prepare their own dependencies +before starting the daemon. See +[initialization contract and limits](../../../../contracts/agents-api/environment-templates.md). diff --git a/services/agents-api/deploy/e2b/README.md b/services/agents-api/deploy/e2b/README.md index dfd0400f6..57e125814 100644 --- a/services/agents-api/deploy/e2b/README.md +++ b/services/agents-api/deploy/e2b/README.md @@ -66,6 +66,12 @@ private workspace layout. Its `template` output is an immutable can include several harnesses. No E2B account key, executor key or model credential belongs in a build, template environment, metadata, command argument or log. +System dependencies must be installed when building the template. The builder may +use root during image construction, but the running daemon remains UID/GID 1000 +with no automatic apt, sudo or privilege escalation. Runtime `system_packages` +is unsupported; missing dependencies fail their consuming operation. The template +no longer installs bubblewrap or socat for inner sandboxing. + ## Application-managed: start an existing self-hosted Environment Create a public `self_hosted` Environment through Core and retain its ID and exact @@ -158,9 +164,11 @@ the daemon's argv or inherited environment. Enrollment and immutable local bindi remain daemon responsibilities; startup does not invent device/Session IDs. E2B clears `/run` at boot, so startup records live under `/root/.oac/e2b`. -Native sandboxing remains mandatory. Each actual template must verify private -credential/history isolation, protected binary/config ownership, privilege denial -and stopped descendant effects, rather than infer safety from file modes alone. +The E2B VM is the outer isolation boundary. The daemon and native harness add no +inner filesystem, permission or network sandbox; tools have UID 1000's access to +Runtime state. Verify the actual outer boundary, process cleanup and native +execution for each template. Prior private-file denial results describe the old +inner sandbox and do not qualify the current behavior. The one-shot receipt cannot be reused to replace the daemon or overwrite history. ## Verification scope @@ -175,7 +183,7 @@ These are controlled startup-contract tests: input binding, protected key output unchanged URL, no secret in argv/environment/record, one-shot claim, and retained provider ID on unknown outcomes. They do not create billable resources or qualify E2B security, enrollment or model execution. The [qualification record](../../../../contracts/agents-api/user-managed-runtime-v1.md) -identifies actual three-harness deployment results and their verification limits, +records historical three-harness deployment results and their verification limits, including shared Core credential lifecycle checks and explicit application-owned cleanup. `tests/official_user_runtime.py` supplies the shared public execution checks. The former Core-managed `official_e2b_v1.py` fixture is diff --git a/services/agents-api/deploy/e2b/build-template.py b/services/agents-api/deploy/e2b/build-template.py index bbb9ce81d..9eb56cef4 100644 --- a/services/agents-api/deploy/e2b/build-template.py +++ b/services/agents-api/deploy/e2b/build-template.py @@ -68,7 +68,7 @@ (context / name).write_bytes(Path(__file__).with_name(name).read_bytes()) template = (Template(file_context_path=context).from_image(BASE) .run_cmd('apt-get update && apt-get install -y --no-install-recommends ' - 'ca-certificates bash git python3 python3-pip ripgrep bubblewrap socat util-linux ' + 'ca-certificates bash git python3 python3-pip ripgrep util-linux ' '&& rm -rf /var/lib/apt/lists/*', user='root') .copy('runtime.tar.gz', '/root/runtime.tar.gz', user='root') .copy('runtime-env.json', '/etc/oac-runtime-env.json', user='root') diff --git a/services/agents-api/deploy/e2b/init.py b/services/agents-api/deploy/e2b/init.py index 7de64064b..75d8e1f07 100644 --- a/services/agents-api/deploy/e2b/init.py +++ b/services/agents-api/deploy/e2b/init.py @@ -68,7 +68,6 @@ def prepare_runtime(): # E2B finalization makes /usr/local world-writable after template commands. subprocess.run(['chown', '-R', 'root:root', '/usr/local'], check=True) subprocess.run(['chmod', '-R', 'go-w', '/usr/local'], check=True) - os.chmod('/usr/local/bin/oac-tool-root', 0o555) for protected in ['/usr/bin/envd', '/etc/inittab', '/etc/init.d/rcS']: if protected == '/etc/init.d/rcS' and not Path(protected).exists(): continue diff --git a/services/agents-api/deploy/mcode/Dockerfile b/services/agents-api/deploy/mcode/Dockerfile index 4dfffe635..178d2ad43 100644 --- a/services/agents-api/deploy/mcode/Dockerfile +++ b/services/agents-api/deploy/mcode/Dockerfile @@ -2,15 +2,11 @@ FROM node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27 USER root RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates bash git python3 python3-pip ripgrep bubblewrap socat \ + ca-certificates bash git python3 python3-pip ripgrep \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /environment/workspace /workspace /home/runtime -COPY build-system-seed.py /tmp/build-system-seed.py -RUN python3 -I -S /tmp/build-system-seed.py && rm /tmp/build-system-seed.py -COPY --chmod=0555 tool-root.py /usr/local/bin/oac-tool-root COPY --chmod=0555 oac-daemon oac-codex-directory oac-codex-write oac-workspace-export /usr/local/bin/ COPY mcode-harness /opt/mcode-harness -COPY --chmod=0444 runtime-initialize.py /usr/local/bin/oac-runtime-initialize ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ OAC_RUNTIME_MCODE_NODE=/usr/local/bin/node \ OAC_RUNTIME_MCODE_BIN=/opt/mcode-harness/native/cli.js \ @@ -18,6 +14,8 @@ ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ OAC_RUNTIME_MCODE_AGENTS_API=1 \ OAC_RUNTIME_MCODE_WORKSPACE=managed \ OAC_RUNTIME_WORKSPACE=/environment/workspace \ + OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization \ + OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages \ OAC_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/oac-codex-directory \ OAC_RUNTIME_WRITE_HELPER=/usr/local/bin/oac-codex-write \ OAC_RUNTIME_EXPORT_HELPER=/usr/local/bin/oac-workspace-export \ diff --git a/services/agents-api/deploy/mcode/README.md b/services/agents-api/deploy/mcode/README.md index f5fad2ab1..54765061a 100644 --- a/services/agents-api/deploy/mcode/README.md +++ b/services/agents-api/deploy/mcode/README.md @@ -4,7 +4,8 @@ MiniMax Code uses the same Core/Runtime execution contract as the other engines. The text profile supports `environment:none`; the dedicated Docker profile adds workspace execution and the shared Files/Artifacts path. See [workspace qualification](../../../../contracts/agents-api/mcode-workspace-v1.md) -for exact acceptance evidence and limits. +for historical acceptance evidence and its limits. Inner sandbox denial results +from that qualification do not describe the current bypass profile. Public MCP/functions, image input and native Subagent execution remain outside this batch. @@ -21,7 +22,9 @@ This profile runs on a trusted execution host. Set `OAC_RUNTIME_MCODE_BIN` to that absolute executable and `OAC_RUNTIME_MCODE_AGENTS_API=1` for the daemon. The opt-in only advertises the profile for the qualified version. Use the existing authenticated daemon connection and operator device enrollment; -this is not a new public enrollment API or official `self_hosted` implementation. +native self-hosted installation uses the same Runtime protocol. See the +[native guide](../../../../docs/self-hosted-native.md); MiniMax on Windows remains +unsupported. Set `OAC_DEFAULT_HARNESS=mcode` in the independent Core deployment. Existing Sessions retain their engine. Do not expose a new public harness selector. @@ -48,10 +51,10 @@ change them. Public Files and Artifacts use the common bound workspace helpers. The native process and ACP Session use a private control directory, while six original native tools execute against `/workspace` through one trusted MCP bridge -and the upstream Linux sandbox. MCP is internal transport here; it does not enable -caller-supplied public MCP servers. Project instructions must be read through the +with the daemon user's ordinary permissions and no inner sandbox. MCP is internal +transport here; its presence alone does not enable caller-supplied public MCP servers. Project instructions must be read through the workspace tools. Native automatic project configuration and diff/undo capture do -not apply to this isolated tool path. Exact native-ID continuation remains +not apply to this adapter tool path. Exact native-ID continuation remains required; recovery without a recorded ID fails closed. Native Bash observations become public `command_execution` items after command arguments arrive. Their text output and status are retained; absent native exit code/duration stay unknown. @@ -84,7 +87,15 @@ child inherits only process and model-network essentials; it does not inherit Core/daemon tokens or arbitrary Node startup configuration. The adapter owns its native config, instructions and home and disables external skills, delegated work, web search, builtin file/shell tools, browser tools, mcode-tools and native goals. -The workspace profile adds only its trusted isolated tool bridge. +The workspace profile uses its tool bridge without sandbox isolation. Tools can +read any local state available to the launching user. The outer Environment owns +managed isolation; daemon network modes do not add another network boundary. + +Provide system dependencies at image/template build time or on the self-hosted +machine before execution. Runtime does not run apt or sudo or elevate daemon +permissions. `system_packages` is unsupported; missing dependencies fail the +operation requiring them. npm/Python packages and setup retain direct execution +with the user's existing permissions. The native model may still see `skill`, `task_query`, `task_output` and `task_stop`. The first loads an exact registered skill name and cannot execute a script; the @@ -105,6 +116,9 @@ native ACP context occupancy and cumulative cost are not per-Turn usage. ## Acceptance +Recorded results below are historical evidence for their exact binaries and +profiles, not qualification of the current native platforms or bypass behavior. + The opt-in `TestNativeMCodePublicExecution` uses the fixed official Python SDK, raw HTTP, actual daemon/gateway/Worker and a dedicated PostgreSQL test database. Provide private `OAC_TEST_MCODE_REAL_OPTIONS` (the provider object above plus the diff --git a/services/agents-api/deploy/microsandbox/README.md b/services/agents-api/deploy/microsandbox/README.md index dc0949b4f..fe8e66840 100644 --- a/services/agents-api/deploy/microsandbox/README.md +++ b/services/agents-api/deploy/microsandbox/README.md @@ -101,9 +101,10 @@ substituting a local image for an already saved release. The provider performs the existing Runtime bootstrap, starts the daemon as uid/gid 1000 and creates `/run/oac` as a private control directory. No model, -Core or tenant credential belongs in the image. The ordinary Runtime initializer -and native isolation profile still apply; snapshot restore does not rerun setup -commands or initial file writes. +Core or tenant credential belongs in the image. The Runtime initializer executes +with that user's existing permissions; the microVM is the isolation boundary. +System dependencies belong in the image: Runtime does not run apt or sudo and +rejects `system_packages`. Snapshot restore does not rerun setup or initial files. ## Deployment and node configuration @@ -127,8 +128,8 @@ retention. The private node provider file supplies its absolute helper/runtime/firmware paths, short Runtime home and explicit host network policy. Permit the required Core, model and package-registry endpoints. Creation and restore apply the same host -policy. Native tool-network policy remains separate and uses the existing Runtime -controls. Never repoint a retained backend namespace or overwrite node identity +policy. The daemon does not enforce `disabled` or `restricted` native network +modes; combinations without required outer enforcement are unsupported. Never repoint a retained backend namespace or overwrite node identity to bypass a configuration mismatch. Use the existing [standalone Core setup](../../README.md) for the database, diff --git a/services/agents-api/deploy/runtime/build-system-seed.py b/services/agents-api/deploy/runtime/build-system-seed.py deleted file mode 100644 index b1dd9c954..000000000 --- a/services/agents-api/deploy/runtime/build-system-seed.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Run during image construction, before installing any Runtime or harness code.""" -import hashlib -import json -from pathlib import Path -import tarfile - - -OUTPUT = Path('/opt/agents-runtime') -EXCLUDED = {'etc/hostname', 'etc/hosts', 'etc/resolv.conf', 'etc/machine-id', - 'etc/mtab', 'etc/shadow', 'etc/gshadow', 'opt/agents-runtime'} - - -def member(info): - if any(info.name == path or info.name.startswith(path + '/') for path in EXCLUDED): - return None - return info - - -def main(): - OUTPUT.mkdir(mode=0o755) - seed = OUTPUT / 'system-root.tar.gz' - # Preserve the matching package database and all base tool symlink targets. - with tarfile.open(seed, 'w:gz', compresslevel=1, dereference=False) as archive: - for name in ('usr', 'bin', 'sbin', 'lib', 'lib64', 'opt', 'etc', - 'var/lib/dpkg', 'var/lib/apt', 'var/cache/debconf'): - path = Path('/') / name - if path.exists() or path.is_symlink(): - archive.add(path, arcname=name, filter=member) - for name in ('dev', 'proc', 'sys', 'tmp', 'run', 'home', 'root', 'workspace', - 'environment', 'var/log', 'var/cache/apt/archives/partial', - 'var/lib/apt/lists/partial'): - info = tarfile.TarInfo(name) - info.type, info.mode = tarfile.DIRTYPE, 0o755 - archive.addfile(info) - for name in ('etc/hostname', 'etc/hosts', 'etc/resolv.conf', 'etc/machine-id'): - archive.addfile(tarfile.TarInfo(name)) - seed.chmod(0o444) - with seed.open('rb') as stream: - digest = hashlib.file_digest(stream, 'sha256').hexdigest() - manifest = OUTPUT / 'system-root.json' - manifest.write_text(json.dumps({'version': 1, 'sha256': digest, 'size_bytes': seed.stat().st_size}) + '\n') - manifest.chmod(0o444) - - -if __name__ == '__main__': - main() diff --git a/services/agents-api/deploy/runtime/initialize.py b/services/agents-api/deploy/runtime/initialize.py deleted file mode 100644 index 86d3e7d54..000000000 --- a/services/agents-api/deploy/runtime/initialize.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Trusted hosted initialization. Invoke with /usr/bin/python3 -I -S. - -Core owns sequencing and the completion ledger. This helper executes one bounded -operation; it never retries, schedules, selects a harness or interprets templates. -""" -import json -import os -from pathlib import Path -import re -import runpy -import shlex -import subprocess -import sys - -ROOT = Path('/environment') -CONFIG = ROOT / 'initialization' -PACKAGES = ROOT / 'packages' -ENV_FILE = CONFIG / 'tool-env.sh' -MAX_INPUT = 32 * 1024 * 1024 -BASE_ENV = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'HOME': '/tmp', 'LANG': 'C.UTF-8'} -DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC - - -def roots(workspace=None): - for directory in (Path(workspace) if workspace else ROOT / 'workspace', PACKAGES, CONFIG): - if not directory.is_dir(): - raise ValueError('invalid Runtime directory') - - -def configure(env): - if not isinstance(env, dict) or any( - not isinstance(name, str) or not re.fullmatch('[A-Za-z_][A-Za-z0-9_]*', name) - or not isinstance(value, str) or '\x00' in value - for name, value in env.items() - ): - raise ValueError('invalid environment') - # Public reserved-name validation belongs to Core. Only the configured - # environment and these defaults are passed to initialization commands. - values = { - 'PATH': str(PACKAGES / 'npm/bin') + ':' + str(PACKAGES / 'python/bin') + ':' + BASE_ENV['PATH'], - **env, - 'PYTHONPATH': str(PACKAGES / 'python') + (':' + env['PYTHONPATH'] if env.get('PYTHONPATH') else ''), - } - materialized = json.dumps(values, ensure_ascii=True) - script = ''.join('export ' + name + '=' + shlex.quote(value) + '\n' for name, value in sorted(values.items())) - directory = os.open(CONFIG, DIRECTORY) - try: - fd = os.open('tool-env.sh', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o400, dir_fd=directory) - with os.fdopen(fd, 'w') as stream: - stream.write(script) - stream.flush() - os.fsync(stream.fileno()) - fd = os.open('tool-env.json', os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o400, dir_fd=directory) - with os.fdopen(fd, 'w') as stream: - stream.write(materialized) - stream.flush() - os.fsync(stream.fileno()) - os.fsync(directory) - finally: - os.close(directory) - - -class StepFailure(Exception): - """A launched initialization command failed; never retain its text or output.""" - - def __init__(self, exit_code): - self.exit_code = exit_code - super().__init__('initialization command failed') - - -def execution_directory(cwd, workspace): - if (not isinstance(cwd, str) or os.path.normpath(cwd) != cwd - or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in cwd) - or not (cwd == '/workspace' or cwd.startswith('/workspace/'))): - raise ValueError('invalid execution directory') - return str(Path(workspace or ROOT / 'workspace') / cwd.removeprefix('/workspace').lstrip('/')) - - -def run(request, workspace=None, capability_root=None): - action = request['action'] - if action == 'configure': - configure(request.get('env', {})) - return - if action == 'system': - runpy.run_path('/usr/local/bin/oac-tool-root')['install'](request['packages'], workspace=workspace or str(ROOT / 'workspace')) - return - if action not in ('setup', 'npm', 'python'): - raise ValueError('invalid action') - if request['network'] not in ('enabled', 'disabled'): - raise ValueError('invalid execution configuration') - cwd = execution_directory(request.get('cwd', '/workspace'), workspace) - if action == 'setup': - command = request['command'] - if not isinstance(command, str) or not command or '\x00' in command: - raise ValueError('invalid setup command') - args = ['/bin/bash', '--noprofile', '--norc', '-c', command] - else: - packages = request['packages'] - if not isinstance(packages, list) or not packages or any( - not isinstance(package, str) or not package or '\x00' in package or package.startswith('-') - for package in packages - ): - raise ValueError('invalid packages') - args = ['npm', 'install', '--global', '--prefix', str(PACKAGES / 'npm'), '--', *packages] - if action == 'python': - args = ['/usr/bin/python3', '-m', 'pip', 'install', '--disable-pip-version-check', - '--no-input', '--target', str(PACKAGES / 'python'), '--', *packages] - env = {**BASE_ENV, **json.loads((CONFIG / 'tool-env.json').read_text())} - # Execution uses the launching user's permissions. The outer Environment owns - # isolation; command output is still excluded from the completion receipt. - try: - subprocess.run(args, cwd=cwd, env=env, stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) - except subprocess.CalledProcessError as error: - raise StepFailure(error.returncode) from None - - -def failed_receipt(error): - """Expose a command's exit status, never its command, input or output.""" - receipt = {'version': 1, 'outcome': 'failed'} - code = getattr(error, 'exit_code', None) - # The system installer retains its existing implementation until its package - # ownership is migrated; other internal helper failures remain generic. - if (isinstance(error, subprocess.CalledProcessError) and isinstance(error.cmd, list) - and error.cmd[:1] == ['/usr/bin/bwrap']): - code = error.returncode - if type(code) is int and 0 < code < 256: - receipt['exit_code'] = code - return json.dumps(receipt, separators=(',', ':')) - - -def main(): - workspace, capability_root = None, None - if len(sys.argv) == 4 and sys.argv[1] == 'initialize': - workspace, capability_root = sys.argv[2:] - try: - if len(sys.argv) != 1 and not (len(sys.argv) == 4 and sys.argv[1] == 'initialize'): - raise ValueError('invalid initialization invocation') - raw = sys.stdin.buffer.read(MAX_INPUT + 1) - if len(raw) > MAX_INPUT: - raise ValueError('input too large') - request = json.loads(raw) - if not isinstance(request, dict) or request.get('version') != 1: - raise ValueError('invalid version') - if workspace is not None: - path = Path(workspace) - if (not workspace.startswith('/') or workspace == '/' or os.path.normpath(workspace) != workspace - or any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in workspace) - or not path.is_dir()): - raise ValueError('invalid Runtime workspace') - if workspace is None: - roots() - run(request) - else: - roots(workspace) - run(request, workspace=workspace, capability_root=capability_root) - except Exception as error: - print(failed_receipt(error)) - return 1 - print('{"version":1,"outcome":"completed"}') - return 0 - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/services/agents-api/deploy/runtime/initialize_receipt_test.py b/services/agents-api/deploy/runtime/initialize_receipt_test.py deleted file mode 100644 index e730838a6..000000000 --- a/services/agents-api/deploy/runtime/initialize_receipt_test.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Host-runnable initialization and finite-receipt checks; no packages downloaded.""" -import contextlib -import importlib.util -import io -import json -import os -from pathlib import Path -import subprocess -import tempfile -import unittest -from unittest import mock - -SPEC = importlib.util.spec_from_file_location('agents_api_runtime_initialize', Path(__file__).with_name('initialize.py')) -initialize = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(initialize) -CANARY = 'CANARY-initializer-receipt-5d1c' - - -def invoke(failure): - request = json.dumps({'version': 1, 'action': 'setup', 'network': 'enabled', 'command': 'echo ' + CANARY}) - stdin = mock.Mock() - stdin.buffer = io.BytesIO(request.encode()) - stdout, stderr = io.StringIO(), io.StringIO() - with mock.patch.object(initialize.sys, 'stdin', stdin), mock.patch.object(initialize.sys, 'argv', ['initialize']), \ - mock.patch.object(initialize, 'roots', lambda: None), mock.patch.object(initialize, 'run', side_effect=failure), \ - contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): - code = initialize.main() - return code, stdout.getvalue(), stderr.getvalue() - - -class InitializationTest(unittest.TestCase): - def test_configure_preserves_runtime_capabilities_and_freezes_environment(self): - with tempfile.TemporaryDirectory() as directory: - config = Path(directory) - capabilities = config / 'capabilities' - capabilities.mkdir() - manifest = capabilities / 'installed.json' - manifest.write_bytes(b'fixed-runtime-fixture') - with mock.patch.object(initialize, 'CONFIG', config): - initialize.configure({'VALUE': "configured'\n$(false)"}) - self.assertEqual(manifest.read_bytes(), b'fixed-runtime-fixture') - self.assertFalse((capabilities / 'skills').exists()) - self.assertEqual(json.loads((config / 'tool-env.json').read_text())['VALUE'], "configured'\n$(false)") - with self.assertRaises(FileExistsError): - initialize.configure({'VALUE': 'changed'}) - - def test_skill_is_not_an_initializer_action(self): - with mock.patch.object(initialize.subprocess, 'run') as run: - with self.assertRaisesRegex(ValueError, 'invalid action'): - initialize.run({'action': 'skill', 'network': 'enabled'}) - run.assert_not_called() - - def test_setup_executes_directly_with_resolved_cwd_and_configured_environment(self): - with tempfile.TemporaryDirectory() as directory: - base = Path(directory) - workspace, config, packages, capabilities = [base / name for name in ('work', 'config', 'packages', 'caps')] - for path in (workspace / 'sub', config, packages, capabilities): - path.mkdir(parents=True) - skill = capabilities / 'proof.sh' - skill.write_text('#!/bin/sh\nprintf skill-proof') - secret = base / 'same-user-file' - secret.write_text('ordinary-user-access') - import shlex - command = ('/bin/sh ' + shlex.quote(str(skill)) + ' > skill-result; ' - 'cat ' + shlex.quote(str(secret)) + ' > readable; ' - 'printf "%s" "$VALUE" > value; pwd > cwd; ' - 'test -z "${DAEMON_PRIVATE_CANARY+x}"') - with mock.patch.object(initialize, 'CONFIG', config), mock.patch.object(initialize, 'PACKAGES', packages), \ - mock.patch.dict(os.environ, {'DAEMON_PRIVATE_CANARY': CANARY}): - initialize.configure({'VALUE': "'\n$(false)"}) - initialize.run({'action': 'setup', 'network': 'disabled', 'cwd': '/workspace/sub', 'command': command}, - workspace=str(workspace), capability_root=str(capabilities)) - self.assertEqual((workspace / 'sub/skill-result').read_text(), 'skill-proof') - self.assertEqual((workspace / 'sub/readable').read_text(), 'ordinary-user-access') - self.assertEqual((workspace / 'sub/value').read_text(), "'\n$(false)") - self.assertEqual((workspace / 'sub/cwd').read_text(), str(workspace / 'sub') + '\n') - - def test_package_managers_use_direct_argv_and_frozen_environment(self): - with tempfile.TemporaryDirectory() as directory: - config = Path(directory) - (config / 'tool-env.json').write_text(json.dumps({'VALUE': CANARY})) - for action, executable in (('npm', 'npm'), ('python', '/usr/bin/python3')): - with self.subTest(action=action), mock.patch.object(initialize, 'CONFIG', config), \ - mock.patch.object(initialize.subprocess, 'run') as run: - initialize.run({'action': action, 'network': 'enabled', 'packages': ['valid-package']}, workspace=directory) - self.assertEqual(run.call_args.args[0][0], executable) - self.assertEqual(run.call_args.args[0][-2:], ['--', 'valid-package']) - self.assertEqual(run.call_args.kwargs['cwd'], directory) - self.assertEqual(run.call_args.kwargs['env']['VALUE'], CANARY) - for channel in ('stdin', 'stdout', 'stderr'): - self.assertEqual(run.call_args.kwargs[channel], subprocess.DEVNULL) - with self.assertRaises(ValueError): - initialize.run({'action': action, 'network': 'enabled', 'packages': ['--bad']}, workspace=directory) - - def test_system_still_delegates_to_existing_installer(self): - install = mock.Mock() - with mock.patch.object(initialize.runpy, 'run_path', return_value={'install': install}) as load: - initialize.run({'action': 'system', 'packages': ['jq']}, workspace='/actual/work') - load.assert_called_once_with('/usr/local/bin/oac-tool-root') - install.assert_called_once_with(['jq'], workspace='/actual/work') - - def test_cwd_uses_only_logical_workspace_paths(self): - self.assertEqual(initialize.execution_directory('/workspace/sub', '/actual'), '/actual/sub') - for invalid in ('/workspace/../outside', '/workspace/', '/elsewhere', 'relative', '/workspace/x\n'): - with self.subTest(cwd=invalid), self.assertRaises(ValueError): - initialize.execution_directory(invalid, '/actual') - - def test_private_initialization_uses_bound_workspace_and_empty_environment(self): - with tempfile.TemporaryDirectory() as directory: - workspace = str(Path(directory)) - stdin = mock.Mock() - stdin.buffer = io.BytesIO(b'{"version":1,"action":"configure"}') - with mock.patch.object(initialize.sys, 'argv', ['initialize', 'initialize', workspace, workspace + '/caps']), \ - mock.patch.object(initialize.sys, 'stdin', stdin), \ - mock.patch.object(initialize, 'roots') as roots, mock.patch.object(initialize, 'run') as run, \ - contextlib.redirect_stdout(io.StringIO()) as output: - self.assertEqual(initialize.main(), 0) - roots.assert_called_once_with(workspace) - run.assert_called_once_with({'version': 1, 'action': 'configure'}, workspace=workspace, capability_root=workspace + '/caps') - self.assertEqual(json.loads(output.getvalue()), {'version': 1, 'outcome': 'completed'}) - with mock.patch.object(initialize, 'configure') as configure: - initialize.run({'action': 'configure'}, workspace=workspace) - configure.assert_called_once_with({}) - - def test_invalid_invocation_and_oversized_input_remain_finite(self): - for argv, data in ((['initialize', 'stdio', 'unused'], b''), (['initialize'], b'x' * 33)): - stdin = mock.Mock() - stdin.buffer = io.BytesIO(data) - with mock.patch.object(initialize.sys, 'argv', argv), mock.patch.object(initialize.sys, 'stdin', stdin), \ - mock.patch.object(initialize, 'MAX_INPUT', 32), contextlib.redirect_stdout(io.StringIO()) as output: - self.assertEqual(initialize.main(), 1) - self.assertEqual(json.loads(output.getvalue()), {'version': 1, 'outcome': 'failed'}) - - def test_step_reports_only_its_exit_status(self): - for status in (1, 3, 100, 255): - code, stdout, stderr = invoke(initialize.StepFailure(status)) - self.assertEqual(code, 1) - self.assertEqual(stderr, '') - self.assertEqual(json.loads(stdout), {'version': 1, 'outcome': 'failed', 'exit_code': status}) - self.assertNotIn(CANARY, stdout) - self.assertEqual(invoke(initialize.StepFailure(3))[1], '{"version":1,"outcome":"failed","exit_code":3}\n') - - def test_runtime_helpers_signals_and_other_errors_stay_generic(self): - for failure in (initialize.StepFailure(-9), initialize.StepFailure(256), - subprocess.CalledProcessError(2, ['/usr/bin/tar', CANARY]), - subprocess.TimeoutExpired([CANARY], 120), ValueError(CANARY), OSError(CANARY)): - code, stdout, stderr = invoke(failure) - self.assertEqual((code, stderr), (1, '')) - self.assertEqual(json.loads(stdout), {'version': 1, 'outcome': 'failed'}) - self.assertNotIn(CANARY, stdout) - - def test_real_child_failure_has_finite_receipt(self): - with tempfile.TemporaryDirectory() as directory: - config = Path(directory) - (config / 'tool-env.json').write_text('{}') - with mock.patch.object(initialize, 'CONFIG', config): - with self.assertRaises(initialize.StepFailure) as failure: - initialize.run({'action': 'setup', 'network': 'enabled', - 'command': 'echo ' + CANARY + '; echo ' + CANARY + ' >&2; exit 7'}, workspace=directory) - self.assertEqual(initialize.failed_receipt(failure.exception), '{"version":1,"outcome":"failed","exit_code":7}') - - -if __name__ == '__main__': - unittest.main() diff --git a/services/agents-api/deploy/runtime/initialize_test.py b/services/agents-api/deploy/runtime/initialize_test.py deleted file mode 100644 index 2410779f2..000000000 --- a/services/agents-api/deploy/runtime/initialize_test.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Real Linux initialization checks; run inside a disposable packaged Runtime. - -The fixture must expose writable workspace/packages/initialization roots and -execute with the launching user's permissions. No model is mocked; -these checks exercise initialization only, not public native-model acceptance. -""" -import json -import os -from pathlib import Path -import subprocess -import sys - -HELPER = '/usr/local/bin/oac-runtime-initialize' -CANARY = 'private-initialization-canary-47a8' - - -def invoke(action, *, succeeds=True, exit_code=None, **fields): - payload = json.dumps({'version': 1, 'action': action, 'network': 'enabled', **fields}) - result = subprocess.run(['/usr/bin/python3', '-I', '-S', HELPER], input=payload, - text=True, capture_output=True, timeout=120) - expected = {'version': 1, 'outcome': 'completed' if succeeds else 'failed'} - if exit_code is not None: - # A failed initialization step reports only its exit status, never its output. - expected['exit_code'] = exit_code - assert result.returncode == (0 if succeeds else 1), (action, result.returncode) - assert result.stderr == '', (action, 'unexpected stderr') - assert json.loads(result.stdout) == expected, (action, result.stdout) - assert CANARY not in result.stdout + result.stderr, 'confidential output exposed' - - -def main(): - for name in ('workspace', 'packages', 'initialization', 'private', 'staging'): - Path('/environment', name).mkdir(exist_ok=True) - Path('/environment/private/credential').write_text(CANARY) - Path('/environment/staging/request').write_text(CANARY) - os.environ['DAEMON_PRIVATE_CANARY'] = CANARY - env = {'INITIALIZATION_VALUE': CANARY, 'WITH_QUOTES': "'\n$(false)"} - if os.environ.get('OAC_TEST_PACKAGE_PROXY'): - env.update(http_proxy=os.environ['OAC_TEST_PACKAGE_PROXY'], - https_proxy=os.environ['OAC_TEST_PACKAGE_PROXY']) - invoke('configure', env=env) - # A fixed Runtime-installed fixture tests setup access, not archive parsing. - skill = Path('/environment/initialization/capabilities/skills/proof') - (skill / 'scripts').mkdir(parents=True) - (skill / 'SKILL.md').write_text('---\nname: proof\ndescription: A proof.\n---\nRead check.sh.') - (skill / 'SKILL.md').chmod(0o400) - (skill / 'scripts/check.sh').write_text('#!/bin/sh\nprintf skill-proof') - (skill / 'scripts/check.sh').chmod(0o500) - invoke('setup', command='/environment/initialization/capabilities/skills/proof/scripts/check.sh > skill-result') - assert Path('/environment/workspace/skill-result').read_text() == 'skill-proof' - if '--system' in sys.argv: - invoke('system', packages=['jq', 'build-essential', 'libpq-dev']) - invoke('system', succeeds=False, packages=['jq']) - # The system installer currently retains its own package-root contract. - # Its native execution checks are migrated with that installer. - - - # Re-entry must not replace confidential configuration after any effects. - invoke('configure', succeeds=False, env={'INITIALIZATION_VALUE': 'changed'}) - invoke('setup', command='printf "%s" "$INITIALIZATION_VALUE" > first; printf secret; printf secret >&2') - assert Path('/environment/workspace/first').read_text() == CANARY - check = '''import os, pathlib -for path in ('/environment/private/credential', '/environment/staging/request'): - assert pathlib.Path(path).read_text() == 'private-initialization-canary-47a8' -assert 'DAEMON_PRIVATE_CANARY' not in os.environ -assert os.environ['INITIALIZATION_VALUE'] == 'private-initialization-canary-47a8' -assert os.environ['WITH_QUOTES'] == "'\\n$(false)" -pathlib.Path('/environment/packages/visible').write_text('ok') -''' - Path('/environment/workspace/check.py').write_text(check) - invoke('setup', network='disabled', command='/usr/bin/python3 check.py') - # Shell cwd is explicit and ordered effects survive between invocations. - Path('/environment/workspace/sub').mkdir() - invoke('setup', cwd='/workspace/sub', command='test -f ../first && pwd > second') - assert Path('/environment/workspace/sub/second').read_text() == '/environment/workspace/sub\n' - invoke('setup', succeeds=False, exit_code=7, command='echo secret; echo secret >&2; exit 7') - invoke('setup', succeeds=False, exit_code=3, command='echo "$INITIALIZATION_VALUE"; echo "$INITIALIZATION_VALUE" >&2; exit 3') - # Failure to enter the requested cwd is a generic launcher failure. - invoke('setup', succeeds=False, cwd='/workspace/missing', command='touch should-not-exist') - assert not Path('/environment/workspace/should-not-exist').exists() - if '--packages' in sys.argv: - # Actual public registries, not synthetic package fixtures. - invoke('npm', packages=['is-number@7.0.0']) - invoke('python', packages=['packaging==26.0']) - # pip's diagnostics name the package and can echo configuration; only its status is reported. - invoke('python', succeeds=False, exit_code=1, packages=['oac-initializer-nonexistent-4f7e-zz']) - invoke('setup', cwd='/workspace/sub', command="node -e \"if (!require('/environment/packages/npm/lib/node_modules/is-number')(42)) process.exit(1)\" && python3 -c 'import packaging; assert packaging.__version__ == \"26.0\"'") - print(json.dumps({'initialization': 'passed', 'real_packages': '--packages' in sys.argv, - 'system_packages': '--system' in sys.argv})) - - -if __name__ == '__main__': - main() diff --git a/services/agents-api/deploy/runtime/tool-root.py b/services/agents-api/deploy/runtime/tool-root.py deleted file mode 100644 index 85cd60cb3..000000000 --- a/services/agents-api/deploy/runtime/tool-root.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/python3 -I -"""Trusted entry into an Environment's installed system tools.""" -import hashlib -import json -import os -from pathlib import Path -import subprocess -import sys - - -ROOT = Path('/environment/packages/system') -CONFIG = Path('/environment/initialization') -SEED = Path('/opt/agents-runtime/system-root.tar.gz') -BASE_ENV = {'PATH': '/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', - 'HOME': '/tmp', 'TMPDIR': '/tmp', 'LANG': 'C.UTF-8'} -MARKER = CONFIG / 'system-root.json' - - -def installed(): - try: - if json.loads(MARKER.read_text()) != {'version': 1}: - raise ValueError('invalid system tools receipt') - return True - except FileNotFoundError: - return False - - -def sandbox(cwd, *, writable=False, network='enabled', workspace='/workspace', scratch=None): - if not isinstance(cwd, str) or not cwd.startswith('/') or '\x00' in cwd: - raise ValueError('invalid working directory') - args = ['/usr/bin/bwrap', '--unshare-user', '--uid', '0', '--gid', '0', - '--unshare-pid', '--unshare-ipc', '--unshare-uts', '--die-with-parent', - '--cap-drop', 'ALL', '--bind' if writable else '--ro-bind', str(ROOT), '/', - '--ro-bind', '/etc/resolv.conf', '/etc/resolv.conf', - '--ro-bind', '/etc/hosts', '/etc/hosts', '--ro-bind', '/etc/ssl', '/etc/ssl', - '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--tmpfs', '/home', - '--bind', workspace, '/workspace', '--dir', '/environment', - '--bind', workspace, '/environment/workspace', - '--bind', '/environment/packages', '/environment/packages', - '--ro-bind', str(ROOT), str(ROOT), '--ro-bind', str(CONFIG), str(CONFIG)] - if workspace not in ('/workspace', '/environment/workspace'): - args += ['--bind', workspace, workspace] - if network == 'disabled': - args += ['--unshare-net'] - elif network != 'enabled': - raise ValueError('invalid network configuration') - if scratch: - args += ['--bind', scratch, scratch] - return args + ['--chdir', cwd, '--'] - - -def initialization_sandbox(cwd, network='enabled', *, writable=False, workspace='/environment/workspace'): - args = sandbox(cwd, writable=writable, network=network, workspace=workspace) - args[1:1] = ['--new-session', '--clearenv'] - for key, value in {**BASE_ENV, 'DEBIAN_FRONTEND': 'noninteractive'}.items(): - args[-1:-1] = ['--setenv', key, value] - return args - - -def install(packages, *, workspace='/environment/workspace'): - if not isinstance(packages, list) or not packages or any( - not isinstance(p, str) or not p or p.startswith('-') or '\x00' in p for p in packages - ): - raise ValueError('invalid packages') - manifest = json.loads(SEED.with_name('system-root.json').read_text()) - with SEED.open('rb') as stream: - digest = hashlib.file_digest(stream, 'sha256').hexdigest() - if manifest != {'version': 1, 'sha256': digest, 'size_bytes': SEED.stat().st_size}: - raise ValueError('invalid system seed') - # The immutable build artifact predates credentials; never snapshot a live Runtime. - ROOT.mkdir(mode=0o700) - subprocess.run(['/usr/bin/tar', '--no-same-owner', '--no-same-permissions', '-xzf', str(SEED), '-C', str(ROOT)], - check=True, env=BASE_ENV, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - args = initialization_sandbox('/workspace', writable=True, workspace=workspace) - # Namespace root maps only to the unprivileged Runtime UID; _apt is unmapped. - apt = ['/usr/bin/apt-get', '-o', 'APT::Sandbox::User=root', '-o', 'Acquire::Retries=0'] - for command in (apt + ['update'], apt + ['install', '-y', '--no-install-recommends', '--', *packages]): - subprocess.run(args + ['/bin/bash', '--noprofile', '--norc', '-c', - '. /environment/initialization/tool-env.sh && exec "$@"', '--', *command], - check=True, env=BASE_ENV, stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - fd = os.open(MARKER, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o400) - with os.fdopen(fd, 'w') as stream: - stream.write('{"version":1}\n') - stream.flush() - os.fsync(stream.fileno()) - - -def main(): - if len(sys.argv) != 2 or not installed(): - raise ValueError('system tools unavailable') - env = dict(BASE_ENV) - scratch = os.environ.get('OAC_RUNTIME_TOOL_SCRATCH') - if scratch: - path = Path(scratch) - temporary = Path(os.environ.get('TMPDIR', scratch)) - if not path.is_absolute() or path == Path('/') or path.resolve(strict=True) != path or not path.is_dir(): - raise ValueError('invalid Runtime scratch') - if temporary.resolve(strict=True) != temporary or not temporary.is_dir() or not temporary.is_relative_to(path): - raise ValueError('invalid native temporary directory') - env['TMPDIR'] = str(temporary) - # Native sandbox networking already selected the proxy and namespace. - for key in ('HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', - 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy'): - if key in os.environ: - env[key] = os.environ[key] - args = sandbox(os.getcwd(), scratch=scratch) - args += ['/bin/bash', '--noprofile', '--norc', '-c', - '. /environment/initialization/tool-env.sh && eval -- "$1"', '--', sys.argv[1]] - os.execve(args[0], args, env) - - -if __name__ == '__main__': - try: - main() - except Exception: - print('Initialized system tools unavailable', file=sys.stderr) - sys.exit(1) diff --git a/services/agents-api/internal/api/environment_packages_test.go b/services/agents-api/internal/api/environment_packages_test.go new file mode 100644 index 000000000..454c45983 --- /dev/null +++ b/services/agents-api/internal/api/environment_packages_test.go @@ -0,0 +1,63 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSystemPackagesRejectedAtEveryInput(t *testing.T) { + for _, value := range []string{`null`, `[]`, `["jq"]`} { + packages := `"packages":{"npm":["semver"],"python":["packaging"],"system":` + value + `}` + t.Run(value, func(t *testing.T) { + if _, err := decodeTemplateInput([]byte("{" + packages + "}")); !errors.Is(err, errSystemPackages) { + t.Fatal("template accepted removed system packages") + } + raw := json.RawMessage(`{"type":"openai_hosted",` + packages + "}") + if _, err := decodeSessionEnvironment(raw); !errors.Is(err, errSystemPackages) { + t.Fatal("Session accepted removed system packages") + } + if _, err := storedEnvironment(raw); !errors.Is(err, errSystemPackages) { + t.Fatal("stored public configuration silently ignored system packages") + } + reference := json.RawMessage(`{"type":"openai_hosted","environment_template_id":"saved",` + packages + "}") + if _, _, _, err := decodeTemplateEnvironment(reference); !errors.Is(err, errSystemPackages) { + t.Fatal("template override accepted removed system packages") + } + }) + } + if _, err := decodeTemplateInput([]byte(`{"packages":{"npm":["semver"],"python":["packaging"]}}`)); err != nil { + t.Fatal("supported package managers rejected", err) + } +} + +func TestSystemPackageErrorExplainsPreinstallation(t *testing.T) { + h, _, _ := testHandler(t) + for _, endpoint := range []string{"/v1/agents/environments/templates", "/v1/agents/sessions"} { + body := `{"packages":{"system":["private-package-canary"]}}` + if endpoint == "/v1/agents/sessions" { + body = `{"agent":{"model":"test"},"environment":{"type":"openai_hosted","packages":{"system":["private-package-canary"]}}}` + } + req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer test-api-key") + req.Header.Set("OpenAI-Beta", "agents=v1") + req.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + h.ServeHTTP(response, req) + var result struct { + Error struct { + Param string `json:"param"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if response.Code != http.StatusBadRequest || result.Error.Param != "packages.system" || result.Error.Message != errSystemPackages.message || strings.Contains(response.Body.String(), "private-package-canary") { + t.Fatal(endpoint, response.Code, response.Body.String()) + } + } +} diff --git a/services/agents-api/internal/api/environment_request.go b/services/agents-api/internal/api/environment_request.go index 9a79b645f..245af23dc 100644 --- a/services/agents-api/internal/api/environment_request.go +++ b/services/agents-api/internal/api/environment_request.go @@ -13,6 +13,13 @@ func decodeSessionEnvironment(raw json.RawMessage) (*v1.Environment, error) { if json.Unmarshal(raw, &environment) != nil { return nil, store.ErrInvalidInput } + var input map[string]json.RawMessage + if json.Unmarshal(raw, &input) != nil { + return nil, store.ErrInvalidInput + } + if err := rejectSystemPackages(input["packages"]); err != nil { + return nil, err + } fields := []string{"type"} switch environment.Type { case "none": diff --git a/services/agents-api/internal/api/environment_setup.go b/services/agents-api/internal/api/environment_setup.go index efdc66ad8..367b948ac 100644 --- a/services/agents-api/internal/api/environment_setup.go +++ b/services/agents-api/internal/api/environment_setup.go @@ -8,8 +8,26 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) +var errSystemPackages = &fieldError{ + param: "packages.system", + message: "Runtime system package installation is not supported. Preinstall system dependencies in the sandbox image or template, or on the host machine.", +} + +func rejectSystemPackages(raw json.RawMessage) error { + var packages map[string]json.RawMessage + if json.Unmarshal(raw, &packages) == nil { + if _, supplied := packages["system"]; supplied { + return errSystemPackages + } + } + return nil +} + func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.EnvironmentSetup, error) { var result store.EnvironmentSetup + if err := rejectSystemPackages(fields["packages"]); err != nil { + return result, err + } if value, ok := fields["env"]; ok { var entries map[string]*string if json.Unmarshal(value, &entries) != nil { @@ -50,20 +68,17 @@ func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.Environmen var input struct { NPM []*string `json:"npm"` Python []*string `json:"python"` - System []*string `json:"system"` } - if decodeInputObject(value, &input, "npm", "python", "system") != nil { + if decodeInputObject(value, &input, "npm", "python") != nil { return result, store.ErrInvalidInput } - for name, entries := range map[string][]*string{"npm": input.NPM, "python": input.Python, "system": input.System} { + for name, entries := range map[string][]*string{"npm": input.NPM, "python": input.Python} { var target *[]string switch name { case "npm": target = &result.Packages.NPM case "python": target = &result.Packages.Python - case "system": - target = &result.Packages.System } for _, entry := range entries { if entry == nil { diff --git a/services/agents-api/internal/api/environment_templates.go b/services/agents-api/internal/api/environment_templates.go index d82edc8fc..105019546 100644 --- a/services/agents-api/internal/api/environment_templates.go +++ b/services/agents-api/internal/api/environment_templates.go @@ -79,14 +79,14 @@ func readTemplateInput(w http.ResponseWriter, r *http.Request) (store.Environmen return in, false } if err != nil { - writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled or exact-domain restricted network, initial files, env, system/npm/Python packages, setup commands inline/referenced Skill ZIPs, Plugin ZIPs and workspace capability directories are supported.") + writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", "Template fields are invalid or require unsupported initialization. Name, enabled/disabled or exact-domain restricted network, initial files, env, npm/Python packages, setup commands inline/referenced Skill ZIPs, Plugin ZIPs and workspace capability directories are supported.") return in, false } return in, true } // @Summary Create an Environment Template -// @Description Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs and workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. Network policy rejections return invalid_request_error with a null param. No compute is allocated. Exact hosted error/retry semantics remain unverified. +// @Description Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs and workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. Network policy rejections return invalid_request_error with a null param. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. No compute is allocated. Exact hosted error/retry semantics remain unverified. // @Tags Environment Templates // @Accept json // @Produce json diff --git a/services/agents-api/internal/api/environment_templates_test.go b/services/agents-api/internal/api/environment_templates_test.go index f9cc2f7b2..5ad12449f 100644 --- a/services/agents-api/internal/api/environment_templates_test.go +++ b/services/agents-api/internal/api/environment_templates_test.go @@ -12,7 +12,7 @@ import ( ) func TestTemplateConfigurationRejectsUnqualifiedInputs(t *testing.T) { - for _, raw := range []string{`{"network":{"access":"restricted","allowed_domains":["Example.com","example.com"]}}`, `{}`, `{"packages":{}}`, `{"packages":{"npm":null}}`, `{"packages":{"system":["jq","libpq-dev"]}}`, `{"packages":{"system":null}}`, `{"name":null,"network":null}`, `{"name":"保存","network":{"access":"disabled"},"env":{},"files":[],"setup_commands":[],"packages":{"npm":null}}`} { + for _, raw := range []string{`{"network":{"access":"restricted","allowed_domains":["Example.com","example.com"]}}`, `{}`, `{"packages":{}}`, `{"packages":{"npm":null}}`, `{"name":null,"network":null}`, `{"name":"保存","network":{"access":"disabled"},"env":{},"files":[],"setup_commands":[],"packages":{"npm":null}}`} { if _, err := decodeTemplateInput([]byte(raw)); err != nil { t.Fatalf("supported input: %s: %v", raw, err) } diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 2a7112fe1..42e4f4079 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -159,7 +159,7 @@ func (h *Handler) routes() *chi.Mux { // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. +// @Description The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/api/hosted_environment_test.go b/services/agents-api/internal/api/hosted_environment_test.go index 04ea2e235..8146b26cb 100644 --- a/services/agents-api/internal/api/hosted_environment_test.go +++ b/services/agents-api/internal/api/hosted_environment_test.go @@ -16,9 +16,8 @@ import ( func TestHostedEnvironmentDefaultsAndExplicitGaps(t *testing.T) { for _, raw := range []string{ `{"type":"openai_hosted"}`, - `{"type":"openai_hosted","packages":{"system":["jq","libpq-dev"]}}`, `{"type":"openai_hosted","network":null,"env":null,"files":null,"packages":null,"plugins":null,"skills":null,"setup_commands":null,"capability_directories":null}`, - `{"type":"openai_hosted","network":{"access":"enabled","allowed_domains":null},"env":{},"files":[],"packages":{"npm":[],"python":null,"system":[]},"plugins":[],"skills":[],"setup_commands":[],"capability_directories":[]}`, + `{"type":"openai_hosted","network":{"access":"enabled","allowed_domains":null},"env":{},"files":[],"packages":{"npm":[],"python":null},"plugins":[],"skills":[],"setup_commands":[],"capability_directories":[]}`, } { got, err := decodeSessionEnvironment(json.RawMessage(raw)) if err != nil || got.Network == nil || got.Network.Access != "enabled" { @@ -63,7 +62,7 @@ func TestHostedEnvironmentResponseHasPinnedShapeAndNoConnectionAction(t *testing if json.Unmarshal(raw, &got) != nil { t.Fatal("invalid environment JSON") } - expected := map[string]any{"id": "environment", "type": "openai_hosted", "capability_directories": []any{}, "files": []any{}, "plugins": []any{}, "skills": []any{}, "packages": map[string]any{"npm": []any{}, "python": []any{}, "system": []any{}}, "network": map[string]any{"access": "enabled", "allowed_domains": []any{}}} + expected := map[string]any{"id": "environment", "type": "openai_hosted", "capability_directories": []any{}, "files": []any{}, "plugins": []any{}, "skills": []any{}, "packages": map[string]any{"npm": []any{}, "python": []any{}}, "network": map[string]any{"access": "enabled", "allowed_domains": []any{}}} if !reflect.DeepEqual(got, expected) { t.Fatal("hosted response shape changed", string(raw)) } diff --git a/services/agents-api/internal/api/session_template.go b/services/agents-api/internal/api/session_template.go index 75d2dedfe..2cc05d800 100644 --- a/services/agents-api/internal/api/session_template.go +++ b/services/agents-api/internal/api/session_template.go @@ -96,12 +96,8 @@ func (h *Handler) resolveTemplateEnvironment(ctx context.Context, tenant string, if templateFieldOverride(managers, "python") { setup.Packages.Python = input.initialization.Packages.Python } - if templateFieldOverride(managers, "system") { - setup.Packages.System = input.initialization.Packages.System - } setup.Packages.NPM = slices.Clone(setup.Packages.NPM) setup.Packages.Python = slices.Clone(setup.Packages.Python) - setup.Packages.System = slices.Clone(setup.Packages.System) setup.Skills, setup.Plugins, setup.CapabilityDirectories = skills, plugins, directories if err := setup.Validate(); err != nil { return err diff --git a/services/agents-api/internal/api/session_template_composition_test.go b/services/agents-api/internal/api/session_template_composition_test.go index 376ad05ee..57601bdfa 100644 --- a/services/agents-api/internal/api/session_template_composition_test.go +++ b/services/agents-api/internal/api/session_template_composition_test.go @@ -41,7 +41,7 @@ func compositionFixture() *compositionTemplateStore { template: store.EnvironmentTemplate{NetworkAccess: "enabled", Initialization: store.EnvironmentSetup{ Env: map[string]string{"TEMPLATE": "private-template-env", "SHARED": "private-old-value"}, Commands: []store.SetupCommand{{Command: "printf private-template-command"}}, - Packages: v1.EnvironmentPackages{NPM: []string{"semver@7.7.2"}, Python: []string{"packaging==25.0"}, System: []string{"jq"}}, + Packages: v1.EnvironmentPackages{NPM: []string{"semver@7.7.2"}, Python: []string{"packaging==25.0"}}, }}, files: []store.InitialFile{{Type: "inline", Path: "/workspace/template", Data: []byte("private-template-bytes")}}, } @@ -55,8 +55,8 @@ func TestTemplateInlineCompositionRules(t *testing.T) { {name: "omitted"}, {name: "null", fields: `,"env":null,"setup_commands":null,"files":null,"packages":null`}, {name: "empty objects", fields: `,"env":{},"packages":{}`}, - {name: "manager null", fields: `,"packages":{"npm":null,"python": null ,"system":null}`}, - {name: "clear lists", fields: `,"setup_commands":[],"files":[],"packages":{"npm":[],"python":[],"system":[]}`, change: func(s *store.EnvironmentSetup, f *[]store.InitialFile) { + {name: "manager null", fields: `,"packages":{"npm":null,"python": null }`}, + {name: "clear lists", fields: `,"setup_commands":[],"files":[],"packages":{"npm":[],"python":[]}`, change: func(s *store.EnvironmentSetup, f *[]store.InitialFile) { s.Commands, *f = nil, nil s.Packages = v1.EnvironmentPackages{} }}, @@ -66,8 +66,8 @@ func TestTemplateInlineCompositionRules(t *testing.T) { *f = []store.InitialFile{{Type: "inline", Path: "/workspace/inline", Data: []byte("private-inline-bytes")}} s.Packages.NPM, s.Packages.Python = nil, []string{"idna==3.10"} }}, - {name: "mixed managers", fields: `,"packages":{"npm":null,"system":[],"python":["idna==3.10"]}`, change: func(s *store.EnvironmentSetup, _ *[]store.InitialFile) { - s.Packages.Python, s.Packages.System = []string{"idna==3.10"}, nil + {name: "mixed managers", fields: `,"packages":{"npm":null,"python":["idna==3.10"]}`, change: func(s *store.EnvironmentSetup, _ *[]store.InitialFile) { + s.Packages.Python = []string{"idna==3.10"} }}, {name: "overlapping path replaces whole list", fields: `,"files":[{"type":"inline","path":"/workspace/template","data":"bmV3"}]`, change: func(_ *store.EnvironmentSetup, f *[]store.InitialFile) { *f = []store.InitialFile{{Type: "inline", Path: "/workspace/template", Data: []byte("new")}} diff --git a/services/agents-api/internal/execution/environment_placement.go b/services/agents-api/internal/execution/environment_placement.go index ddb9b619b..f29f5ee6e 100644 --- a/services/agents-api/internal/execution/environment_placement.go +++ b/services/agents-api/internal/execution/environment_placement.go @@ -17,7 +17,6 @@ type environmentPlacement struct { Skills []store.EnvironmentSkillMetadata `json:"skills,omitempty"` Type string `json:"type"` ToolEnvironment bool `json:"initialization,omitempty"` - SystemPackages bool `json:"-"` NetworkAccess string `json:"-"` AllowedDomains []string `json:"-"` WorkspaceDirectory string `json:"workspace_directory"` @@ -68,7 +67,6 @@ func parseEnvironmentPlacement(configuration json.RawMessage) (environmentPlacem decoder := json.NewDecoder(bytes.NewReader(configuration)) decoder.DisallowUnknownFields() if decoder.Decode(&local) == nil && agentcapabilities.ValidateDirectories(local.CapabilityDirectories) == nil { - placement.SystemPackages = local.Packages != nil && len(local.Packages.System) > 0 placement.NetworkAccess = "enabled" if local.Network != nil { if (agentnetwork.Policy{Access: local.Network.Access, AllowedDomains: local.Network.AllowedDomains}).Validate() != nil { @@ -95,9 +93,6 @@ func (d *Dispatcher) configurePreparedEnvironment(session store.Session, environ if err != nil || !environmentDeviceMatches(session, environment, bound) { return store.ErrInvalidInput } - if placement.SystemPackages && !placement.ToolEnvironment { - return store.ErrInvalidInput - } sources := &agentcapabilities.Input{Plugins: append([]agentplugin.Metadata(nil), placement.Plugins...), Directories: append([]string(nil), placement.CapabilityDirectories...)} for _, metadata := range placement.Skills { if store.ValidateInstalledSkillMetadata(metadata) != nil { @@ -109,7 +104,7 @@ func (d *Dispatcher) configurePreparedEnvironment(session store.Session, environ ID: environment.ID, WorkspaceDirectory: placement.WorkspaceDirectory, CapabilitySources: sources, Capabilities: len(sources.Skills)+len(sources.Plugins)+len(sources.Directories) > 0, - ToolEnvironment: placement.ToolEnvironment, SystemPackages: placement.SystemPackages, + ToolEnvironment: placement.ToolEnvironment, } req.LocalEnvironment.NetworkAccess = placement.NetworkAccess req.LocalEnvironment.AllowedDomains = append([]string(nil), placement.AllowedDomains...) diff --git a/services/agents-api/internal/execution/environment_placement_test.go b/services/agents-api/internal/execution/environment_placement_test.go index 647e57538..7d78a4854 100644 --- a/services/agents-api/internal/execution/environment_placement_test.go +++ b/services/agents-api/internal/execution/environment_placement_test.go @@ -77,55 +77,12 @@ func TestNetworkPolicySurvivesPreparedBinding(t *testing.T) { } } -func TestSystemPackagesRemainRequiredInExecutionBinding(t *testing.T) { +func TestToolEnvironmentRemainsInExecutionBinding(t *testing.T) { session := store.Session{ID: "session", TenantID: "tenant"} - environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, - Configuration: []byte(`{"type":"openai_hosted","initialization":true,"packages":{"system":["jq"]}}`)} + environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, Configuration: []byte(`{"type":"openai_hosted","initialization":true,"packages":{"npm":["is-number"]}}`)} var req proto.PromptRequestPayload - err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, - store.ExecutionDevice{EnvironmentID: environment.ID}, &req) - if err != nil || req.LocalEnvironment == nil || !req.LocalEnvironment.ToolEnvironment || !req.LocalEnvironment.SystemPackages { - t.Fatal("system initialization requirement was lost", err) - } - environment.Configuration = []byte(`{"type":"openai_hosted","packages":{"system":["jq"]}}`) - if !LocalWorkspaceConfiguration(environment.Configuration) { - t.Fatal("public admission requires a private execution receipt") - } - req = proto.PromptRequestPayload{} - if err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, - store.ExecutionDevice{EnvironmentID: environment.ID}, &req); err == nil || req.LocalEnvironment != nil { - t.Fatal("execution without the required initialization was admitted") - } -} - -func TestLocalNetworkDefaultsAndSupportedPolicies(t *testing.T) { - for _, configuration := range []string{`{"type":"openai_hosted"}`, `{"type":"openai_hosted","network":null}`, `{"type":"openai_hosted","network":{"access":"enabled","allowed_domains":[]}}`} { - got, err := parseEnvironmentPlacement([]byte(configuration)) - if err != nil || got.NetworkAccess != "enabled" { - t.Fatal("enabled default lost", got, err) - } - } - got, err := parseEnvironmentPlacement([]byte(`{"type":"openai_hosted","network":{"access":"disabled","allowed_domains":null}}`)) - if err != nil || got.NetworkAccess != "disabled" { - t.Fatal("disabled policy lost", got, err) - } -} - -func TestSelfHostedUsesSameLocalBinding(t *testing.T) { - session := store.Session{ID: "session", TenantID: "tenant"} - for _, workspace := range []string{"/workspace", "/other"} { - environment := store.Environment{ID: "environment", SessionID: session.ID, TenantID: session.TenantID, - Configuration: []byte(`{"type":"self_hosted","workspace_directory":"` + workspace + `"}`)} - for _, binding := range []string{"", "foreign", environment.ID} { - var request proto.PromptRequestPayload - err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, store.ExecutionDevice{EnvironmentID: binding}, &request) - valid := binding == environment.ID - if (err == nil) != valid { - t.Fatal(workspace, binding, err) - } - if valid && (request.LocalEnvironment == nil || request.LocalEnvironment.ID != environment.ID || request.LocalEnvironment.NetworkAccess != "enabled" || request.LocalEnvironment.WorkspaceDirectory != workspace || request.LocalEnvironment.CapabilitySources == nil) { - t.Fatal("self-hosted placement did not retain common local contract", request.LocalEnvironment) - } - } + err := (&Dispatcher{}).configurePreparedEnvironment(session, environment, store.ExecutionDevice{EnvironmentID: environment.ID}, &req) + if err != nil || req.LocalEnvironment == nil || !req.LocalEnvironment.ToolEnvironment { + t.Fatal("tool initialization requirement lost", err) } } diff --git a/services/agents-api/internal/execution/runtime_setup.go b/services/agents-api/internal/execution/runtime_setup.go index 4225f3280..4dc4cf6de 100644 --- a/services/agents-api/internal/execution/runtime_setup.go +++ b/services/agents-api/internal/execution/runtime_setup.go @@ -28,7 +28,7 @@ func (operation runtimeSetupOperation) provisioningFailure(exitCode int) store.P action = operation.Request.Initialization.Action } switch action { - case "setup", "python", "npm", "system", "skill": + case "setup", "python", "npm", "skill": return store.ProvisioningFailure{Step: action, Index: operation.Index, ExitCode: exitCode} } return store.ProvisioningFailure{} @@ -57,7 +57,7 @@ func setupOperations(setup store.EnvironmentSetup) []runtimeSetupOperation { for _, packages := range []struct { action string values []string - }{{"system", setup.Packages.System}, {"npm", setup.Packages.NPM}, {"python", setup.Packages.Python}} { + }{{"npm", setup.Packages.NPM}, {"python", setup.Packages.Python}} { if len(packages.values) > 0 { result = append(result, initialize(proto.RuntimeInitialization{Action: packages.action, Network: "enabled", Packages: packages.values})) } diff --git a/services/agents-api/internal/execution/runtime_setup_test.go b/services/agents-api/internal/execution/runtime_setup_test.go index 9a8a44bb2..5955e2fa9 100644 --- a/services/agents-api/internal/execution/runtime_setup_test.go +++ b/services/agents-api/internal/execution/runtime_setup_test.go @@ -53,7 +53,7 @@ func TestRuntimeSetupReceiptOutcomes(t *testing.T) { } } func TestRuntimeSetupFailureLabels(t *testing.T) { - for action, want := range map[string]store.ProvisioningFailure{"setup": {Step: store.ProvisioningSetupCommand, Index: 2, ExitCode: 3}, "python": {Step: store.ProvisioningPythonPackages, Index: 2, ExitCode: 3}, "npm": {Step: store.ProvisioningNPMPackages, Index: 2, ExitCode: 3}, "system": {Step: store.ProvisioningSystemPackages, Index: 2, ExitCode: 3}, "skill": {Step: store.ProvisioningSkill, Index: 2, ExitCode: 3}, "configure": {}, "": {}} { + for action, want := range map[string]store.ProvisioningFailure{"setup": {Step: store.ProvisioningSetupCommand, Index: 2, ExitCode: 3}, "python": {Step: store.ProvisioningPythonPackages, Index: 2, ExitCode: 3}, "npm": {Step: store.ProvisioningNPMPackages, Index: 2, ExitCode: 3}, "skill": {Step: store.ProvisioningSkill, Index: 2, ExitCode: 3}, "configure": {}, "": {}} { op := runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: action}, Index: 2} if action != "skill" { op.Request = proto.RuntimePreparePayload{Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: action}} diff --git a/services/agents-api/internal/store/environment_packages_test.go b/services/agents-api/internal/store/environment_packages_test.go new file mode 100644 index 000000000..d557bc48e --- /dev/null +++ b/services/agents-api/internal/store/environment_packages_test.go @@ -0,0 +1,39 @@ +package store + +import ( + "bytes" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestStoredSystemPackagesRejected(t *testing.T) { + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{5}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(nil, cipher) + tenant, session := uuid.NewString(), uuid.NewString() + for _, value := range []string{`null`, `[]`, `["jq"]`} { + packages := []byte(`{"npm":[],"python":[],"system":` + value + "}") + row := templateMetadataRow{Files: []byte("[]"), Skills: []byte("[]"), Plugins: []byte("[]"), Packages: packages} + if _, err := templateFromRow(row, nil); err == nil { + t.Fatal("template silently ignored removed system packages", value) + } + plain := append([]byte(`{"packages":`), packages...) + plain = append(plain, '}') + encrypted, err := cipher.SealEnvironmentSetup(plain, credentialcrypto.EnvironmentSetupBinding{TenantID: tenant, Resource: "session", OwnerID: session, Field: "initialization"}) + if err != nil { + t.Fatal(err) + } + var output EnvironmentSetup + if err := s.openEnvironmentSetup(tenant, "session", session, "initialization", encrypted, &output); err == nil { + t.Fatal("snapshot silently ignored removed system packages", value) + } + } + row := templateMetadataRow{Files: []byte("[]"), Skills: []byte("[]"), Plugins: []byte("[]"), Packages: []byte(`{"npm":["semver"],"python":["packaging"]}`)} + if _, err := templateFromRow(row, nil); err != nil { + t.Fatal("supported stored package managers rejected", err) + } +} diff --git a/services/agents-api/internal/store/environment_setup.go b/services/agents-api/internal/store/environment_setup.go index 541f1fc6f..55c1d52ec 100644 --- a/services/agents-api/internal/store/environment_setup.go +++ b/services/agents-api/internal/store/environment_setup.go @@ -1,6 +1,7 @@ package store import ( + "bytes" "context" "encoding/json" "errors" @@ -36,7 +37,7 @@ type SetupCommand struct { var environmentName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) func (s EnvironmentSetup) Empty() bool { - return len(s.Env)+len(s.Commands)+len(s.Packages.NPM)+len(s.Packages.Python)+len(s.Packages.System)+len(s.Skills)+len(s.Plugins)+len(s.CapabilityDirectories) == 0 + return len(s.Env)+len(s.Commands)+len(s.Packages.NPM)+len(s.Packages.Python)+len(s.Skills)+len(s.Plugins)+len(s.CapabilityDirectories) == 0 } func (s EnvironmentSetup) Validate() error { @@ -66,7 +67,7 @@ func (s EnvironmentSetup) validate(installed bool) error { return ErrInvalidInput } } - for _, packages := range [][]string{s.Packages.NPM, s.Packages.Python, s.Packages.System} { + for _, packages := range [][]string{s.Packages.NPM, s.Packages.Python} { for _, item := range packages { if item == "" || strings.HasPrefix(item, "-") || strings.ContainsRune(item, 0) { return ErrInvalidInput @@ -95,7 +96,7 @@ func (s *Store) openEnvironmentSetup(tenant, resource, id, field string, ciphert if err != nil { return err } - if json.Unmarshal(plaintext, output) != nil { + if decodeSetupJSON(plaintext, output) != nil { return ErrInvalidInput } return nil @@ -146,9 +147,6 @@ func (s EnvironmentSetup) PackageMetadata() v1.EnvironmentPackages { if result.Python == nil { result.Python = []string{} } - if result.System == nil { - result.System = []string{} - } return result } @@ -164,3 +162,13 @@ func (s *Store) sealTemplateSetup(tenant, id string, setup EnvironmentSetup) ([] commands, err := s.sealEnvironmentSetup(tenant, "environment_template", id, "setup_commands", setup.Commands, len(setup.Commands) == 0) return packages, env, commands, err } + +// decodeSetupJSON rejects removed configuration fields instead of silently dropping them. +func decodeSetupJSON(data []byte, output any) error { + if !json.Valid(data) { + return ErrInvalidInput + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + return decoder.Decode(output) +} diff --git a/services/agents-api/internal/store/environment_setup_test.go b/services/agents-api/internal/store/environment_setup_test.go index 28ccd0051..3e957e9b5 100644 --- a/services/agents-api/internal/store/environment_setup_test.go +++ b/services/agents-api/internal/store/environment_setup_test.go @@ -20,13 +20,13 @@ func TestEnvironmentSetupEncryptedSnapshotAndIsolation(t *testing.T) { } s := NewWithCredentialCipher(pool, cipher) tenant, foreign := uuid.NewString(), uuid.NewString() - setup := EnvironmentSetup{Env: map[string]string{"SECRET": "template-env-canary"}, Commands: []SetupCommand{{Command: "printf template-command-canary > result"}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}, System: []string{"jq", "libpq-dev"}}} + setup := EnvironmentSetup{Env: map[string]string{"SECRET": "template-env-canary"}, Commands: []SetupCommand{{Command: "printf template-command-canary > result"}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}}} template, err := s.CreateEnvironmentTemplate(t.Context(), tenant, EnvironmentTemplateInput{Initialization: setup, SetEnv: true, SetSetup: true, SetPackages: true}) if err != nil { t.Fatal(err) } public, err := New(pool).GetEnvironmentTemplate(t.Context(), tenant, template.ID) - if err != nil || len(public.Packages.NPM) != 1 || !reflect.DeepEqual(public.Packages.System, setup.Packages.System) || !public.Initialization.Empty() { + if err != nil || len(public.Packages.NPM) != 1 || !reflect.DeepEqual(public.Packages.NPM, setup.Packages.NPM) || !public.Initialization.Empty() { t.Fatal("public metadata requires plaintext or key", err) } resolved, _, err := s.ResolveEnvironmentTemplate(t.Context(), tenant, template.ID) diff --git a/services/agents-api/internal/store/environment_templates.go b/services/agents-api/internal/store/environment_templates.go index 1c512c3ab..d12f0cfae 100644 --- a/services/agents-api/internal/store/environment_templates.go +++ b/services/agents-api/internal/store/environment_templates.go @@ -64,7 +64,7 @@ func templateFromRow(row templateMetadataRow, err error) (EnvironmentTemplate, e if row.Name.Valid { result.Name = &row.Name.String } - if json.Unmarshal(row.Files, &result.Files) != nil || json.Unmarshal(row.Packages, &result.Packages) != nil || json.Unmarshal(row.Skills, &result.Skills) != nil || json.Unmarshal(row.Plugins, &result.Plugins) != nil { + if json.Unmarshal(row.Files, &result.Files) != nil || decodeSetupJSON(row.Packages, &result.Packages) != nil || json.Unmarshal(row.Skills, &result.Skills) != nil || json.Unmarshal(row.Plugins, &result.Plugins) != nil { return EnvironmentTemplate{}, ErrInvalidInput } return result, nil diff --git a/services/agents-api/internal/store/provisioning_failure_detail.go b/services/agents-api/internal/store/provisioning_failure_detail.go index d3876916f..cd18ef33c 100644 --- a/services/agents-api/internal/store/provisioning_failure_detail.go +++ b/services/agents-api/internal/store/provisioning_failure_detail.go @@ -24,7 +24,7 @@ func (f ProvisioningFailureDetail) sanitized() *ProvisioningFailureDetail { value := *f.Index result.Index = &value } - case ProvisioningPythonPackages, ProvisioningNPMPackages, ProvisioningSystemPackages: + case ProvisioningPythonPackages, ProvisioningNPMPackages: case ProvisioningInitialFile, ProvisioningSkill: value := *f.Step result.Step = &value diff --git a/services/agents-api/internal/store/runtime_environment_terminal.go b/services/agents-api/internal/store/runtime_environment_terminal.go index 4a1414935..7243ae082 100644 --- a/services/agents-api/internal/store/runtime_environment_terminal.go +++ b/services/agents-api/internal/store/runtime_environment_terminal.go @@ -21,7 +21,6 @@ const ( ProvisioningSetupCommand = "setup" ProvisioningPythonPackages = "python" ProvisioningNPMPackages = "npm" - ProvisioningSystemPackages = "system" ProvisioningInitialFile = "file" ProvisioningSkill = "skill" ) @@ -37,13 +36,12 @@ type ProvisioningFailure struct { // reason renders the public Session error. The setup_commands and Python package // labels match observed official errors (which append raw pip output for Python; -// Core never does). The npm, system package, file and Skill labels are unverified. +// Core never does). The npm, file and Skill labels are unverified. // A script step without a reported exit status keeps the generic reason. func (f ProvisioningFailure) reason() string { label := map[string]string{ ProvisioningPythonPackages: "Python package installation", ProvisioningNPMPackages: "npm package installation", - ProvisioningSystemPackages: "System package installation", }[f.Step] if f.Step == ProvisioningSetupCommand && f.Index >= 0 { label = fmt.Sprintf("setup_commands[%d]", f.Index) diff --git a/services/agents-api/internal/store/runtime_environment_terminal_test.go b/services/agents-api/internal/store/runtime_environment_terminal_test.go index 33ea92238..99052e2aa 100644 --- a/services/agents-api/internal/store/runtime_environment_terminal_test.go +++ b/services/agents-api/internal/store/runtime_environment_terminal_test.go @@ -157,7 +157,6 @@ func TestProvisioningFailureReasons(t *testing.T) { {Step: ProvisioningSetupCommand, Index: 12, ExitCode: 1}: `Failed to provision environment: script "setup_commands[12]" failed with exit code 1`, {Step: ProvisioningPythonPackages, ExitCode: 1}: `Failed to provision environment: script "Python package installation" failed with exit code 1`, {Step: ProvisioningNPMPackages, ExitCode: 1}: `Failed to provision environment: script "npm package installation" failed with exit code 1`, - {Step: ProvisioningSystemPackages, ExitCode: 100}: `Failed to provision environment: script "System package installation" failed with exit code 100`, {Step: ProvisioningInitialFile}: "Failed to provision environment: initial file installation failed", {Step: ProvisioningSkill}: "Failed to provision environment: Skill installation failed", // Missing or impossible statuses, unknown steps and old receipts stay generic. diff --git a/services/agents-api/internal/store/runtime_initialization_test.go b/services/agents-api/internal/store/runtime_initialization_test.go index d58d36d33..2dc1fb20e 100644 --- a/services/agents-api/internal/store/runtime_initialization_test.go +++ b/services/agents-api/internal/store/runtime_initialization_test.go @@ -187,7 +187,7 @@ func TestManagedRuntimePreparationAllOperationsUsePeer(t *testing.T) { fileBody := bytes.Repeat([]byte("bounded bytes"), 12000) session, environment := hostedFailureSession(t, s, tenant, store.CreateSessionInput{ InitialFiles: []store.InitialFile{{Type: "inline", Path: "/workspace/first", Data: fileBody}}, - Initialization: store.EnvironmentSetup{Skills: []store.EnvironmentSkill{hostedFailureSkill(t)}, Plugins: []store.EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin", Description: "A plugin."}, Archive: archive.Bytes()}}, Packages: v1.EnvironmentPackages{System: []string{"jq"}, NPM: []string{"is-number@7.0.0"}, Python: []string{"packaging==24.2"}}, Commands: []store.SetupCommand{{Command: "read installed bundles and create directory"}}, CapabilityDirectories: []string{"/workspace/generated"}}, + Initialization: store.EnvironmentSetup{Skills: []store.EnvironmentSkill{hostedFailureSkill(t)}, Plugins: []store.EnvironmentPlugin{{Metadata: agentplugin.Metadata{Type: "inline", Name: "plugin", Description: "A plugin."}, Archive: archive.Bytes()}}, Packages: v1.EnvironmentPackages{NPM: []string{"is-number@7.0.0"}, Python: []string{"packaging==24.2"}}, Commands: []store.SetupCommand{{Command: "read installed bundles and create directory"}}, CapabilityDirectories: []string{"/workspace/generated"}}, }) provider := &initializingProvider{lifecycleProvider: lifecycleProvider{resources: map[string]sandbox.Info{}}} var actions []string @@ -222,7 +222,7 @@ func TestManagedRuntimePreparationAllOperationsUsePeer(t *testing.T) { break } } - expected := []string{"file", "configure", "skill", "plugin", "system", "npm", "python", "setup", "finalize"} + expected := []string{"file", "configure", "skill", "plugin", "npm", "python", "setup", "finalize"} if !reflect.DeepEqual(actions, expected) || provider.commandCalls.Load() != 0 { t.Fatal("typed ordering or provider isolation", actions, provider.commandCalls.Load()) } diff --git a/services/agents-api/internal/store/template_composition_public_test.go b/services/agents-api/internal/store/template_composition_public_test.go index 920a9e95b..380b18578 100644 --- a/services/agents-api/internal/store/template_composition_public_test.go +++ b/services/agents-api/internal/store/template_composition_public_test.go @@ -86,7 +86,7 @@ func TestTemplateCompositionOfficialClientPostgres(t *testing.T) { for label, id := range receipt.Sessions { env := map[string]string{"TEMPLATE_ONLY": marker + "-template-env", "SHARED": marker + "-template-shared"} commands := []store.SetupCommand{{Command: "printf " + marker + "-template-command", CWD: "/workspace"}} - packages := v1.EnvironmentPackages{Python: []string{"packaging==25.0"}, NPM: []string{"semver@7.7.2"}, System: []string{"jq"}} + packages := v1.EnvironmentPackages{Python: []string{"packaging==25.0"}, NPM: []string{"semver@7.7.2"}} paths := []string{"/workspace/template-only.txt", "/workspace/overlap.txt"} contents := []string{marker + "-template-file", marker + "-source"} switch label { From 214068c1130d3a784f79c6a31508f726ca77bdf4 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:23:39 +0000 Subject: [PATCH 39/78] Fail reset reconciliation reads only after mutation acceptance --- apps/web/e2e/sandbox-reset.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/sandbox-reset.spec.ts b/apps/web/e2e/sandbox-reset.spec.ts index c314cd666..168072bfb 100644 --- a/apps/web/e2e/sandbox-reset.spec.ts +++ b/apps/web/e2e/sandbox-reset.spec.ts @@ -138,9 +138,13 @@ test("an applied reset with a lost response stays blocked through failed reads a await setDeployment(request, { resources: { allocations: 1, pending: 0 } }); await page.getByRole("button", { name: "Refresh sandbox state", exact: true }).click(); await expect(page.getByRole("button", { name: "Reset deployment", exact: true })).toBeEnabled(); - let failReads = true; + let failReads = false; await page.route(resetURL, async (route) => { - await route.fetch(); // Core accepted the mutation, but its response never reached this browser. + if (route.request().method() !== "POST") return route.continue(); + const accepted = await route.fetch(); // Core accepted the mutation, but its response never reached this browser. + expect(accepted.ok()).toBe(true); + // Only fail reconciliation reads after acceptance; earlier reads must allow the explicit submission. + failReads = true; await route.abort("failed"); }); await page.route(deploymentURL, (route) => failReads ? route.fulfill(unavailable) : route.continue()); From 0e84edaa2cfff7b09988f465805eaa61e6b31e08 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:27:05 +0000 Subject: [PATCH 40/78] docs(runtime): align native support and dependency preparation --- CONTRIBUTING.md | 176 +++++++------ apps/docs/content/docs/admin-api.mdx | 24 +- apps/docs/content/docs/agents-and-tools.mdx | 40 +-- .../api-reference/environment-templates.mdx | 10 +- .../content/docs/api-reference/sessions.mdx | 32 +-- .../content/docs/bootstrap-projects-keys.mdx | 4 +- apps/docs/content/docs/concepts.mdx | 22 +- apps/docs/content/docs/configure.mdx | 16 +- apps/docs/content/docs/console.mdx | 18 +- .../content/docs/environments-and-files.mdx | 68 ++--- apps/docs/content/docs/execution-model.mdx | 6 +- apps/docs/content/docs/hosted-providers.mdx | 8 +- apps/docs/content/docs/index.mdx | 9 +- apps/docs/content/docs/install.mdx | 6 +- apps/docs/content/docs/meta.json | 1 + apps/docs/content/docs/observability.mdx | 10 +- apps/docs/content/docs/public-api.mdx | 64 ++--- apps/docs/content/docs/quickstart.mdx | 8 +- .../content/docs/self-hosted-execution.mdx | 213 ++++++---------- .../content/docs/self-hosted-execution.zh.mdx | 2 +- apps/docs/content/docs/self-hosted-native.mdx | 159 ++++++++++++ .../content/docs/self-hosted-native.zh.mdx | 10 + apps/docs/content/docs/sessions.mdx | 31 +-- apps/docs/content/docs/troubleshooting.mdx | 19 +- apps/docs/content/guide-sources.json | 55 +++-- apps/docs/openapi/core-api.yaml | 5 - apps/docs/openapi/public-api.yaml | 15 +- apps/docs/openapi/sources.json | 12 +- apps/docs/scripts/generate-guides.mjs | 2 +- apps/docs/scripts/guides.json | 9 +- apps/docs/scripts/verify-docs-facts.mjs | 2 +- contracts/agents-api/README.md | 10 +- .../environment-executor-credentials.md | 6 +- contracts/agents-api/environment-templates.md | 232 ++++++++---------- contracts/agents-api/environments.md | 34 +-- contracts/agents-api/harnesses.md | 6 +- contracts/agents-api/mcode-workspace-v1.md | 6 +- contracts/agents-api/model-execution.md | 7 +- .../official-semantics-alignment.md | 8 +- .../agents-api/user-managed-runtime-v1.md | 5 +- contracts/agents-api/workspace-placement.md | 13 +- docs/getting-started/self-hosted.md | 2 +- docs/self-hosted-native.md | 33 ++- 43 files changed, 780 insertions(+), 638 deletions(-) create mode 100644 apps/docs/content/docs/self-hosted-native.mdx create mode 100644 apps/docs/content/docs/self-hosted-native.zh.mdx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 832a419e6..57272fdcf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -649,37 +649,39 @@ transaction stores a safe reason with the failed Environment and records `environment.failed`, `error` and one `agent.session.failed`; Session reads derive `failed`, that reason and the failure time from the same record, and live streams end after the failed event. The initializer reports only the integer exit status of a -failed sandboxed step. Core composes the reason from a fixed step label and that status, +failed initialization command. Core composes the reason from a fixed step label and that status, never from command, package-manager or file output; unknown effects, timeouts and receipts without a status keep a generic reason. New input then gets the observed 409 `conflict_error`; expiry and pending-input settlement keep their behavior. Completed environments never reinstall initial files on reconnect or native recovery. The typed Runtime preparation protocol carries bounded confidential input. The -daemon selects fixed trusted initializers; Core supplies no executable or host -platform field. User setup and package install hooks -run in the common packaged sandbox, without daemon credentials or native history. -Files, resolved Skills and inline Plugins precede system, npm/Python packages and ordered setup commands. Initialization has -provisioning network access; requested network restrictions apply to native tools -after setup. Confidential env and setup snapshots are encrypted independently of -ordinary metadata. Adapters apply tool env only after isolation, never to the -credential-bearing daemon/native harness launcher. -Reuse the packaged atomic file writer and anchored parent creation across all profiles. - -System packages use one Runtime-owned tool root, separate from trusted daemon and -harness executables. Build its immutable seed from the base image before adding -Runtime/harness code or secrets; include the matching package database and base -tool symlink targets. The shared installer extracts independent inodes and runs -apt/dpkg inside an unprivileged namespace. Package scripts cannot access Runtime -credentials, native history or outer processes. Later setup and native tools enter -the installed root read-only, retaining the authorized workspace and adapter-owned -scratch. `/workspace` and `/environment/workspace` refer to the same authorized -workspace inside that root, preserving native working directories. Native adapters -own entry and existing process cancellation; Core never -selects an engine or Provider for package initialization. No live filesystem -snapshot, second lifecycle owner or package-manager framework is introduced. -Core preserves the system-package requirement in the common execution binding; -a missing installation receipt fails preparation instead of falling back to base -tools. This requirement does not add execution prerequisites to Files reads. +daemon owns the common Go initialization implementation; Core supplies no +executable or host-platform field. Initial files and tool configuration precede +Skill/Plugin bundles, npm/Python packages and ordered setup, followed by capability +directory capture. Commands run directly with the starting account's permissions +and host network. Outer Environments own managed isolation; unsupported network +restrictions must reject rather than silently run unrestricted. Confidential env +and setup snapshots are encrypted independently of ordinary metadata. Adapters +apply explicit tool variables without automatically inheriting daemon credentials; +this does not prevent same-user tools from reading local Runtime state. +Reuse runtimefs atomic replacement and anchored logical-workspace paths for initial +files on every platform. Public Files creation keeps its own non-replacement rule. + +System dependencies must be preinstalled in the managed image/template or by the +self-hosted user. `packages.system` rejects explicitly, including a supplied null +or empty list. Runtime does not run apt, sudo, an unprivileged system-root installer +or any other automatic system-package operation. No daemon privilege increase or +managed-only exception is permitted. Missing dependencies fail the consuming +operation. npm and Python use local prefix/target directories; setup requires Bash +and Windows requires Git Bash, without a substitute shell. + +Initialization and package directories default under `OAC_RUNTIME_HOME` and may be +selected with `OAC_RUNTIME_INITIALIZATION_DIRECTORY` and +`OAC_RUNTIME_PACKAGE_DIRECTORY`. Packaged Linux images select their existing +`/environment` layout through those settings. `OAC_RUNTIME_TOOL_ENV_FILE` selects +explicit configuration. These are resource paths, never Environment-source or OS +switches in Core. Runtime process ownership waits for exit and I/O settlement; +confirmed failures retain only a bounded exit status, never command output. Skills and their immutable versions are Core-owned tenant resources, independent of Sessions and native Skill installations. Serialize version allocation and pointer @@ -711,7 +713,8 @@ installation directories are not portable public paths. Inline and referenced Skill ZIPs use the same confidential initialization snapshot and installer. Core validates portable manifests and bounded regular-file archives, returns only safe Skill metadata, and freezes content before native preparation. The Runtime -owns `/environment/initialization/capabilities/skills/`; the manifest validates the installed snapshot before reuse. This is state +owns `skills/` below its configured capability root; the manifest validates +the installed snapshot before reuse. This is state consistency, not protection from the launching user. Public Plugin ZIPs preserve their complete package layout and reuse the shared archive and portable Skill parsers. Core keeps safe Plugin metadata separate from encrypted archives. Templates inherit or replace @@ -1476,17 +1479,19 @@ failure when the authorized workspace cannot be reached; it never grants public admission merely because an adapter advertises a capability. Use distinct authorization for callers, devices and environment connections. A -co-located harness must not expose broader application credentials or other tenants' -secrets to generated code. Directory bindings and process identities do not provide -filesystem isolation. Preserve or demonstrably restore native history across +managed outer Environment must exclude broader application credentials and other +tenants' secrets. The daemon does not hide its own state from same-user tools. +Directory bindings and process identities do not provide filesystem isolation. +Preserve or demonstrably restore native history across compute replacement; never silently move a bound Session or replay unknown work. Self-hosted compute/files remain caller-owned, with explicit cleanup separate from Session deletion. The full Environment implementation remains pending; follow the [pinned contract and acceptance sequence](contracts/agents-api/environments.md) and the [two-engine placement prerequisites](contracts/agents-api/workspace-placement.md). -For co-location, qualify both deployment isolation and native tool restrictions. -Bash sandbox settings alone do not establish file-tool or whole-harness isolation. -Never enable an environment profile before those boundaries are verified. +For co-location, qualify the outer deployment boundary and the shared Runtime +lifecycle. Native tools use the starting account's permissions; do not claim a +daemon or harness sandbox. Host operators choose their own outer isolation. +Never enable an execution combination whose required outer behavior is unverified. The internal Store creates one Environment with an environment-bearing Session in its creation transaction. The Session upsert selects the retry winner; retries @@ -2128,17 +2133,17 @@ Environment of that Project whose Session exists; anything else is 404. The Project's principal is the credential's execution principal, its scope stays daemon enrollment and connection for that one Environment, and issue, rotate and revoke each record an administrator audit entry in the write's transaction without -the secret. Project API keys cannot issue them. 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. -Web's install command carries no secret: the installer reads the credential at a -hidden terminal prompt and verifies against the console's -`self_hosted_installer_sha256`. On a permanent rejection the self-hosted daemon -parks (one message, no requests, exit 0 on SIGTERM) rather than exiting into -Docker's `unless-stopped` restart loop. Rerunning the installer with the same key -rotated replaces it in the same stopped container through the launcher's -`replace-credential`, which checks the container's labels, name, volumes and -private directories; never relaunch or adopt history to recover a credential. +the secret. Project API keys cannot issue them. Native self-hosted installation runs the daemon on Linux, macOS or Windows with +its starting account's permissions. It owns no sandbox node or Core allocation, +adds no isolation and retains user-owned native history after uncertain launches. +Report started, connected and real execution success separately. +Native installation uses `oac-daemon install` and `start` with an explicit +credential-file path and the same `OAC_RUNTIME_HOME` for lifecycle commands. It is +current-version only and does not adopt an older container installation. Rotation +replaces the configured credential file for the same key and restarts the daemon; +never create a replacement Session history to recover a credential. The console's +older container-installer command remains a distinct packaged workflow, not the +native installation interface. Report connection and actual execution separately. Core-key executor credential lists expose a required connection observation with never_enrolled, connected or disconnected status, immutable bound key identity, enrollment time and last authenticated heartbeat time. Read credential metadata @@ -2155,8 +2160,8 @@ using only its restricted executor credential. The read checks the exact live Environment/key binding and current authenticated connection; it never enrolls, allocates, wakes a sandbox or grants project resource access. It is an `/api/v1` machine route that reaches Core directly, never through the console. Bounded -polling and reruns retain the original container and history; timeout is a -diagnostic failure, not permission to relaunch. The installation public URL +polling retains the original Runtime identity and history; timeout is a +diagnostic failure, not permission to replay initialization or replace history. The installation public URL (`public_url` in the installation's `config.json`, seeded by `--public-url`, and `OAC_PUBLIC_URL` for Core) is the one origin for applications, nodes, sandbox guests and self-hosted executors, and also the console @@ -3296,7 +3301,7 @@ remain separate. Runtime advertisements alone never enable public operations. Shared dispatch checks capability combinations, not a whitelist of engine names. Harness onboarding does not require feature equality. Verify common lifecycle obligations and use the same public assertions for each declared operation, -retaining native isolation tests where appropriate. Optional native differences +qualifying outer isolation where the deployment requires it. Optional native differences remain independently prioritized capability/protocol work, not onboarding blockers. Keep the complete pinned public protocol target and accepted functionality intact. Never equate accepted parameters with applied native behavior. @@ -3334,32 +3339,34 @@ Runtime recovery then loads the exact owned native history in a new Executor. Do profile. See [deployment and acceptance](services/agents-api/deploy/mcode/README.md). The MiniMax workspace profile builds one CLI from the fixed upstream source and -lockfile through the existing companion packaging path, and isolates native -workspace tools behind its standard MCP client. The process and native Session +lockfile through the existing companion packaging path, and connects native +workspace tools through its standard MCP client. The process and native Session share one private control directory; public workspace files cannot configure that process or become privileged project instructions. A trusted adapter-owned bridge -runs the original six tool implementations in the upstream Linux sandbox, with no -unsandboxed fallback. Keep native history bound to the control directory and Files/ -Artifacts bound to the public workspace. Core and shared file helpers remain engine +runs the original six tool implementations with the launching user's ordinary +permissions. It adds no inner sandbox on any platform. Keep native history bound +to the control directory and Files/Artifacts bound to the public workspace. Core and shared file helpers remain engine neutral. This internal MCP transport does not admit public MCP configuration. Record the upstream revision, native admission patch hashes and worker-source provenance. The bounded patch checks the shared descendant-task limit inside the existing native SQLite admission transaction before start, without another scheduler. ACP initialization must acknowledge the applied limit before input. -The native tool catalog applies the protected workspace policy to every child, -not only the root's configured profile. Only the Session's authorized internal +The native tool catalog selects the same admitted workspace tools for every child, +not only the root's configured profile; this is tool selection, not filesystem +isolation. Only the Session's authorized internal workspace MCP entry crosses the native child selector; this does not grant -external MCP access or bypass the native profile's read/write restrictions. -Initialization must acknowledge that protected tool policy before input as well. -Subagent reads use the Session-private protected native database. Multi-agent +external MCP access. Initialization must acknowledge the admitted tool inventory +before input as well. Subagent reads use the Session's native database; the daemon +does not protect it from other tools running as the same user. Multi-agent workspace execution installs only the existing authorized workspace MCP entry in that private native configuration so children inherit the same tools; public MCP and Environment-origin MCP combinations remain separately qualified. Complete [workspace acceptance](contracts/agents-api/mcode-workspace-v1.md) before enabling hosted execution. The standalone companion uses its own npm lock; `make check` runs its lifecycle tests and script checks, while its exact-source Linux build and -Docker qualification (including `native.test.mjs` under both network policies) -are required when the companion changes. +native qualification for the actual supported platform and outer deployment +are required when the companion changes. Historical tests of both inner network +policies do not qualify the current bypass implementation. Claude hosted functions compose the existing SDK function bridge with the common workspace execution profile. Only declared function tools and the verified native tool @@ -3487,38 +3494,25 @@ Confirmed native cancellation may settle unanswered function calls after result admission closes and callbacks drain. A submitted function result still requires its native application receipt, including when the MCP request aborts. -`claudesdk.Config.Workspace` is a private, trusted operator binding for one -qualified placement. It enables native Bash/Read/Edit and declared host functions -in the existing SDK loop. The entire factory must already run inside an outer mount/process boundary -that excludes application, daemon and other-tenant credentials and host policy. -The factory does not create that boundary. The dedicated Docker profile below -selects this binding at startup; cwd and request options cannot select its policy. -The workspace, managed history, runtime home, scratch and protected secret roots -must be pre-existing canonical, separate directories. Runtime code and dependency -search paths must remain outside those roots and be read-only in the placement. -The complete packaged runtime directory, including `node_modules`, must not -overlap any bound root. Its bridge uses the packaged `dist/main.js` layout. -Node, the bridge entrypoint and its readiness companion must use canonical file -paths. Dependency aliases outside mutable roots are resolved before use in PATH; -aliases within mutable roots are rejected even if their current target is safe. -The operator owns allocation, exclusive use and retention; a binding is not -per-Session authorization, a tenant boundary or an idle Files owner. - -For this profile, `Config.Env` replaces inheritance for both readiness and -execution, selecting only supported provider/proxy variables. The adapter fixes -HOME/history/scratch, native enforcement flags and tool inventory; the bridge -request contains variable names, never credential values. Native Bash uses the -strict sandbox with no fallback or weaker isolation. Separate native file-tool -permissions and a session tool hook restrict Read/Edit to the bound workspace -and deny protected roots; background/unsandboxed Bash requests are rejected. -The deployment must retain these controls, including the SDK-owned hook. -External MCP and remote-environment combinations remain rejected in this private -profile until separately qualified. The existing `none` profile retains its -behavior. Packaged `workspace_tools` establishes bridge support only, not host -isolation or a public capability. The dedicated Runtime integration composes -public preparation, shared placement quotas, command Items and Files ownership. -`TestLiveClaudeWorkspaceFactory` is explicit real-provider acceptance inside a -qualified placement, including effects, cancellation and same-history continuation. +`claudesdk.Config.Workspace` is an operator binding for the selected workspace and +native state. It enables native Bash/Read/Edit and admitted host functions in the +existing SDK loop. Native tools run with the launching user's permissions on all +platforms; there is no inner sandbox, protected-root deny policy or managed shell +wrapper. Managed isolation belongs to the outer Environment, which must exclude +other tenants' and broader application credentials. An ordinary native install +provides no such boundary. Directory selection is not tenant authorization. + +`Config.Env` selects readiness and native process variables. Explicit tool env is +applied to tool execution, but this is not a guarantee that same-user tools cannot +read credentials or history from local files. Workspace hooks retain their event, +identity and lifecycle responsibilities, not security enforcement. Process groups +and Windows Jobs provide cancellation and descendant cleanup, not isolation. +Supported MCP and subagent combinations require their own qualification. The +existing `none` profile keeps its tool inventory. Packaged `workspace_tools` +establishes bridge support, not outer host isolation or public API admission. +The dedicated Runtime composes public preparation, placement quotas, command Items +and Files ownership. Real-provider acceptance verifies effects, cancellation and +same-history continuation for the actual platform and outer deployment. The private bridge accepts `executor_prepare` without model input. It freezes validated configuration and resume identity, checks required history, and retains diff --git a/apps/docs/content/docs/admin-api.mdx b/apps/docs/content/docs/admin-api.mdx index 1e4fe2786..aa70cec65 100644 --- a/apps/docs/content/docs/admin-api.mdx +++ b/apps/docs/content/docs/admin-api.mdx @@ -8,7 +8,7 @@ resource inspection, Project and key management, credential issuance, audit, usa and sandbox operations. It never calls `/v1` or `/api/v1`, and has no Agent execution, copy or arbitrary asset editing operation. -Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core-errors.md), +Core request failures use the [Core administration error envelope](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/core-errors.md), including optional typed safe details. Console sign-in endpoints retain their separate error shape described below. @@ -49,9 +49,9 @@ label, but could set any value. Never use it for authorization or as proof of origin. Web calls Core only through the typed Core clients in `packages/agents-client`: -[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/packages/agents-client/src/admin-client.ts) (`/core/v1`), +[AdminClient](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/packages/agents-client/src/admin-client.ts) (`/core/v1`), `SandboxAdminClient` (`/core/v1/sandbox`) and `CoreMetricsClient` -(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) +(`/core/v1/metrics`). See [the complete administrator reference](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md) for methods, fields, filters, pagination and response shapes. Routes below are relative to `/core/v1`: @@ -62,8 +62,8 @@ Routes below are relative to `/core/v1`: | Resource lists/details/deletion | `/projects/{id}/agents`, `/sessions`, `/environment-templates`, `/skills`, `/files`, `/vaults`, including the documented nested reads | | Asset ownership | `GET /projects/{id}/resource-owners` with batched resource IDs | | Key operation history | `GET /projects/{id}/write-operations` with key/resource/time filters | -| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md)) | -| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md#deployment-defaults)) | +| Executor credentials | `GET/POST /projects/{id}/environments/{environment_id}/executor-credentials`, `DELETE …/executor-credentials/{key_id}` ([contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-executor-credentials.md)) | +| Deployment model providers | `GET /harnesses`, `GET/PUT/DELETE /harnesses/{harness}/model-provider`; the key is write-only ([contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/model-execution.md#deployment-defaults)) | | Usage and health | `GET /summary`, `/sandbox/runtime-observations`, `/metrics` | | Administrator audit | `GET /audit-log`; deployment-wide entries have `project_id: null` | @@ -76,9 +76,9 @@ it is operational attribution, not per-key billing. ## Sandbox administration -The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/sandbox-deployment.md), -[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md) -and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core.openapi.yaml) +The [deployment configuration contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/sandbox-deployment.md), +[nodes and sandbox backends reference](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +and [generated OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/core.openapi.yaml) define deployment and node operations: - `GET/POST/PUT /core/v1/sandbox/deployment` and @@ -115,7 +115,7 @@ is a durable serving pin, not proof of current connectivity. Target unknown, fai or update-required state does not by itself invalidate confirmed old-generation service; consume Core's connection/provider facts separately. -Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md#administrative-session-archive) +Administrators may [archive an individual hosted Session](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md#administrative-session-archive) at the current generation without reset. History and persisted Files/Artifacts survive; unpersisted workspace is lost and the original Session cannot resume. Cancel reset stops further archives, not cleanup already requested. After zero @@ -156,7 +156,7 @@ generated schema; do not interpret every empty or failed read as an absent resou `GET /core/v1/metrics?range=1h|6h|24h|7d` returns Core process, execution queue/slots, PostgreSQL and background-job measurements. It requires the Core key, rejects arbitrary query filters and never grants -Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core-metrics.md) +Agent execution access. See the [exact measurement contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/core-metrics.md) for complete buckets, null values, units and process-local retention. Frontend implementation is maintained separately; this backend change does not modify Agent metrics or the public Agent API. @@ -165,7 +165,7 @@ Agent metrics or the public Agent API. The Core key reads a node and its host history through `GET /core/v1/sandbox/nodes/{node_id}?range=1h|6h|24h`. See the -[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/node-host-history.md) +[node host history contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/node-host-history.md) for nullable observations, freshness and aggregation. The node list is unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/api/web-management.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/api/web-management.md) diff --git a/apps/docs/content/docs/agents-and-tools.mdx b/apps/docs/content/docs/agents-and-tools.mdx index 888915ce6..7b652ad86 100644 --- a/apps/docs/content/docs/agents-and-tools.mdx +++ b/apps/docs/content/docs/agents-and-tools.mdx @@ -4,7 +4,7 @@ description: "Native harness tools, shared declarations and the limits of execut --- Assessed against Core `206474a4c10901442b1faa094281bfb5559082b5` on 2026-09-22. -The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream.json), Beta +The contract remains [OpenAI Python 3.13.0 at `d7c41ef`](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/upstream.json), Beta `agents=v1`. This matrix records the bounded execution/tools milestone. It does not establish complete protocol compatibility; the remaining limits below stay open. @@ -22,20 +22,20 @@ and Environment Plugin MCP have separate inventories and qualification. | Operation | Implemented behavior and real qualification | Unsupported or unverified boundary | | --- | --- | --- | -| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | -| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | -| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | -| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | -| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | -| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | +| Initial message input, `sessions.create` | String input and ordered user-message arrays share atomic admission. Codex/Claude text and inline image execution: M1/M2; ordinary MiniMax text: M1/P1. [Input contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/message-input.md), [initial parser](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/api/session_initial_input.go). | Whitespace-only text is admitted verbatim for Codex and rejected at admission for Claude SDK and MiniMax Code ([text content](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/message-input.md#text-content)). Empty parts beside text (SES-08) and local size-limit parity need upstream evidence. Images are only qualified for inline PNG/JPEG on `none` and Core-managed Docker `openai_hosted`; MiniMax, self-hosted images and remote URLs remain gaps. | +| Prepared and active messages, `sessions.events.create` | Ordered `input_text`/`input_image` arrays retain original content and distinct public user Items. HTTP 202 confirms persistence; native receipts establish application. Initial/prepared/active Docker PNG/JPEG: M2; active PNG on `none`: M1. [Shared admission](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/api/inputs.go). | Codex flattens native messages with blank-line separators. Claude can fold or queue native turns; it does not promise Codex's same-native-turn behavior. Public durability does not prove native consumption. Claude SDK and MiniMax Code reject messages without an image or non-whitespace text at admission (`unsupported_or_invalid_configuration`); Codex delivers them unchanged. | +| Structured output, `agent.text.format` | Save/inherit/freeze `{type:json_schema,schema:...}`. Claude SDK object-root, single Agent, medium verbosity, ordinary functions returning text: S1 (`none`) and S2 (Docker, including prepared/active input and Files/Artifacts). Native final text is retained unchanged; its stream follows the official message sequence with the whole text in one `output_text.delta` ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). [Contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/structured-output.md), [profile](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/engine/claude.go). | An explicit non-object root type is a protocol error for every harness ([validation](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Codex/MiniMax, schemas without an object root, schema numbers changed by binary64, self-hosted, Skills/Plugins, MCP, Subagent and discovery combinations reject execution. No output repair, coercion or extra model loop. Arbitrary schema dialects are unverified. | +| Function configuration, saved/inline Agents | Required name/description/schema; `defer_loading` defaults false. Protocol errors, repeated names and explicit non-object root types reject with the official fields ([validation](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/official-semantics-alignment.md#agent-configuration-validation--september-23)). Saved references resolve into an immutable Session snapshot. Codex/Claude real calls: F1/F2/M2/S1/S2. [Parser](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/api/function_configuration.go), [saved tools](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/api/saved_tools.go). | MiniMax public functions reject. Claude requires an explicit object root. Local nonblank/512-byte name and 64-definition Session bounds are compatibility gaps. Saving configuration alone does not qualify execution. | +| Function-result admission, `events.create` | Required `turn_id`, `call_id`, `success`; optional nullable `error` and `output`. Output is string or ordered text/image content. Scoped atomic batches retain field presence, original content and retry identity in storage; public result Items and events always carry `output` and `error`, null when not submitted ([item serialization](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/history-events-usage.md#item-serialization-2026-09-23)). Same result retries are accepted; changed results and results after cancellation return 409 `conflict_error`, including after terminal state. In the caller's Session, an unknown call or a call of another Turn returns 400 `invalid_request_error` without changing the pending action; missing and foreign Sessions stay 404 ([error rows](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/official-semantics-alignment.md#session-input-conflicts-and-result-targets--september-23)). F1/F2/M2 plus [controlled SDK/raw checks](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/tests/official_function_inputs.py). [Parser](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/api/function_inputs.go), [Store](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/store/function_inputs.go). | Admission is separate from application and public Item publication. Invalid or unqualified content cannot consume a pending call. Core messages omit the call and executor IDs that official messages name; hosted defaults and publication timing remain unverified. | +| Function text results and native application | Codex waits for a matching live root dynamic-tool completion; Claude waits for a matching live root native tool result. Text/error results, retry/conflict, cancellation and cold continuation: F1/F2. [Receipt contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/function-result-images.md). | Transport writes alone do not confirm application. Confirmation is not provider consumption, crash recovery or exactly-once external effects. No automatic result replay. | | Function image results | Successful ordered inline PNG/JPEG with text, large PNG and image-only JPEG: Codex/Claude `none` F1/F2; Docker M2. Public Items retain submitted bytes. Codex receipt regression: F2. | Claude rejects failed images and remote references before persistence; native resizing may change its image bytes. MiniMax functions and self-hosted image workflows are unqualified. Other Codex image/error/reference combinations cannot be inferred from successful-inline evidence. | -| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | -| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | -| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | -| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | -| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | -| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | -| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | +| Deferred function discovery | Type-only `tool_search` plus mixed eager/deferred functions: Claude SDK 0.3.269/native 2.1.269, Kimi K3, single Agent, medium, `none`, text results; text and PNG input D1. Native provider observations establish lazy schema loading. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/tool-search.md). | A repeated `tool_search` is a protocol error. Codex/MiniMax discovery, search-only/missing-search, workspace, MCP, structured-output and Subagent combinations remain gaps. Opaque native policy changes lack a reliable pre-input deferral signal. Saved tools include `tool_search`; the pinned Session response union excludes it. Exact hosted projection is unverified. | +| Explicit disabled search/PTC | Saved and inline `web_search.mode=disabled` and `programmatic_tool_calling.enabled=false`; shared native controls on initial and cold execution. All three harnesses on `none`: P1, including native inventory/control evidence. [Contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/tool-policy.md), [parser](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/api/disabled_tools.go). | Unsupported explicit enablement rejects at Session admission; saved Agents keep every pinned search mode as resource data (TV-05), and Sessions from such Agents reject unless they replace the tools. A repeated `web_search` is a protocol error. Omission retains approved native behavior, which does not establish official default-on PTC parity. Enabled search and default/error parity remain gaps; unrelated native utilities are not implicitly removed. | +| Agent service-origin MCP | Implemented Codex/Claude HTTP `none` profiles, anonymous/static bearer, scoped Vault selection and native `mcp_call` Items. [Configuration and qualification limits](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/README.md#http-mcp-execution), [profiles](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/engine/profile.go). | This closure batch does not requalify MCP/provider combinations. MiniMax, self-hosted/hosted service-origin MCP, OAuth, nonempty inline headers/metadata and other transports/origins reject. Claude requires a static connected inventory; original MCP-envelope fidelity and continuing server health are unverified. | +| Environment Plugin MCP/native tools | Separate [Docker Plugin transport matrix](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-templates.md#environment-origin-mcp-plugins) and [V1 deployment evidence](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/user-managed-runtime-v1.md). Native tools stay within the existing colocated Runtime. | Plugin MCP is not Agent service-origin MCP or a public function action. No new optional cross-product is qualified here. Native utility inventories need not be identical. | +| Required action: `function_call` | Session GET/list and Session SSE expose persisted `{arguments,call_id,name,turn_id,type}`. Actions remain pending until native application or cancellation/terminal settlement. [Projection](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/store/function_state.go), [confirmation](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/store/function_results.go); real handling F1/F2/M2/S1/S2/D1; explicit client disconnect/query/reconnect: F3. | Function-call history is not pending-state authority. Client reconnect does not replay events or redo external application effects. | +| Required action: `environment_connection` | Offline waiting input exposes `{environment_id,type}` before Turn creation. Connect the exact Session Environment through our scoped daemon enrollment. Three-harness Docker/E2B execution: E1; explicit initial pending-input/query/connection/native completion: E2; expiry: [controlled initial-input test](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/tests/official_self_hosted_initial.py). | Idle offline Sessions without pending input request nothing. Registration alone is not connectivity or native readiness. Stock `exec-server`/Noise transport is outside the approved V1 route. Exact upstream registration/action-removal timing remains unverified. | +| Stream disconnect and execution loss | SSE is live-only. Reconnect, buffer events, retrieve Session/Turn/Items and deduplicate Item IDs. Worker recovery fails previously claimed work without replay; queued work can remain. [Recovery contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md#public-execution-admission), [connection reconciliation](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/store/environment_connection_recovery.go). | Client disconnect and daemon/Core loss are different cases. F3 qualifies client-stream loss and continued pending function handling; F2 qualifies native process loss with an unapplied saved result. Neither promises replay or recovery of unknown external effects. | ## Required-action recovery contract @@ -52,7 +52,7 @@ application already performed the function, keep and submit that saved result; do not run an external effect again merely because an action remains visible. Core's acknowledgement boundary is native application. For an environment action, connect its exact Environment using the existing enrollment authorization. -[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/environment_connections.go) +[Connection observations](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/store/environment_connections.go) fence stale generations; transport connection can clear pre-Turn activity before native preparation. Neither registration nor an action disappearing proves that a model ran successfully. Query the matching Turn and Items for its outcome. @@ -61,8 +61,8 @@ Two timing questions remain open. Repeated `requires_action` notification order and acknowledgement timing are local implementation choices, not proven upstream semantics. Also, an admitted result that is cancelled before native observation can remain internally saved without a public output Item or `item.added`. -[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/internal/store/item_projection.go) and -[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#public-function-configuration) preserve +[Item publication](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/internal/store/item_projection.go) and +[the existing coverage record](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md#public-function-configuration) preserve this gap. Successful retry cannot manufacture the missing observation or establish that the model consumed the result. @@ -82,12 +82,12 @@ not a claim that every historical workflow was rerun at the current baseline. | M2 | `20260922/workspace-images/validation-summary.json`; Codex `public-run-_lr5uiy_`, Claude `public-run-sb65p9e9`; candidate `2c295116d66ed9aafc808ec49a8cb6dcb5c674c2`, merge `1adb2489bc3415039440224e9a7905c53e68a557` ([#17](https://github.com/MiniMax-AI/parsar-core/pull/17)). Kimi K3, Codex 0.153.4, Claude SDK 0.3.269/native 2.1.269; Docker seven-Turn image/workspace workflow. Codex's receipt conclusion is superseded by F2. | | F1 | `20260922/function-result-images/validation-summary.json`, `validated/function-image-public-2567456666/public.json`; candidate `d3cdb225bc7628b968b76c7e1b400101894ce8cd`, merge `fd23f169e1ede1b2f1c39a1d9dcce71886e3c006` ([#16](https://github.com/MiniMax-AI/parsar-core/pull/16)). Claude SDK 0.3.269/native 2.1.269 with Kimi K3 on `none`; success PNG/JPEG, failed text, retries, cancellation, history continuation. Earlier Codex transport-only evidence does not qualify current receipts. | | F2 | `20260922/function-result-receipts/validation-summary.json`, `candidate-public-owner-sdk.log`; hosted `public-run-4fn70foy` (104.13s), `none` `function-image-public-945075091` (83.918s). Candidate `6dceb98e1c56469fcc70cd82025ce77fc870ed3d`, merge `206474a4c10901442b1faa094281bfb5559082b5` ([#20](https://github.com/MiniMax-AI/parsar-core/pull/20)); real Kimi, Codex 0.153.4. Qualifies native receipts, not completed provider consumption. | -| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | +| F3 | `20260922/execution-tools-closure/pending-actions/validation-summary.json`; Codex `codex/public-run-8gexf5a8` (65.58s), Claude `claude_sdk/public-run-5f2tr7e_` (74.33s), production source `206474a`. The same [public verifier](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/tests/official_pending_actions_native.py) checks disconnected-client pending queries, success/error/cancel, original results, target isolation, retry/conflict and no implicit call replay with real Kimi on Docker. No Core restart or native-loss injection is claimed by these runs. | | S1 | `20260922/structured-output/acceptance-summary.json`, `structured-public-1905281777/public.json`; real candidate `7f533d46c472f3cdf7c16ce9471c225a2ba7eded`, merge `8716e699dbc34904499c94b6b0b03857bbebfaad` ([#11](https://github.com/MiniMax-AI/parsar-core/pull/11)). Claude/Kimi `none` schema/function execution and cold continuation. Schema-specific active steering was not separately qualified here. | | S2 | `20260922/hosted-structured-output/validation-summary.json`, `validated/public-inline.log`, `public-run-qvq3csf1` (186.09s); candidate `f6d2c3f2a1dc2ff5f177213bd2b20d78496011d0`, merge `aa85d8ecc60e0a8b7f2494b73caa81216ee197e2` ([#18](https://github.com/MiniMax-AI/parsar-core/pull/18)). Claude SDK 0.3.269/native 2.1.269, real Kimi; Docker saved/inline schema, prepared/active input, native files and four completed structured answers. | | D1 | `20260922/deferred-tools/tool-search-public-2039155387/public.json`, `public-image-isolated-tests.log` (86.71s); merge `178507ae7a63d4068e1e82bef9cd256ba398ae00` ([#13](https://github.com/MiniMax-AI/parsar-core/pull/13)). Claude SDK 0.3.269/native 2.1.269/Kimi K3 `none`; text results with initial/active PNGs, cancellation and cold continuation. `claude-native-1790052750` separately records provider schema inventories and native feasibility. | | P1 | `20260922/tool-policy/acceptance.json`, `server-evidence/tool-policy-{codex-2495445746,claude_sdk-2539346823,mcode-3747575401}/public.json`; candidate `eeb432c7495265ae3a9309e32f5b4323611c3245`, merge `4b8754a6eda6696d9b18eb8d583953ac16c6800f` ([#14](https://github.com/MiniMax-AI/parsar-core/pull/14)). Codex/Claude Kimi K3, MiniMax M2.7; four `none` Sessions per harness, native disable controls and cold continuation. | -| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | +| E1 | `20260921/self-hosted-onboarding/{source-candidate.json,source-verified.json,live,live-e2b}`; candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. [Exact Docker/E2B run paths and limits](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/user-managed-runtime-v1.md#evidence-and-verification-boundaries): Codex Kimi K3 Responses, Claude Kimi K3 Anthropic-compatible API, MiniMax M2.7, immutable Linux amd64 Runtime builds. Native execution, restart/history, cancellation, Files/Artifacts and isolation; shared credential lifecycle evidence is not a separate live rotation run for every E2B profile. | | E2 | `20260922/execution-tools-closure/environment-actions/result.json` contains Codex/Claude Kimi passes (13 checks each) and the retained MiniMax direct-network failure; `mcode-relay-retry/result.json` separately passes MiniMax M2.7 (13 checks) after correcting provider routing. Source `206474a`; same shared SDK/raw workflow, isolated Core/PostgreSQL and user-managed Docker. Covers initial creation SSE, client disconnect, exact pending Environment/no Turn or Items, scoped enrollment, one actual completed model Turn and creation retries preserving history. This is connection-action qualification, not another full lifecycle or E2B regression. | Controlled tests establish parser, projection, atomicity and race behavior; native @@ -121,4 +121,4 @@ remain separate limitations. They do not authorize silently relabeling an unprov principal workflow as deferred. OAuth, full Usage, release publication, new tool kinds and new execution loops are outside this closure batch. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/execution-tools.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/execution-tools.md) diff --git a/apps/docs/content/docs/api-reference/environment-templates.mdx b/apps/docs/content/docs/api-reference/environment-templates.mdx index 5e68bd5c2..32e05af1a 100644 --- a/apps/docs/content/docs/api-reference/environment-templates.mdx +++ b/apps/docs/content/docs/api-reference/environment-templates.mdx @@ -20,13 +20,15 @@ _openapi: Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, - system/npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs - and workspace-contained capability directories. Omitted/null network + npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs and + workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. Network policy - rejections return invalid_request_error with a null param. No compute - is allocated. Exact hosted error/retry semantics remain unverified. + rejections return invalid_request_error with a null param. System + dependencies must be preinstalled in the sandbox image or template, or + on the host machine; packages.system is rejected. No compute is + allocated. Exact hosted error/retry semantics remain unverified. - content: >- Returns safe tenant-owned configuration metadata without allocating compute. Missing and foreign resources return the same not-found diff --git a/apps/docs/content/docs/api-reference/sessions.mdx b/apps/docs/content/docs/api-reference/sessions.mdx index 5de4d4f81..400565ff7 100644 --- a/apps/docs/content/docs/api-reference/sessions.mdx +++ b/apps/docs/content/docs/api-reference/sessions.mdx @@ -126,22 +126,24 @@ _openapi: enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, - system/npm/Python packages and ordered setup commands use the shared + npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. - Initial inline and tenant-owned file_id files freeze encrypted bytes - before provisioning, then install through the common Core lifecycle - before native execution or live Files access. With a template - reference, omitted/null files, env, packages and setup_commands - inherit. Non-null files and command lists replace; env overlays by - key; each package manager inherits on omission/null and otherwise - replaces its list. Empty lists clear their selected field. - Tenant-owned environment_template_id references inherit omitted/null - network and allow only narrowing overrides. Inline hosted network:null - retains the enabled default; updating a Template with network:null - resets its saved policy to enabled. Core freezes effective - configuration; template updates/deletion do not alter Session - snapshots or same-intent creation retries. Inline or tenant-owned - skill_reference Skills share initialization. Templates preserve + System dependencies must be preinstalled in the sandbox image or + template, or on the host machine; packages.system is rejected. Initial + inline and tenant-owned file_id files freeze encrypted bytes before + provisioning, then install through the common Core lifecycle before + native execution or live Files access. With a template reference, + omitted/null files, env, packages and setup_commands inherit. Non-null + files and command lists replace; env overlays by key; each package + manager inherits on omission/null and otherwise replaces its list. + Empty lists clear their selected field. Tenant-owned + environment_template_id references inherit omitted/null network and + allow only narrowing overrides. Inline hosted network:null retains the + enabled default; updating a Template with network:null resets its + saved policy to enabled. Core freezes effective configuration; + template updates/deletion do not alter Session snapshots or + same-intent creation retries. Inline or tenant-owned skill_reference + Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list diff --git a/apps/docs/content/docs/bootstrap-projects-keys.mdx b/apps/docs/content/docs/bootstrap-projects-keys.mdx index 2194e6472..93eadddbe 100644 --- a/apps/docs/content/docs/bootstrap-projects-keys.mdx +++ b/apps/docs/content/docs/bootstrap-projects-keys.mdx @@ -69,7 +69,7 @@ and display only. Keep deployment, application, node and provider credentials ou Installation creates no Project or application key. An administrator creates a Project and issues named keys on the console's **Projects and keys** page, which -uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md), or with that +uses the [administrator API](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md), or with that API directly. The Project owns one tenant and one principal; its keys share assets and permissions. Writes retain each key's provenance. All Projects and application keys live in @@ -98,4 +98,4 @@ management request points to the console's Core key or Core connection. A resour deletion conflict must remain visible; it does not authorize an execution call. Node and daemon `/api/v1` routes and native Runtime interfaces retain their own authentication. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/core-connection.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/web/core-connection.md) diff --git a/apps/docs/content/docs/concepts.mdx b/apps/docs/content/docs/concepts.mdx index 6ff1c2e41..357c62d9d 100644 --- a/apps/docs/content/docs/concepts.mdx +++ b/apps/docs/content/docs/concepts.mdx @@ -66,6 +66,22 @@ create, copy or edit arbitrary assets, start a Session, send an event, cancel work, or read stored credentials. Project keys share all assets within their Project; nothing is shared or copied across Projects. +## Runtime and outer isolation + +The same daemon and Core protocol serve self-hosted Linux, macOS and Windows. +OS differences belong to Runtime implementations; harness differences belong to +adapters. Managed Providers remain Linux-only. Runtime prepares capabilities and +executes work; Providers create, bootstrap and reclaim outer Environments. + +The daemon runs tools with its launching user's permissions. It does not add a +filesystem, permission or network sandbox, including on Linux. Outer Docker/E2B/ +microsandbox Environments provide managed isolation. Credential authentication, +ordinary private storage, atomic writes, locks and process cleanup remain required, +but do not protect Runtime data from tools running as the same user. Local +capability snapshots share the managed parser and do not create a second security +boundary. See the [native Runtime guide](/self-hosted-native) for current platform +validation limits. + ## Secrets and evidence Credential values, model credentials and confidential template initialization are @@ -83,12 +99,12 @@ ownership. No request bodies, secrets or file contents enter audit records. Do not add product users, RBAC, cross-Project shared assets, administrator execution, or old private-protocol compatibility to this management model. Existing public -execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md). +execution and Runtime ownership rules remain in [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/CONTRIBUTING.md). Native failure classification is adapter-owned and uses finite structured native values. Optional Runtime error metadata is normalized once and retained through normal delivery and journal draining; it never replaces Core terminal authority, cancellation receipts, Usage or native identity. See -[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/native-error-classification.md). +[native failure classification](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/native-error-classification.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/design-principles.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/design-principles.md) diff --git a/apps/docs/content/docs/configure.mdx b/apps/docs/content/docs/configure.mdx index 360b9717a..24c5a9cb6 100644 --- a/apps/docs/content/docs/configure.mdx +++ b/apps/docs/content/docs/configure.mdx @@ -116,7 +116,7 @@ output or in Core's settings snapshot. Model providers are not process settings; The schema is -[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/deploy/install/config.schema.json); each +[`deploy/install/config.schema.json`](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/deploy/install/config.schema.json); each installation keeps a copy in `generated/config.schema.json` for editors. Core serves the non-secret settings snapshot, with the path of `config.json` and the apply command, at `GET /core/v1/installation`. @@ -145,7 +145,7 @@ Which harnesses are enabled, and the default one, are process settings Exactly one provider serves the deployment: Docker, microsandbox or E2B. The deployment specification holds the per-sandbox resources and, for Docker and microsandbox, the Runtime release. Web's setup proposes the Standard size from -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/apps/web/src/features/sandbox/standard-sizes.json) and +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/apps/web/src/features/sandbox/standard-sizes.json) and derives Small and Large from it; the installer applies the bundle's copy of the same file. The database owns the saved values. @@ -183,8 +183,8 @@ Current Runtime generation coexistence and ownership-scoped garbage collection remain supported. They do not upgrade the installed node program or convert an old installation. History stays, and existing Sessions never move between providers. See the [nodes guide](/hosted-providers#change-the-sandbox-backend-or-size), -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) -and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/sandbox-deployment.md). +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +and [deployment contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/sandbox-deployment.md). ### Node capacity @@ -211,7 +211,7 @@ Session freezes the provider it starts with, so a later change affects only new Sessions. Self-hosted Sessions never use it. A hosted or self-hosted Session with no provider is refused with 400 `model_provider_required`. See [model providers](/quickstart#model-providers) and the -[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md#deployment-defaults). +[model execution contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/model-execution.md#deployment-defaults). The operator file `AGENTS_API_EXECUTION_OPTIONS_FILE` is retired; see [installation version policy](/troubleshooting#installation-version-policy). @@ -231,7 +231,7 @@ A Web-only installation has only `secrets/core.key`, a copy of its Core's key. Core reads only its environment. The installer renders `generated/core.env` from `config.json`; if you run Core yourself (see -[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/maintainers.md)), set these variables. Compose +[Maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/maintainers.md)), set these variables. Compose loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose must be 2.26.0 or newer. @@ -246,7 +246,7 @@ loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose mu | `OAC_INSTALLATION_ID` | `state.json`; pinned to the database | | `OAC_SETTINGS_FILE` | `generated/settings.json`, the snapshot Core serves at `GET /core/v1/installation`; Core does not act on it | | `OAC_EXECUTION_CONCURRENCY`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS` | The matching `core.*` settings | -| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-history-api.md) | +| `OAC_HISTORY_SETTINGS_FILE` | `generated/runtime-history.json` when `core.runtime_history` is set; see the [history contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime-history-api.md) | | `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | `log.*`; Web reads the same three | | `OAC_E2B_STATE_DIR`, `OAC_E2B_PROVIDER_BIN` | The E2B receipt directory and, for native Core, the bundled helper. The E2B key and template live in the database | @@ -268,4 +268,4 @@ paths must be canonical absolute paths without control characters, quotes, backs or wildcards. Keep the installation ID and the database together; Core refuses a missing installation ID when its database already has a deployment. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/configuration.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/configuration.md) diff --git a/apps/docs/content/docs/console.mdx b/apps/docs/content/docs/console.mdx index 4a39b51de..6a6342b0d 100644 --- a/apps/docs/content/docs/console.mdx +++ b/apps/docs/content/docs/console.mdx @@ -27,7 +27,7 @@ management client, and it sends nothing to `/v1`. | Platform | Projects and keys, Nodes, System | Project and key lifecycle; sandbox deployment and nodes; System: the installation's public address, API base URL, ID and source commit (read-only), each harness's default model provider (write-only key) beside its read-only startup state, the sandbox configuration every Project shares: provider, sandbox size, Runtime or E2B template build, idle suspension and reset, and Core's config.json startup settings with where to change them | Missing data is shown as missing (—), never as zero. How each figure is read and -bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/protocol-coverage.md). +bounded is recorded in [management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/web/protocol-coverage.md). ## Management scope @@ -60,15 +60,15 @@ only the console server sends it to Core. - [Connection and authentication](/bootstrap-projects-keys) - [Architecture and ownership](/execution-model) -- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/protocol-coverage.md) -- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/roadmap.md) -- [React application](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/apps/web/README.md) +- [Management interface coverage](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/web/protocol-coverage.md) +- [Frontend handoff and acceptance](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/web/roadmap.md) +- [React application](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/apps/web/README.md) -The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) defines -management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md) +The [administrator API contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md) defines +management routes and resource behavior. The [public API contracts](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md) define the separate application interface. See the [design principles](/concepts) -for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md) for required checks. +for ownership and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/CONTRIBUTING.md) for required checks. -OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/LICENSE). +OpenAgentCore Web is available under the [MIT License](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/LICENSE). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/web/README.md) diff --git a/apps/docs/content/docs/environments-and-files.mdx b/apps/docs/content/docs/environments-and-files.mdx index 896627ce9..7322707ca 100644 --- a/apps/docs/content/docs/environments-and-files.mdx +++ b/apps/docs/content/docs/environments-and-files.mdx @@ -3,19 +3,19 @@ title: "Environments and files" description: "Execution placement, Environment resources and workspace ownership." --- -This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream.json), with partial +This assessment covers the fixed [Python SDK contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/upstream.json), with partial coverage. Core-managed Docker runs the three qualified native harnesses. V1 user-managed `self_hosted` enrollment uses the same colocated Runtime for Codex, Claude SDK and MiniMax. `/workspace` names the packaged workspace; a self-hosted Session may instead select its exact canonical physical directory. The -[recorded deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md) retains its historical +[recorded deployment qualification](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/user-managed-runtime-v1.md) retains its historical source, paths and tested capability scope. For hosted compute, the deployment selects E2B, Docker or microsandbox through -[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md). +[Hosted Sandbox Manager](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md). For the separate caller-managed E2B path, the user owns allocation, renewal and -cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/deploy/e2b/README.md). -[Templates](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; -[Files](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. +cleanup through the official SDK and [Runtime packaging](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/deploy/e2b/README.md). +[Templates](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-templates.md) remain a hosted-only resource path; +[Files](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-files.md) reuse the exact authorized local workspace. The internal Store now owns a durable Environment association for newly created `self_hosted` and `openai_hosted` snapshots, atomically with Session creation. @@ -33,32 +33,35 @@ readiness. Rotation/revocation, Session deletion and ownership loss deny further access without promising immediate cessation of native effects. Native history remains local to the bound Runtime and cannot be replaced on retry. There is no registry/Noise relay or transient service-side harness credential. -See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/README.md#user-managed-runtime-enrollment). +See the [enrollment guide](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/README.md#user-managed-runtime-enrollment). ## Basic public Docker-hosted profile An explicitly configured default managed Docker provider enables `type=openai_hosted` for the qualified Codex, Claude Code and MiniMax Code profiles. Each uses the same -Runtime lifecycle and workspace interfaces with its own native adapter/isolation. -See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. -The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) +Runtime lifecycle and workspace interfaces with its native adapter. The outer +Environment provides managed isolation; the daemon adds no inner sandbox. +See the [engine profile guides](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md#public-engine-profiles) for setup and limits. +The standalone [operator configuration](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/deploy/codex/README.md#standalone-operator-configuration) selects the qualified immutable Runtime image; advertised capabilities alone do not enable admission. An idle or initial-text creation commits Session, Environment and retry identity before the existing leased Worker provisions its allocation. A committed creation interrupted before bootstrap is recovered without replaying an existing allocation's Create. -Omitted/null network defaults to enabled. Enabled, disabled and exact-host restricted -policies use the same qualified image with adapter-selected immutable native policy. +Omitted/null network defaults to enabled. The daemon does not enforce disabled +or restricted networking. An execution combination must reject unless its outer +Environment implements and qualifies the requested behavior. Templates and inline configuration share initial files, env, packages, ordered setup and inline or tenant-owned referenced Skills through ordered managed initialization. The same authenticated daemon handles initial files, tool configuration, packages, setup, Skill/Plugin import and directory snapshot finalization through `runtime_prepare`. Providers place, create, bootstrap, inspect, renew and reclaim -compute. Package installation and setup retain enabled provisioning network access; -the requested network policy constrains native execution after setup. +compute. npm/Python installation and setup use the host's existing network and +starting account. System dependencies must be preinstalled; `packages.system` +rejects explicitly and never invokes apt, sudo or elevated execution. Unsupported hostname forms and installation combinations reject explicitly; see -the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-templates.md). Empty/null installation +the [Template coverage and limits](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Service-origin hosted MCP remains unsupported; Environment Plugin MCP has its own qualified transport matrix. @@ -78,11 +81,11 @@ semantics; this profile does not establish complete Environment compatibility. The application creates, renews and destroys its E2B sandbox through the official SDK. It deploys the shared Runtime, then enrolls that Runtime into a `self_hosted` Session. Core neither keeps an E2B allocation nor issues Provider renew/kill calls. -The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/deploy/e2b/README.md) owns packaging and +The [E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/deploy/e2b/README.md) owns packaging and user-side lifecycle instructions. Expiry or lost workspace/history must not trigger -transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md) +transparent replacement or replay. The [new enrollment qualification](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/user-managed-runtime-v1.md) records its own real deployment evidence. -The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#e2b-v1-qualification) records a historical +The [prior E2B qualification](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md#e2b-v1-qualification) records a historical Core-managed route; it does not establish this caller-managed enrollment result. Current deployment-managed E2B is a separate hosted configuration in the manager guide. @@ -120,7 +123,7 @@ connection alone is not readiness. Retries preserve identity and the five-minute database deadline. HTTP disconnect retains the reservation. Local expired/cancelled outcomes return 409, lost ownership 503, and deletion 404; exact hosted error status/body and pending-input crash recovery are unverified. See the -[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md#environment-ownership-and-placement). +[canonical wait rules](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/CONTRIBUTING.md#environment-ownership-and-placement). Cancellation-only batches use the existing locked admission and native delivery. An idle cancellation creates no Turn, and retry identity preserves the original @@ -165,11 +168,11 @@ accepts only local `capability_directories` with `workspace_directory`, not mana Skill/Plugin archive fields or a hosted Template. Local directory discovery does not populate the public API-managed `skills` or `plugins` installation arrays. -Both origins use the packaged Linux Runtime's shared parser and protected -`installed.json`. Its default root is `/environment/initialization/capabilities`; -the operator may freeze another root with `OAC_RUNTIME_CAPABILITY_DIRECTORY`, kept -separate from workspace and private Runtime state. Core and public requests cannot -select this installation root. The manifest binds one +Both origins use the common Runtime parser and `installed.json` on Linux, macOS +and Windows. The operator selects the capability root; the native installer +uses `capabilities` under `OAC_RUNTIME_HOME` by default. Core and public requests +cannot select that destination. Read-only snapshot modes are integrity hints, +not protection from the launching user. The manifest binds one Session and Environment to the source-selection digest. The admitted asynchronous preparation owner verifies or creates it before native execution, including for an empty selection. Reconnect and replacement executors reuse installed contents; @@ -189,12 +192,13 @@ Core does not resolve these paths on its own host. The daemon applies its local path and access checks. Initial files and setup working directories use logical `/workspace` paths. Harness differences remain in adapters. -The protocol is platform-neutral, but this implementation retains the existing Linux -helpers, protected initialization storage and isolation requirements. It does not provide -a new native-host installer, another platform layout, or Windows/macOS isolation. -This implementation description does not extend historical real-deployment acceptance; -new managed and self-hosted capability coverage requires its own evidence. See the -[canonical preparation rules](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md#shared-runtime-capability-preparation). +The protocol and Go preparation implementation are shared by all three native +platforms. Resource directories are operator settings, not deployment branches. +Managed Providers remain Linux-only. The daemon does not sandbox tools, files or +network access; tools use the starting account's permissions. Native installation +and current validation limits are in the [native guide](/self-hosted-native). +Historical deployment acceptance does not qualify later platform or capability changes. See the +[canonical preparation rules](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/CONTRIBUTING.md#shared-runtime-capability-preparation). ## Contract inventory @@ -249,7 +253,7 @@ from Session and Turn status. ## Ownership and placement decision -The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md#environment-ownership-and-placement) +The canonical [architecture rules](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/CONTRIBUTING.md#environment-ownership-and-placement) keep Environment lifecycle common while leaving process placement and native transport to adapters. Logical ownership does not require a machine per object. @@ -273,4 +277,4 @@ native history independently of disposable compute, or prove native restoration; never treat an Environment ID as a filesystem or history backup. Do not silently move an existing Session away from its bound device. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environments.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environments.md) diff --git a/apps/docs/content/docs/execution-model.mdx b/apps/docs/content/docs/execution-model.mdx index 5fe1f60d6..ad64f9b8d 100644 --- a/apps/docs/content/docs/execution-model.mdx +++ b/apps/docs/content/docs/execution-model.mdx @@ -10,7 +10,7 @@ Agents API independently with their own Project keys. The management backend, `AdminClient` and the React console built on them are implemented. The [design principles](/concepts) define identity and authority. -The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) defines exact +The [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md) defines exact routes, payloads, pagination and audit records. ## Request boundaries @@ -106,6 +106,6 @@ comes from the deployment's allocations; the hosted Runtime total counts hosted observation records across projects and reported lifecycle states. These sources have different coverage and refresh independently, so the console does not infer resource retention or cleanup from their difference. -Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md). +Native execution ownership remains governed by [CONTRIBUTING.md](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/CONTRIBUTING.md). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/web/architecture.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/web/architecture.md) diff --git a/apps/docs/content/docs/hosted-providers.mdx b/apps/docs/content/docs/hosted-providers.mdx index 53ba8ee54..77e214b8f 100644 --- a/apps/docs/content/docs/hosted-providers.mdx +++ b/apps/docs/content/docs/hosted-providers.mdx @@ -10,7 +10,7 @@ With E2B you need no nodes. Self-hosted executors, which applications run for th own Sessions, are a different thing; see [Self-hosted executors](/self-hosted-execution). You add a node by generating a command in Web and pasting it on the host. The -[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the +[operator reference](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md) covers the node protocol, manual registration, placement and failure handling. ## Before you add a node @@ -187,7 +187,7 @@ The installation ID is in the command (`--installation-id`) and on the **System* 1. In Web, open **Nodes** and choose **Remove node** on the node's page, or **Remove** in its list row, then **Confirm removal**. Core refuses while the node still holds sandboxes, snapshots or pending cleanup; let them finish, or - [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) + [archive their Sessions](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) through the Core API. Removal is permanent: the host can come back only as a new node. 2. Web then shows **Clean up the host** with the uninstall command. Run it on the host: @@ -290,7 +290,7 @@ files. Shared daemon images remain; a host administrator may remove them only af confirming no installation still needs them. Microsandbox image cleanup remains scoped to the installation's private native store and verified ownership receipts. -The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) +The [operator reference](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md#removal-and-reset) describes reset and API behavior. Same-team E2B edits have a separate online Core API path and do not involve node installation. @@ -303,4 +303,4 @@ convert or delete it. Preserve the old installation, Runtime resources and histo provision a fresh node separately through **Add node**. The current installer does not adopt historical resources or remove another installation's state. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/nodes.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/getting-started/nodes.md) diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 4159e280e..07a356c99 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -13,16 +13,17 @@ applications call Core with. | [Install Core and Web](/install) | Administrators | Prerequisites, download, the installer and every option, HTTPS and the reverse proxy, a local trial, first sign-in, what is created on disk | | [Nodes](/hosted-providers) | Administrators | Adding a node with one command, sudo and no-sudo modes, removal, logs and troubleshooting | | [Self-hosted executors](/self-hosted-execution) | Administrators and application owners | Connecting an application's own machine to a `self_hosted` Session, rotating and revoking its credential | +| [Native Runtime installation](/self-hosted-native) | Executor operators | Linux, macOS and Windows installation commands, lifecycle and current validation limits | | [Operations](/troubleshooting) | Administrators | The `oac` command, the Core key, backups, current-release repair and troubleshooting | | [Configuration reference](/configure) | Administrators | Every `config.json` setting and every runtime setting in Web | | [Call the API](/quickstart) | Application developers | `OPENAI_BASE_URL` and `OPENAI_API_KEY`, running a Session, model providers and `x_agents_core` | | [API reference](/public-api) | Developers | The `/v1`, `/core/v1` and `/api/v1` namespaces and their contracts | Deeper references: the -[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md) +[nodes and sandbox backends](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md) reference for operators, the -[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md), and -[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/maintainers.md) for building distributions +[protocol coverage and native differences](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md), and +[maintainers and advanced deployments](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/maintainers.md) for building distributions and running Core without the installer. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/getting-started/README.md) diff --git a/apps/docs/content/docs/install.mdx b/apps/docs/content/docs/install.mdx index c52a6237f..cb8d2e98a 100644 --- a/apps/docs/content/docs/install.mdx +++ b/apps/docs/content/docs/install.mdx @@ -140,7 +140,7 @@ A deployment runs its sandboxes on exactly one backend: | `none` | Nothing yet | Choose the backend on the **Nodes** page in Web | The Standard sizes come from the bundle's copy of Web's -[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/apps/web/src/features/sandbox/standard-sizes.json). The +[`standard-sizes.json`](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/apps/web/src/features/sandbox/standard-sizes.json). The installer saves the choice through the same administrator API that Web uses once the services are healthy. If Core refuses it, for example because E2B rejects the key, the installer prints Core's message and exits with an error; the services keep @@ -164,7 +164,7 @@ it installs anything: ``` Prepare the template build with the -[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/deploy/e2b/README.md). To change the backend or +[E2B guide](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/deploy/e2b/README.md). To change the backend or the sandbox size later, use the Nodes page in Web; see [Nodes](/hosted-providers#change-the-sandbox-backend-or-size). @@ -531,4 +531,4 @@ missing images, restores a missing `oac` command, copies node files newly placed `--install-dir`. It refuses a bundle from another release; see [installation version policy](/troubleshooting#installation-version-policy). -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/install.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/getting-started/install.md) diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 73b131510..1b46bcbcd 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -17,6 +17,7 @@ "environments-and-files", "hosted-providers", "self-hosted-execution", + "self-hosted-native", "---Administration---", "console", "admin-api", diff --git a/apps/docs/content/docs/observability.mdx b/apps/docs/content/docs/observability.mdx index 658c0b304..c8bebe347 100644 --- a/apps/docs/content/docs/observability.mdx +++ b/apps/docs/content/docs/observability.mdx @@ -7,7 +7,7 @@ Status: Phase 2 and initial Core Web consumption implemented. The current-snapsh `packages/agents-client` projection, and generated `core.openapi.yaml` contract are implemented and consumed by the Dashboard through complete Session/observation identity joins. Durable history uses the separate optional -[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside +[Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime-history-api.md); lifecycle controls remain outside this phase. These are administrator reads under `/core/v1`, authenticated by the Core key, @@ -92,7 +92,7 @@ Authorization: Bearer ... ``` This returns the same object shape as a list item's `observation`, without the -administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md)). It never starts a Turn, creates +administrator list's `disk` (see the [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md)). It never starts a Turn, creates an Environment, provisions compute, renews a lease, or changes lifecycle state. A valid `environment:none` Session returns `200` with status `unsupported`; the @@ -193,7 +193,7 @@ Use the existing Agents API error envelope. | 400 | `invalid_request_error` / `invalid_request_error` | List: a repeated supported query key, or an empty or invalid limit or order, with the shared Beta list messages. Unknown list query keys are ignored. | | 400 | `invalid_request_error` / `unsupported_parameter` | Single-Session retrieval with any query parameter. | | 401 | `invalid_request_error` / `invalid_admin_key` | Missing or invalid Core key. | -| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | +| 404 | `not_found_error` / `not_found_error` | Missing, malformed or foreign Session/cursor, indistinguishably, as for the [Session list cursor](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/list-query-semantics.md#list-cursor-errors--september-23-2026). | | 500 | `server_error` / `internal_error` | Integrity, ownership, or invalid provider evidence. | | 503 | `server_error` / `execution_unavailable` | Required Runtime observation service is not configured, or list collection exceeded its request budget. | @@ -273,10 +273,10 @@ deletion makes the candidate incomplete and prevents publication. - Token usage: use existing Session/Turn Usage. - Billing and cost: product/backend concern. - Historical series in these routes: the optional capability uses the separate - [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-history-api.md). + [Runtime history API](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime-history-api.md). - Container logs and command output. - Provider credentials or native configuration. - Start, stop, pause, resume, restart, renew, or delete operations. - Idle classification and automatic shutdown. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-observability-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime-observability-api.md) diff --git a/apps/docs/content/docs/public-api.mdx b/apps/docs/content/docs/public-api.mdx index 964db7ded..a1e90499c 100644 --- a/apps/docs/content/docs/public-api.mdx +++ b/apps/docs/content/docs/public-api.mdx @@ -9,8 +9,8 @@ no credential works in another namespace. | Namespace | Caller | Credential | Contents | Reference | | --- | --- | --- | --- | --- | | `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`) | [Public API](/sessions) | -| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core.openapi.yaml) | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime.openapi.yaml) | +| `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/core.openapi.yaml) | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime.openapi.yaml) | A Project API key gets 401 on `/core/v1` and `/api/v1`; the Core key gets 401 on `/v1` and `/api/v1`. Projects own assets. Multiple equally privileged keys share @@ -34,7 +34,7 @@ with the Core key on Core's loopback port, not through the public entry. Applications call every `/v1` route with a Project API key. The routes are exactly the 58 method and path pairs in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream-routes.json); the +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/upstream-routes.json); the [Public API guide](/sessions) groups them by resource. `/v1` has no Core-only route. @@ -45,18 +45,18 @@ with the Core key. | Routes | Contents | Contract | | --- | --- | --- | -| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/execution-configuration.md) | -| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/session-diagnostics.md) | +| `projects`, `projects/{project_id}[/archive]`, `projects/{project_id}/keys[/{key_id}]` | Projects and their API keys | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/{agents,environment-templates,skills,files,vaults,sessions}/**` | Resource reads and deletion, Session history, artifacts and archive | [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/sessions/{session_id}/execution-configuration` | Committed harness and model selection | [Execution configuration](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/execution-configuration.md) | +| `projects/{project_id}/sessions/{session_id}/diagnostics`, `projects/{project_id}/sessions/{session_id}/turns/{turn_id}/diagnostics` | Root failure categories and Item receipt timing | [Session diagnostics](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/session-diagnostics.md) | | `projects/{project_id}/sessions/{session_id}/runtime-observation`, `sandbox/runtime-observations` | Current Runtime observations | [Runtime observations](/observability) | -| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime-history-api.md) | -| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) | -| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md) | -| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/installation.md) | -| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core-metrics.md) | -| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/node-host-history.md) | -| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md#deployment-defaults) | +| `projects/{project_id}/sessions/{session_id}/runtime-history` | Stored Runtime history | [Runtime history](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime-history-api.md) | +| `projects/{project_id}/{resource-owners,write-operations}`, `audit-log`, `summary` | Provenance, write history, administrator audit and usage summary | [Write audit](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/write-audit.md), [administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md) | +| `projects/{project_id}/environments/{environment_id}/executor-credentials[/{key_id}]` | Executor credentials for a self-hosted Environment | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-executor-credentials.md) | +| `installation` | Public URL, API base URL, source commit, the installer's process settings and what is bound to the public URL; available before any deployment | [Installation](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/installation.md) | +| `metrics` | Core's own process metrics | [Core metrics](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/core-metrics.md) | +| `sandbox/deployment[/reset]`, `sandbox/enrollment-tokens`, `sandbox/nodes[/{node_id}[/allocations]]` | Sandbox deployment, node enrollment tokens and nodes | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/sandbox-deployment.md), [node operations](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md), [node host history](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/node-host-history.md) | +| `harnesses`, `harnesses/{harness}/model-provider` | Supported harnesses and each harness's deployment default model provider (write-only key) | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/model-execution.md#deployment-defaults) | ## Machine connection API @@ -65,42 +65,42 @@ the Core key or a Project API key. | Routes | Caller | Credential | Contract | | --- | --- | --- | --- | -| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime.openapi.yaml) | -| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | -| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md) | -| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/services/agents-api/README.md#user-managed-runtime-enrollment) | +| `POST sandbox-node/enroll` | Node installer | One-use enrollment token from `POST /core/v1/sandbox/enrollment-tokens` | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/configuration` | Node installer and node | Enrollment token, or node credential with `X-OAC-Node-ID` | [Sandbox deployment](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/sandbox-deployment.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime.openapi.yaml) | +| `GET sandbox-node/identity`, WebSocket `GET sandbox-node/connect` | Node | Node credential registered at enrollment | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host) | +| `POST agent-daemon/enroll`, `GET agent-daemon/connection` | Self-hosted executor and its installer | Executor credential from `/core/v1/projects/{project_id}/environments/{environment_id}/executor-credentials` | [Executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-executor-credentials.md) | +| WebSocket `GET agent-daemon/ws`, `POST agent-daemon/bootstrap`, `GET agent-daemon/device-status` | Runtime daemons | Daemon credential: Core writes one into each hosted sandbox it prepares; a self-hosted executor uses its executor credential | [Runtime enrollment](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/services/agents-api/README.md#user-managed-runtime-enrollment) | ## Contract sources -- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream.json): OpenAI +- [Pinned upstream baseline](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/upstream.json): OpenAI Python SDK 3.13.0, exact upstream commit and `agents=v1`. -- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream-routes.json) and - [fields](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream-fields.json): extracted from the +- [Pinned routes](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/upstream-routes.json) and + [fields](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/upstream-fields.json): extracted from the pinned SDK by `scripts/extract-agents-api-upstream.py`. Contract tests require the public OpenAPI to have exactly these routes and to keep every other field inside `x_agents_core`. -- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/openapi.yaml): public schema snapshot; - combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/operation-evidence.md). -- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core.openapi.yaml): generated `/core/v1` +- [Public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/openapi.yaml): public schema snapshot; + combine it with the fixed SDK and [operation evidence](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/operation-evidence.md). +- [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/core.openapi.yaml): generated `/core/v1` routes, all authenticated by the Core key. -- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/runtime.openapi.yaml): - generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/node-generation-protocol.md); the private +- [Machine connection OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/runtime.openapi.yaml): + generated `/api/v1` sandbox node routes. The node WebSocket is described in the [generation protocol](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/node-generation-protocol.md); the private daemon transport is described in the Runtime credential guide. -- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md): Project/key +- [Administrator contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md): Project/key lifecycle, resources, explicit hosted Session archive, summary, errors/deletion preconditions, audit and historical copy provenance. - [Web integration](/admin-api): browser, console and Core boundaries and frontend handoff. -- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/CONTRIBUTING.md): +- [Design rules](/concepts) and [contributor guide](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/CONTRIBUTING.md): ownership, security and change requirements. Generated schemas do not establish complete compatibility or real execution -support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md) identifies +support. The [coverage record](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md) identifies qualified workflows, native differences and unresolved behavior. Update the relevant contract and this index when adding or moving an API surface. -Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/core-errors.md), +Core administration failures use the [Core error envelope](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/core-errors.md), including typed optional safe details and distinct console proxy rejection codes. The public and machine error contracts remain unchanged. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/api/README.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/api/README.md) diff --git a/apps/docs/content/docs/quickstart.mdx b/apps/docs/content/docs/quickstart.mdx index 49a88bfca..e8f66cebe 100644 --- a/apps/docs/content/docs/quickstart.mdx +++ b/apps/docs/content/docs/quickstart.mdx @@ -101,8 +101,8 @@ The provider's protocol must match the harness: | Claude Code (`claude_sdk`) | `anthropic` | The provider's exact model ID | | MiniMax Code (`mcode`) | `anthropic` | `context_window` and `max_output_tokens` | -See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/harness-selection.md) and -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md) for the full contract. +See [harness selection](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/harness-selection.md) and +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/model-execution.md) for the full contract. Selecting a harness doesn't make an unsupported model or operation work. ## Model providers @@ -142,8 +142,8 @@ not replay history. Don't submit the same work as a new Session when a response lost. Files, Artifacts, cancellation and their current limits are in the -[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md). MCP credentials use the +[coverage ledger](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md). MCP credentials use the separate Vault API; they are neither Project API keys nor model provider keys. The [API index](/public-api) lists every route. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/quickstart.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/getting-started/quickstart.md) diff --git a/apps/docs/content/docs/self-hosted-execution.mdx b/apps/docs/content/docs/self-hosted-execution.mdx index a724057ff..7b587b5cd 100644 --- a/apps/docs/content/docs/self-hosted-execution.mdx +++ b/apps/docs/content/docs/self-hosted-execution.mdx @@ -3,36 +3,28 @@ title: "Self-hosted executors" description: "Connect a user-owned Runtime to its Environment with a restricted executor credential." --- -A `self_hosted` Session runs on a machine the application owns, the executor host, -instead of a node. The application creates the Session through `/v1`; the -administrator issues an executor credential for it in Web; one command on the host -starts a Runtime container that connects to Core. The Runtime is the same one nodes -use: daemon, native harness and workspace. - -An executor credential works for one Session's environment only. It can't call the -Agent API, the Core API or node enrollment. - -## Before you start - -- **Core has an HTTPS public URL.** The Session's `remote_url` is - `wss:///api/v1/agent-daemon/ws`, and the installer connects only over - `wss://`. Web shows no command until Core has a reachable public URL. -- **Web holds the installer and the Runtime files.** The command downloads the - installer, the Runtime launcher and the image from Core's public URL, which the - reverse proxy sends to Web (in a [split deployment](/install#split-deployment), to - the Web host); install from the offline bundle. A console without the self-hosted - installer shows no **Connect a host** section at all. -- **The host** runs Linux amd64 with Python 3.9+, `curl`, `sha256sum` and the Docker - CLI, with Docker usable through `/var/run/docker.sock` by the non-root user that runs - the installer; the installer refuses root. It reaches the public URL over HTTPS. -- **The Session carries its model provider**, in the request or saved on its Agent. - Default models never apply to self-hosted Sessions, and creating one without a - provider fails with 400 `model_provider_required`. +A `self_hosted` Session runs on a machine the application owns. The application +creates the Session through `/v1`; the administrator issues an executor credential +in Web or through `/core/v1`; the host runs `oac-daemon` with that credential. +Linux, macOS and Windows use the same Runtime protocol. Core-managed Providers +remain Linux-only. + +The daemon runs with its launching user's permissions and adds no inner +filesystem, permission or network sandbox. Its tools may access that user's files +and Runtime credentials. Managed isolation belongs to the outer Environment; +self-hosted operators choose any outer isolation they need. See the +[native installation guide](/self-hosted-native) for prerequisites, commands +and supported platforms. + +An executor credential works for one Environment only. It cannot call the Agent +API, Core API or node enrollment. Default model providers never apply to +self-hosted Sessions: the request or Agent must carry its own model provider. +Otherwise creation fails with 400 `model_provider_required`. ## Connect a host -1. **The application creates the Session**, with its Project API key and its own model - provider: +1. Create an existing workspace on the executor host. The application creates a + Session using that host's absolute path and its own Project API key: ```python import os @@ -40,7 +32,10 @@ Agent API, the Core API or node enrollment. client = OpenAI() # reads OPENAI_BASE_URL and OPENAI_API_KEY session = client.beta.agents.sessions.create( - environment={"type": "self_hosted", "workspace_directory": "/workspace"}, + environment={ + "type": "self_hosted", + "workspace_directory": os.environ["EXECUTOR_WORKSPACE"], + }, extra_body={ "agent": {"model": os.environ["MODEL_NAME"], "x_agents_core": {"harness": "codex"}}, "x_agents_core": {"model_provider": { @@ -53,92 +48,60 @@ Agent API, the Core API or node enrollment. print(session.id, session.environment.id, session.environment.remote_url) ``` -2. **In Web**, open **Session log**, then the Session's page. In its - **Executor credentials** section, **Connect a host** shows a command in a - **Terminal** block; copy it and run it on the executor host: - - ```sh - (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT - curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example/node-install/self-hosted-install.pyz' -o "$d/install.pyz" && - printf '%s %s\n' '' "$d/install.pyz" | sha256sum -c --status && - python3 "$d/install.pyz" --source-url 'https://core.example' --environment-id '' --remote 'wss://core.example/api/v1/agent-daemon/ws') - ``` - - The command contains no secret. It downloads the installer from your console and - checks its SHA-256 before running it. -3. **Issue the credential.** Select **Issue credential**. The **Executor credential** - dialog shows it once: choose **Copy credential**. -4. **Paste it at the installer's hidden prompt** and press Enter. The prompt accepts the - compact or the pretty-printed credential and never echoes it. Then choose **Done** in - the dialog; after that the credential can't be shown again. Closing the dialog with - × keeps the credential on the page until you choose **Done**. - -The installer downloads the Runtime from the console and checks it, starts a container -named `oac-selfhost-<32 hex digits>`, and waits until Core confirms that the -Environment is connected. The Session then runs its Turns there. Rerunning the same -command resumes the same installation. It refuses, with "This installation belongs to -another Environment or distribution", once Core runs another release or its public URL -changed: the host's installation is tied to both. Moving an executor to a new release -or address isn't supported yet. A fresh installation would start a new container -without the old workspace and native history, so the Session can't continue there: -stop the old container, create a new self-hosted Session and connect a host for it. - -For automation without a terminal, choose **Download credential file**, which saves -`executor-credential-.json`, make it private -(`chmod 600`), and add `--credential-file /absolute/path/to/that-file.json` to the -installer line. The path must not go through a symbolic link. - -The host keeps its state in `~/.oac/self-hosted//` -(`--install-dir` chooses another absolute directory). Keep it with the container's -volumes: they hold the credential, workspace and native history. The Runtime's log is -`docker logs `. A connected Environment proves the connection, not that the -model works; send the Session a Turn to test execution. - -Core delivers the Session's model provider only over this Environment's executor -connection. The host keeps it in the Runtime's harness home, where the model's tools -and the Files API can't read it but the host's owner can. +2. In Web, open **Session log**, then the Session's **Executor credentials** + section. Choose **Issue credential**, then **Download credential file**. Core + returns the credential only once; retain it privately before choosing **Done**. +3. On the host, run `oac-daemon install` with the returned `remote_url`, Environment + ID, matching workspace and absolute credential-file path, then `oac-daemon start`. + The [native guide](/self-hosted-native#install-and-start) has Linux/macOS and + PowerShell examples. No Docker installation is required for this native path. + +The console's existing **Connect a host** download command targets the Linux +container installer, not the native installation command. Use the native guide for +this workflow; downloading an executor credential is independent of the console's +installer assets. Core must be reachable from the host: `wss://` is required +outside loopback, while a local Core can use a loopback `ws://` URL. + +A connected Environment proves only the machine connection. Send a Turn to check +the selected harness and model. Core supplies the Session's model provider over +this Environment's authenticated executor connection. The host's user and tools +running with that user's permissions can access locally stored Runtime data. ## Local capability directories -A self-hosted Session may supply `capability_directories` alongside its -`workspace_directory`, for example: +A self-hosted Session may supply `capability_directories` alongside its workspace: ```python environment = { "type": "self_hosted", - "workspace_directory": "/workspace", - "capability_directories": ["/workspace/capabilities"], + "workspace_directory": os.environ["EXECUTOR_WORKSPACE"], + "capability_directories": [os.environ["EXECUTOR_CAPABILITIES"]], } ``` -The API accepts portable absolute Unix, Windows drive and UNC source paths without -checking Core's filesystem. The current Linux Runtime requires canonical local Unix -paths and applies its own access checks; this does not add Windows support. -These paths refer to directories visible inside that Runtime. Populate them before -first execution. `/workspace` maps to its bound workspace; an operator-prepared -Runtime may use its exact canonical absolute workspace instead. Directory aliases, -private credentials, native history and protected initialization trees are refused. -This does not mount arbitrary host paths into the container or add a new installer. -Self-hosted public input accepts local directories, not managed Skill/Plugin archives -or hosted Templates. - -The current implementation uses the packaged Linux helpers and protected -`installed.json`, by default under `/environment/initialization/capabilities`. -Operators may freeze another root with `OAC_RUNTIME_CAPABILITY_DIRECTORY`; it must -stay separate from the workspace and private Runtime state and is not a public API -setting. Runtime snapshots sources -before native execution and supplies the resulting Skill and MCP inventory through -the same parser as managed bundles. Reconnect reuses installed contents even after -source edits; a new Session captures its own snapshot. A missing or inconsistent -snapshot fails preparation without silently reinstalling. Local discovery does not -create entries in the public API-managed installation arrays. +Core accepts absolute Unix, Windows drive and UNC source paths without checking +its own filesystem. Runtime validates them using the executor host's path syntax. +Populate these directories before first execution. They are ordinary paths visible +to that process; naming a directory does not mount it or create a sandbox. +Self-hosted public input accepts local directories, not managed Skill/Plugin +archives or hosted Templates. + +Runtime snapshots sources before native execution and uses the same parser and +`installed.json` format as managed bundles to supply Skills and Plugin MCP. +The native installer defaults the snapshot destination to `capabilities` under +`OAC_RUNTIME_HOME`; `--capability-directory` selects another local destination. +It is an operator setting, not a public API write destination. Reconnect reuses +installed contents even after source edits; a new Session captures its own +configuration. A missing or inconsistent snapshot fails preparation rather than +silently reinstalling. Local discovery does not create entries in the public +API-managed installation arrays. Snapshot file modes do not isolate the snapshot +from tools running as the same user. Closing an executor, cancelling a Turn or losing its connection preserves the -snapshot and workspace. The compute owner remains responsible for explicit resource -cleanup. This source-level capability behavior requires its own real acceptance; -[earlier qualification](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/user-managed-runtime-v1.md) covered -only its recorded inputs and binaries, not local directory preparation or additional -platforms. +snapshot and workspace. The compute owner remains responsible for explicit +cleanup. [Historical qualification](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/user-managed-runtime-v1.md) +records only its stated inputs and binaries; it does not qualify the current native +platforms or local capability preparation. ## Rotate or revoke @@ -147,25 +110,16 @@ platforms. | **Rotate** | The credential gets a new secret; the old secret stops working at once. Rotating a revoked credential restores it | | **Revoke** | The credential stops working at once | -After either, the Runtime disconnects and stops retrying: its log shows one message -that names the fix, and the container keeps running without a restart loop. To -reconnect the host: - -1. **Rotate** the same credential in Web and copy the new secret. -2. Rerun the same install command on the host and paste it. +To reconnect a native installation, stop it with `oac-daemon stop`, rotate the same +credential, replace the JSON at its configured credential-file path, and run +`oac-daemon start`. Keep the same `OAC_RUNTIME_HOME` for every command. Issuing a +new credential does not reconnect an Environment already bound to its first +credential; rotate that credential instead. Do not run `install` again over the +existing installation. -The installer puts the new credential into the same container, which reconnects with -its workspace and history. Issuing a *new* credential doesn't reconnect an Environment -that has already connected: it stays bound to its first credential, and the installer -says to rotate that one. - -To remove the Runtime instead, stop its container: -`docker --host unix:///var/run/docker.sock stop `. Deleting the Session does -not remove containers or volumes on the executor host. - -In an archived project, Web shows "This project is archived, so executor credentials -can't be issued or rotated." Only **Revoke** remains; a host that already holds a -credential keeps it when you rerun the command. +Stopping the daemon keeps its workspace and native history. Deleting a Session +does not remove host files. In an archived Project, credentials cannot be issued +or rotated; revocation remains available. ## Without Web @@ -185,20 +139,15 @@ key_id=$(python3 -c 'import uuid; print(uuid.uuid4())'); echo "credential ID: $k Rotate with `{"key_id":"…","rotate":true}` on the same route; revoke with `DELETE …/executor-credentials/`. After an uncertain response, list the credentials with `GET` before trying again. The -[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md) +[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-executor-credentials.md) has every rule and error. - ## Historical executor installations -The current installer does not upgrade or adopt an executor from an older release. -It refuses when the same Environment retains state under the old -`~/.parsar/self-hosted` directory, including when another `--install-dir` is supplied. -Preserve the old container, files and native history; provision a fresh self-hosted -Session and connect its executor separately. There is no supported cross-version -continuation or automatic data deletion. - -Credential replacement for executors created by the current installer updates and -restarts the same owned container. It does not authorize adopting an older executor. +The native installer supports its current version only. It does not adopt or +upgrade an older daemon or container installation. Preserve old files, containers +and native history; use a fresh Runtime home and Session when moving versions. +Historical container-installer instructions and qualification records describe +those earlier artifacts, not acceptance of the current native daemon. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/self-hosted.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/getting-started/self-hosted.md) diff --git a/apps/docs/content/docs/self-hosted-execution.zh.mdx b/apps/docs/content/docs/self-hosted-execution.zh.mdx index 27012a4b9..86f1481c1 100644 --- a/apps/docs/content/docs/self-hosted-execution.zh.mdx +++ b/apps/docs/content/docs/self-hosted-execution.zh.mdx @@ -3,7 +3,7 @@ title: "Self-hosted executors · 中文导读" description: "当前版本的中文导读;完整操作步骤见英文正文。" --- -self_hosted 执行器由用户拥有,Core 不管理它的容器。通过 Web 为对应 Environment 签发 executor 凭据,安装器通过隐藏输入或私有文件读取凭据;不要把凭据放入命令行。 +self_hosted 执行器由用户拥有,通过 Web 为对应 Environment 签发凭据。Linux、macOS 和 Windows 使用同一 daemon 协议;daemon 以启动账户权限运行,隔离由外层环境负责。凭据通过私有文件提供,不放入命令行。 此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/self-hosted-execution)。 diff --git a/apps/docs/content/docs/self-hosted-native.mdx b/apps/docs/content/docs/self-hosted-native.mdx new file mode 100644 index 000000000..b0e1c908f --- /dev/null +++ b/apps/docs/content/docs/self-hosted-native.mdx @@ -0,0 +1,159 @@ +--- +title: "Native daemon installation" +description: "Install and operate a self-hosted daemon on Linux, macOS or Windows." +--- + +A self-hosted Runtime runs the same `oac-daemon` on Linux, macOS or Windows. +Core uses the same Environment protocol on all three platforms; native harness +adapters handle harness differences. Core-managed Providers remain Linux-only. + +The daemon runs tools with the permissions of the user who starts it. It adds no +filesystem, permission or network sandbox. Tools can access whatever that user +can access, including Runtime state and credentials. Authentication, ordinary +credential storage, atomic state writes and process cleanup do not isolate tools +from the host. When isolation is needed, run the Runtime inside an outer sandbox; +Core creates that outer boundary for managed Docker/E2B/microsandbox Environments. +The daemon does not enforce `disabled` or `restricted` network modes; combinations +without corresponding outer enforcement are unsupported. + +## Supported platforms + +| Platform | Codex | Claude Code | MiniMax Code | +| --- | --- | --- | --- | +| Linux | Supported | Supported | Supported | +| macOS | Supported | Supported | Supported | +| Windows | Supported | Supported | Not supported by the current adapter | + +Native CI passing is the platform support criterion. Windows has not yet had +manual machine acceptance. Managed Providers support Linux only. + +Use a current-version daemon binary for the host's OS and architecture. The daemon +installer saves local configuration; native harnesses and their dependencies must +already be available to its process. Claude on Windows requires Git Bash. + +Create a `self_hosted` Session with its model provider and an existing absolute +`workspace_directory` on the executor host. Obtain its `environment.id` and +`environment.remote_url`, then have the Core administrator issue an +[executor credential](/self-hosted-execution#without-web). Save the returned +JSON as a private local file. It contains `key_id`, `environment_id` and +`executor_token`; do not put the token in command arguments. + +Use the exact `remote_url` from Core. It uses `wss://` outside loopback; `ws://` is +accepted only for a loopback Core. The Environment ID must be a canonical UUID. +Paths must be clean absolute paths in the host's syntax, and the workspace must +already exist. The install workspace must match the Session's configured workspace. + +## Install and start + +Choose a separate `OAC_RUNTIME_HOME` for each installation. It contains local +configuration, connection state, logs and the default capability snapshot. Keep +this same value for install, start, status, logs and stop. + +On Linux or macOS: + +```sh +export OAC_RUNTIME_HOME="$HOME/.oac/runtime-example" +mkdir -p "$HOME/agent-workspace" +chmod 600 "$HOME/executor-credential.json" +oac-daemon install \ + --remote 'wss://core.example/api/v1/agent-daemon/ws' \ + --environment-id '11111111-2222-4333-8444-555555555555' \ + --workspace "$HOME/agent-workspace" \ + --credential-file "$HOME/executor-credential.json" +oac-daemon start +``` + +On Windows, use native absolute paths in PowerShell: + +```powershell +$env:OAC_RUNTIME_HOME = "$HOME\.oac\runtime-example" +New-Item -ItemType Directory -Force "$HOME\agent-workspace" | Out-Null +oac-daemon.exe install ` + --remote 'wss://core.example/api/v1/agent-daemon/ws' ` + --environment-id '11111111-2222-4333-8444-555555555555' ` + --workspace "$HOME\agent-workspace" ` + --credential-file "$HOME\executor-credential.json" +oac-daemon.exe start +``` + +Replace the example URL and UUID with the Session's actual values. On Windows, keep +the credential file under the intended user's normal private storage permissions. + + +Optional install flags: + +| Flag | Meaning | +| --- | --- | +| `--capability-directory ABS` | Local destination for the installed capability snapshot; defaults to `capabilities` under `OAC_RUNTIME_HOME`. This is an operator setting, not a public API destination | +| `--tool-env-file ABS` | JSON object of string-valued tool environment variables. Use it for explicitly configured tool/MCP variables; it is not a dotenv or shell file | + +For example, a tool environment file can contain `{"EXAMPLE_SETTING":"value"}`. +Treat files containing credentials as secrets. Retain the credential and tool +configuration files at their configured paths; installation records those paths. + +Runtime preparation uses one Go implementation on all platforms. Its local +initialization and package directories default to `initialization` and `packages` +under `OAC_RUNTIME_HOME`. Operators can select absolute paths with +`OAC_RUNTIME_INITIALIZATION_DIRECTORY` and `OAC_RUNTIME_PACKAGE_DIRECTORY`; +managed images set these to `/environment/initialization` and +`/environment/packages`. They change storage layout, not execution permissions. +`OAC_RUNTIME_TOOL_ENV_FILE` selects an explicit tool-variable JSON file; preparation +does not overwrite an existing configuration. + +npm and Python packages install only into the Runtime package directory using +npm's prefix and pip's target options. Node/npm and Python/pip must already be +installed. Setup requires Bash; on Windows it requires Git Bash, found from Git's +installation or `CLAUDE_CODE_GIT_BASH_PATH`. Missing dependencies fail preparation; +Runtime does not substitute PowerShell, cmd.exe or WSL for Bash setup. + + +`oac-daemon start --foreground` stays attached to the terminal instead of starting +in the background. An operator can use their own service manager to run it; the +native installer does not configure one. + +## Operate the same installation + +With the same `OAC_RUNTIME_HOME` still set: + +```sh +oac-daemon status +oac-daemon logs -n 100 +oac-daemon logs -f +oac-daemon stop +``` + +Use `oac-daemon.exe` for these commands on Windows. A connected Environment proves +the machine connection, not model execution: send a Turn to validate the selected +native harness and model. Stopping the daemon preserves the workspace, native +history and capability snapshot; deleting a Session does not remove host files. + +After credential rotation, stop the daemon, replace the JSON at the configured +credential path with the newly issued credential for the same `key_id`, then start +it again. Do not rerun `install` over an existing installation. Revocation prevents +the old credential from reconnecting. + +## Capabilities and versions + +`capability_directories` supplies local source directories when the Session is +created. Runtime snapshots them before execution and resolves Skills and Plugin +MCP from the common `installed.json` format used for managed bundles. Paths use the +executor host's native syntax, not Core's filesystem. Reconnect reuses the snapshot; +source edits do not silently replace it. A new Session captures new configuration. +See [local capability directories](/self-hosted-execution#local-capability-directories). +Read-only snapshot modes are an integrity hint, not protection from the same user. + +Installation is current-version only. It refuses an existing installation, and +`start` refuses configuration from another daemon version. There is no in-place +upgrade, adoption of a historical container installation, or cross-version native +history migration. Preserve an old installation's files and use a fresh Runtime +home and Session when moving versions. + +System dependencies must be present before Runtime execution: install them while +building the managed image/template, or prepare them as the self-hosted user. +Runtime does not run apt, request sudo or elevate the daemon's privileges. +`packages.system` is explicitly rejected on Session and Template input, including +an empty or null value; it is never ignored. A missing preinstalled dependency +fails the operation that requires it. npm/Python package installation and setup retain their supported +initialization flow using the launching user's existing permissions. + +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/self-hosted-native.md) diff --git a/apps/docs/content/docs/self-hosted-native.zh.mdx b/apps/docs/content/docs/self-hosted-native.zh.mdx new file mode 100644 index 000000000..1eb15cfee --- /dev/null +++ b/apps/docs/content/docs/self-hosted-native.zh.mdx @@ -0,0 +1,10 @@ +--- +title: "Native daemon installation · 中文导读" +description: "当前版本的中文导读;完整操作步骤见英文正文。" +--- + +使用当前平台的 oac-daemon 安装、启动和停止自托管 Runtime。系统依赖需提前安装,运行时不提权。Windows 已通过原生 CI 自动测试,尚未人工实机验收。 + +此页为中文导读,完整且与当前源码同步的步骤请阅读[英文正文](/en/self-hosted-native)。 + +[安装](/install) · [配置](/configure) · [API 与凭据](/public-api) · [节点](/hosted-providers) · [应用示例](/quickstart) diff --git a/apps/docs/content/docs/sessions.mdx b/apps/docs/content/docs/sessions.mdx index 9b4751d65..506083997 100644 --- a/apps/docs/content/docs/sessions.mdx +++ b/apps/docs/content/docs/sessions.mdx @@ -34,8 +34,8 @@ leaves the others valid; archiving the Project revokes all its keys. The optiona ## Public resources Paths are relative to `/v1`. Detailed fields, union types and response shapes are -in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/openapi.yaml), fixed SDK source -and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/README.md#upstream-resource-inventory) +in the [public OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/openapi.yaml), fixed SDK source +and linked semantic records. The [resource inventory](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/README.md#upstream-resource-inventory) lists each operation and its coverage. | Resource | Route family | Semantics | @@ -44,11 +44,11 @@ lists each operation and its coverage. | Sessions and Turns | `/agents/sessions`, nested `/turns` | Admission, metadata, state and recovery queries | | Items and events | Session `/items`, `/events` | Persisted history and live SSE | | Artifacts | Session `/artifacts` | Captured output metadata, immutable content and deletion | -| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/subagents.md) | -| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-files.md) | -| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-templates.md) | -| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/source-files.md), distinct from live workspace files | -| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/file-resource-semantics.md) | +| Subagents | Session `/subagents`, nested `/turns` and `/items` | Scoped child execution reads; [native limits](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/subagents.md) | +| Environments and workspace Files | `/agents/environments`, nested `/files` | [Environment state](/environments-and-files) and [workspace access](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-files.md) | +| Environment Templates | `/agents/environments/templates` | [Reusable initialization](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-templates.md) | +| Source Files | `/files` | [Uploaded content resources](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/source-files.md), distinct from live workspace files | +| Skills and versions | `/skills`, nested `/versions` | [Bundles and versions](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/file-resource-semantics.md) | | Vaults and Credentials | `/vaults`, nested `/credentials` | [Scoped write-only credential storage](/environments-and-files) | SSE is live, not a historical replay service. After a disconnect, query the Session, @@ -58,14 +58,14 @@ native execution. Do not automatically replay uncertain tool or file effects. ## Core extension fields The `/v1` route set is exactly the pinned SDK's operations, listed in -[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/upstream-routes.json); Core adds +[upstream-routes.json](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/upstream-routes.json); Core adds no route. It adds fields only inside `x_agents_core`, because it runs several harnesses and accepts custom model access: | Field | Where | Reference | | --- | --- | --- | -| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/harness-selection.md) | -| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md) | +| `x_agents_core.harness` | Saved Agent create, update and read; the inline Session `agent`; the Session's effective `agent` | [Harness selection](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/harness-selection.md) | +| `x_agents_core.model_provider` | Saved Agent create, update and read; Session creation. It takes `protocol`, `base_url`, optional `context_window` and `max_output_tokens`, and a write-only `api_key`; reads return `api_key_configured` instead of the key | [Model execution](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/model-execution.md) | Any other member of `x_agents_core` is rejected with 400. Deployment, placement, credential issuance and operational reads are not part of `/v1`; they belong to @@ -78,7 +78,7 @@ never to `self_hosted`; `none` Sessions accept only the deployment default. `openai_hosted` and `self_hosted` Sessions must end up with one: otherwise creation returns 400 with code `model_provider_required` and param `x_agents_core.model_provider`, and nothing is written. See -[model execution](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/model-execution.md). +[model execution](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/model-execution.md). An application reads a Session's model from `agent.model` and an explicitly selected harness from `agent.x_agents_core.harness`; a Session on the deployment @@ -88,10 +88,13 @@ read. `openai_hosted` keeps the official wire name and means Core-managed compute here. The deployment chooses E2B or its own Docker/microsandbox nodes. A public `self_hosted` Environment uses caller-owned compute and the same colocated -Runtime. For a `self_hosted` Session, the deployment operator issues the +Runtime on Linux, macOS or Windows; managed Providers remain Linux-only. Native +Runtime execution uses the launching user's permissions without an inner sandbox. +See [native installation and validation status](/self-hosted-native). +For a `self_hosted` Session, the deployment operator issues the Environment's executor credential with the Core key; see -[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/environment-executor-credentials.md). +[executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/environment-executor-credentials.md). Our daemon transport and the supported native harness differences are explicit; they do not imply interoperability with OpenAI's stock executor transport. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/api/public-agent-api.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/api/public-agent-api.md) diff --git a/apps/docs/content/docs/troubleshooting.mdx b/apps/docs/content/docs/troubleshooting.mdx index b1d4fe576..12c9615e6 100644 --- a/apps/docs/content/docs/troubleshooting.mdx +++ b/apps/docs/content/docs/troubleshooting.mdx @@ -123,7 +123,7 @@ refuses. `rotate-core-key` refuses to run on a Web-only installation. ## Projects and API keys Create Projects and issue their keys in Web, on **Projects and keys**, or with the -[Core API](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's +[Core API](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md) under `/core/v1/projects`. A key's plaintext is shown once; Core stores only its digest. All keys of a Project share its assets and execution principal, and each write records the key that made it. @@ -225,11 +225,14 @@ sign-in sessions live in Web's memory and last 12 hours. microsandbox uses an explicit network policy: public egress, the Core and DNS ports the Runtime needs, and no inbound or private-network access. Private model or MCP endpoints -need an explicit policy change. A Session's own network policy is separate. +need an explicit policy change. The daemon does not enforce Session-level +`disabled` or `restricted` networking. Combinations without the required outer +network enforcement are unsupported; a requested mode is not an isolation guarantee. -The Docker node uses the nested-sandbox Runtime policy. Its service account needs the -host's Docker daemon, and Core has no Docker socket. Install nodes on trusted hosts and -don't share their Docker access with untrusted users. +Docker isolates the Runtime at the outer container boundary. The daemon does not +add an inner filesystem, permission or network sandbox. The node's service account +needs the host's Docker daemon, and Core has no Docker socket. Install nodes on +trusted hosts and do not share their Docker access with untrusted users. A Core restart leaves node services and resident microVMs running. A host reboot, the end of a user manager or the loss of a running microVM is not a Core restart and is not @@ -241,9 +244,9 @@ Core records which key made each public resource write, for the console. Set `core.write_audit_retention` in `config.json` (Go duration, at least `1h`, default `2160h`) to control how long non-creation history is kept; creation ownership is kept for good. Removing keys or resources does not delete these records. See the -[query contract](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/write-audit.md); administrator mutations +[query contract](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/write-audit.md); administrator mutations have a separate -[audit log](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither +[audit log](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/contracts/agents-api/admin-api.md#monitoring-and-audit). Neither logs bodies or secrets. -[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e35d3c9eda2cdb717fd4c3627404d362cc2e4dca/docs/getting-started/operations.md) +[Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/getting-started/operations.md) diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index b5aa27c92..8d65bb82b 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -1,8 +1,8 @@ { - "revision": "e35d3c9eda2cdb717fd4c3627404d362cc2e4dca", + "revision": "281f243aa785ea25512a6157daa1d44197b9723e", "sources": { - "docs/getting-started/README.md": "4eb3d21e073b25e2e6ffb8800e13b33032104ba44d8d93ea3e59394549dc7962", - "docs/design-principles.md": "71285ff0b9a7796481b9fe42f7c4b71d0377c767157980ca6386792d9ca8951b", + "docs/getting-started/README.md": "06ee21845be57504ee780eaf063753941cb67ab6bf3fbac69319bb3bf7b64dbb", + "docs/design-principles.md": "564a9b7a79f429e4d4501d87b3f759b3ea7db76a5befc399f37df4d61f6ebbc2", "docs/web/architecture.md": "80bdb8053c06c2b2030b193aae17d5434545f9c72af50e9d3df3ade522fe25ed", "docs/getting-started/install.md": "a4f5591793ba37a7200fa69f462b7bad2a7466eebc3aa5bc2514d1ab0b15238e", "docs/configuration.md": "478b988c06a61f5555f8ec2b5fe4a9379c74e28cea4108734af2e478397905fe", @@ -10,52 +10,55 @@ "docs/getting-started/quickstart.md": "5effeffcfb343f7939910f0c1758e1492d49e7bd66c409b1819cefe67af592d7", "docs/api/README.md": "592fc2aef474e36eb760be0dd1b2269b6766c0d984f826eaf6f47711ea139387", "contracts/agents-api/execution-tools.md": "e0b61f6c0c236c362186c5f6d0ad69a16dd8a8d9afabe1329a1fe22e5f4a71e9", - "docs/api/public-agent-api.md": "18829b366bd2e49fe5beb76dc8a709bc1aa8e35d4fbfd2484a0c52b46cf75417", - "contracts/agents-api/environments.md": "4cbc2efb68e7527798b3d6836593aad5af1130416c861fc6b2b638681aea328a", + "docs/api/public-agent-api.md": "5b04e5ec9311df851376f66fc1584dd48ca123cd8c26ca63277c10b9172e9955", + "contracts/agents-api/environments.md": "bd6cdd76f33d6e2cf6cf1654713be9fb10d0273dc2a9adf0fc88d951ab7aa291", "docs/getting-started/nodes.md": "c1bc854c4798b9757bbb8cea4474a03442ba6f4c10444d5505733e8b94046038", - "docs/getting-started/self-hosted.md": "82f45d07962f49ebffbda9f2c4c4ac029296b31a6cfa048f9fcf7adca32e9de9", + "docs/getting-started/self-hosted.md": "a2eb73cd5d3930cd2851d52e9a60a45f5bd2b0f2f60bfb1da9997ef52dd9a5b5", + "docs/self-hosted-native.md": "f14c3eabf3f1b07284560ccc1e9c7fde90122df43f71fbbd0f42405a0b2b8c03", "apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "docs/web/README.md": "9fce295f6f6ff04acefe338679c1e54723e123dd0027a778202adfa9ab95b6c4", "docs/api/web-management.md": "dc79df33dade6139749752c9ce53d4e126354cfabccada3e7826de4dd20fae62", "contracts/agents-api/runtime-observability-api.md": "cd46e777fe716a7e76374f4655c96dfbad9d8be1bfca6203ed6118ab87874c2b", - "docs/getting-started/operations.md": "3f0980913fb63bc249e95e7cb41224354c750f5f6f4b4cb15007ce149d188984", - "apps/docs/scripts/guides.json": "1f6036e3cccaf5a71cdd9ea790be6f395acdf871c4552c490b6747957a10b174" + "docs/getting-started/operations.md": "f39d7eaf403ed92c6a1f37c732a19195a389784f78799b3c391654f7d394ac2e", + "apps/docs/scripts/guides.json": "1da7b90e34505a2c149410627f1463103eb638ce0dd02004abd58406fd07a114" }, "outputs": { - "content/docs/index.mdx": "d710539d3e0b5951cbf5b980142c31feda939dda9c39a38915045efd3492ecf6", + "content/docs/index.mdx": "14b35b89630c7836508ccf174217fdc1721c4dfa71370be28e5fc3a164a022d1", "content/docs/index.zh.mdx": "5bdd41893946f1d229eb4cf1599a97f2e38cb9e09722d2a69f62053376ac88a9", - "content/docs/concepts.mdx": "c7eb1d9cbddeabb8b132db37b825eed24456e7729d2784b78f3cd4f88a216fae", + "content/docs/concepts.mdx": "f97413b5c7ba6dcb136980fefa85f1d2defbe12e67fb3e2eeb3bd38335cc0002", "content/docs/concepts.zh.mdx": "c60644f395dbac5ee607cdb053249e05ec8352d28de97ef1c38d6c1e4443bbb1", - "content/docs/execution-model.mdx": "cfc5d4245a7eaf693022717dc34e004a65467d1c30fe0f57de1835a569344365", + "content/docs/execution-model.mdx": "83f44bbba5b2cece1e6f74511bbd10b0db6286782cf2ed8fd402636194477031", "content/docs/execution-model.zh.mdx": "9976a9203c6a83d49c2c753e3a8528a369078e87aeb88850654da948ac8c17e1", - "content/docs/install.mdx": "fbcc7f893e29e5a03a26cdcb5e51b71569a23df5d2a8d268bcba4eb35b836126", + "content/docs/install.mdx": "736f74a503fcb7227aa0ec047c06beba83fedb25d66a3a4f0bc6a53f82286c0a", "content/docs/install.zh.mdx": "f207607f0cfceef2012a4e772fc347ef254486327584289fee1c85414dffc907", - "content/docs/configure.mdx": "d3c6bd0c0c5fe76725a57acc1681f66bf6eb3f642cbaf296ce40d5ea45d24d01", + "content/docs/configure.mdx": "3180e9945c386f95673fc68d05adff1b9f16dc8c1dc258dff9dff1c0ba46c381", "content/docs/configure.zh.mdx": "1c4b149c332ade6f2eeca52319e3e7ccfaac3213db2fd084d66e2da517b6bbd6", - "content/docs/bootstrap-projects-keys.mdx": "da60daed343138acaf5d961b72ca670c18171371d6e791883b170fd233461e41", + "content/docs/bootstrap-projects-keys.mdx": "bc4f999166f48184b92275653e735f6727858b58ec6f78cfef5be3dcb8d750b5", "content/docs/bootstrap-projects-keys.zh.mdx": "509b4dcea4c865d6834bcaef78baa681180d48274931230f958a39e46011c88f", - "content/docs/quickstart.mdx": "0afc29fc97b166b11356343e67c4bb802b14cdc22d29d7c691683fc24f2d8a14", + "content/docs/quickstart.mdx": "6b886d5f000444a692c2cded7e4f965d40c8709414e60a3ff30311cf8d70fb7e", "content/docs/quickstart.zh.mdx": "c5f119b100b102918a45763834d0ed7fd436479fb411e16a62a408411dbcb3e1", - "content/docs/public-api.mdx": "53492062e7647ebbf39f469bb0ade267769cd08eabe73d9f42e0b9870356b4d8", + "content/docs/public-api.mdx": "7e85514c70420cfff66bd02f8c48372967828303434076fc7e000ce05a94e25b", "content/docs/public-api.zh.mdx": "fb0efb9be2fba3f4a3dc71bf053d2c7eddffbfe010f59fa5a1ae60a3f068545b", - "content/docs/agents-and-tools.mdx": "efbfee0d551c3f362f900affde6fe2721910fabc11c85841e981d0a60655a5a5", + "content/docs/agents-and-tools.mdx": "713d5e619c4bd0eec76b66f196842a2507c94b8daaa333458d64a2ca81b023cd", "content/docs/agents-and-tools.zh.mdx": "cc8d4e0ef0ea894f068f3bbd143d5ee70c6346bf98f425b1d5fe07ec3931f128", - "content/docs/sessions.mdx": "57328e8fb2aa645cb8334b6e9a8eb677e4123b5505027d2d6add5c8e6d030b38", + "content/docs/sessions.mdx": "994fa339b30c625ccb9692a226fc5e365b1e430c575ad63c7a08e65b48c39393", "content/docs/sessions.zh.mdx": "b1b1a7f8aacd9a902320f9692173ede24036124f85761cf02f23ef3ef2bcc4c1", - "content/docs/environments-and-files.mdx": "cd5ca9579ef981b9e7c2d37ebec89d9e9d15283964334d74efaaf118bbd5c09c", + "content/docs/environments-and-files.mdx": "be8395bf8568ab19a60debf014c75809d68ebc7d27991d3db0e50937b71a1fa9", "content/docs/environments-and-files.zh.mdx": "ee64577220a798f023b330c65302c14438f25a34a78c4e36144bfdb4304a3ae5", - "content/docs/hosted-providers.mdx": "de6ece49be3db64e5e5b223737673c86107e001dd540b228bb43ee2b02094a32", + "content/docs/hosted-providers.mdx": "a0ccf06394e28a79c8e98503852a8f6a7558b22928ecdaa1804b04b16ecf92b4", "content/docs/hosted-providers.zh.mdx": "d7f6115f052a09dc943f0ed516ff30a27da5847ed24d387d9d1c7e7faa006ab0", - "content/docs/self-hosted-execution.mdx": "b7d8c2424b02229527465b144f62059ca5c09dfe90474869ba57ce576d994e7f", - "content/docs/self-hosted-execution.zh.mdx": "f11d34ce30d48039376256474bc9509a508a2f6b9906f49b5cf6e396707ae5c0", + "content/docs/self-hosted-execution.mdx": "cad6d197ad1c49d06174f304a067f3e5e57b5b23c739cab6b5c6fa5e6dadd5fa", + "content/docs/self-hosted-execution.zh.mdx": "48627ebf94a278b37fb814ec8cf0186447268bb36da9982f354f88e1d6d47500", + "content/docs/self-hosted-native.mdx": "e90ab57097bfa5d654c5216139446ec19381b5325867204c3ae2d70d261d20f3", + "content/docs/self-hosted-native.zh.mdx": "686eb7c22c0bbe59c753d5e8271309d60fad43131129ba5df4fcde44610f2d78", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", - "content/docs/console.mdx": "40bc03f4ad3334c7b7d1e141827cd6eaf51144f34532007cfe7925c2d6cb2a82", + "content/docs/console.mdx": "95d3e6f3795f07809e59c88b395a0fa1c5a746e3b878b3fae84a7222f187f4f5", "content/docs/console.zh.mdx": "72f953ba46dcc95933773ac0c4dffdc0d0a499be9d9a1cb9467db2659f1fa10d", - "content/docs/admin-api.mdx": "73c8f57db4e0131e3af1442a84dd61337622188f16d5c685b18b39750a1000b7", + "content/docs/admin-api.mdx": "5807056736f190680f93f73e07f64eb496fdd4ead765914b3ea8066e857f8834", "content/docs/admin-api.zh.mdx": "07006de30df3080e54c3cd79ed0ea5337dce557aa2f11afea84edb7f92884d38", - "content/docs/observability.mdx": "3d9f0891a7efb0a35a5cd7ba9db97d364511ae1e70ceecbdb7f815957909bc4b", + "content/docs/observability.mdx": "b9bd7151b204c36ceaf4d200a4a6420ecc8b8e23eefa44253474f9c947e49b30", "content/docs/observability.zh.mdx": "9574b3412a5754f41ab2d3a16062ac1cad06766be481a8e6bff1b84acbde7e7e", - "content/docs/troubleshooting.mdx": "a87de7639962d7830a2c5905d69439eb17f762f87d7cdd4df1dbedc71278e7a9", + "content/docs/troubleshooting.mdx": "fe388146f73b46f4f749ad399c8d24a96111d084a4f2ed20b52f78e6984fae5c", "content/docs/troubleshooting.zh.mdx": "6d8b4668e8c371120a9275daabbebc0d1ecf21a42ba32beeb92853c14b5a459a" } } diff --git a/apps/docs/openapi/core-api.yaml b/apps/docs/openapi/core-api.yaml index e27a325ae..bf40279d6 100644 --- a/apps/docs/openapi/core-api.yaml +++ b/apps/docs/openapi/core-api.yaml @@ -5889,14 +5889,9 @@ components: items: type: string type: array - system: - items: - type: string - type: array required: - npm - python - - system type: object v1.EnvironmentTemplate: properties: diff --git a/apps/docs/openapi/public-api.yaml b/apps/docs/openapi/public-api.yaml index 090594dd8..8cd200c67 100644 --- a/apps/docs/openapi/public-api.yaml +++ b/apps/docs/openapi/public-api.yaml @@ -575,7 +575,7 @@ paths: tags: - Environment Templates post: - description: Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, system/npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs and workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. Network policy rejections return invalid_request_error with a null param. No compute is allocated. Exact hosted error/retry semantics remain unverified. + description: Saves tenant-owned hosted configuration. Supports nullable name, enabled/disabled or exact-domain restricted network, initial inline/file_id files, confidential env, ordered setup_commands, npm/Python packages inline/referenced Skill ZIPs, Plugin ZIPs and workspace-contained capability directories. Omitted/null network defaults to enabled. Restricted network requires 1–100 exact ASCII hostnames; other host forms and populated unsupported installations are rejected before persistence without echoing input. Network policy rejections return invalid_request_error with a null param. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. No compute is allocated. Exact hosted error/retry semantics remain unverified. parameters: - schema: type: string @@ -864,7 +864,7 @@ paths: tags: - Sessions post: - description: The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. + description: The optional Core model_provider bundle resolves from the Session override, saved Agent defaults, then, for openai_hosted and none, the deployment default of the resolved harness; self_hosted never uses the deployment default and none accepts only it. openai_hosted and self_hosted Sessions that resolve no bundle return 400 model_provider_required with param x_agents_core.model_provider before any write. Core encrypts and freezes the resolved bundle; later Agent or deployment default edits and same-key retries cannot change it. Keys are never returned. Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with service origin (omitted or null on HTTP transport is saved as service), native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer or OAuth credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Session reads, lists and event snapshots show that implicitly selected credential ID in a null or omitted credential_id, also after the credential is deleted; anonymous selections stay null and the stored caller intent is unchanged. After the input requirement and before any write, a credential_id without vault_ids, one outside the attached Vaults (one message for missing, foreign and unattached IDs) or one for another server_url returns 400 invalid_request_error, and several implicit matches return 409 conflict_error. Missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and native OAuth login remain unsupported. The self_hosted profile uses a qualified native harness, a clean absolute workspace_directory and optional absolute local capability_directories prepared by Runtime, with optional non-deferred function tools and HTTP MCP using service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null; non-string values and limit violations return invalid_request_error with a metadata or metadata. param. The inline agent uses the Agent create configuration validation with agent.-prefixed params, reported before the input requirement and saved-Agent lookup; saved configurations with conflicting tools or schema roots reject admission with the same errors, and execution limits keep unsupported_or_invalid_configuration. Hosted network policy rejections return invalid_request_error with a null param. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none and qualified openai_hosted also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Initial input is required for none and for streamed creation outside self_hosted. Omitted/null input remains valid for non-streaming hosted and self_hosted creation. With stream=true, returns live Session events starting with the committed creation snapshot and closes right after the first agent.session.idle recorded when a Turn ends or an input reservation stops being pending, or any agent.session.failed, without sending later events. A creation that admitted nothing closes after the snapshot; a settlement that records no event closes after events up to the cursor read with a settled Session projection. Required actions keep it open; disconnect does not cancel execution. The GET events stream remains live-only. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; new hosted inline requests also freeze caller intent before deployment defaults resolve; unrelated non-hosted inline retries preserve resolved/default equivalences, and their resolved hash leaves out any deployment default. Provider keys enter retry hashes only as fingerprints keyed by the credential key. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. A same-key stream=true retry of an existing creation returns 201 with no events and closes at once; retry with stream=false or use the GET events stream to recover. Claude SDK on none and Core-managed Docker openai_hosted supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions. Hosted execution reuses native workspace tools and Files/Artifacts; Skills, Plugins, capability directories, HTTP MCP, Subagent and tool_search combinations remain unqualified, including inherited template contents. Other non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text or successful inline PNG/JPEG results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. System dependencies must be preinstalled in the sandbox image or template, or on the host machine; packages.system is rejected. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. With a template reference, omitted/null files, env, packages and setup_commands inherit. Non-null files and command lists replace; env overlays by key; each package manager inherits on omission/null and otherwise replaces its list. Empty lists clear their selected field. Tenant-owned environment_template_id references inherit omitted/null network and allow only narrowing overrides. Inline hosted network:null retains the enabled default; updating a Template with network:null resets its saved policy to enabled. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill, Plugin and capability-directory list omission/null inherit; a non-null list replaces, including empty-list clearing. Omitted/null Skill version selectors resolve the default version. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Explicit web_search mode disabled and programmatic_tool_calling enabled false use frozen common Runtime controls. Enabled forms, including those saved on an Agent, remain unqualified and reject before any write unless the Session replaces tools. Omitted programmatic configuration preserves native behavior, a documented difference from the official default-on behavior. Other combinations remain unqualified; see the operation coverage. parameters: - schema: type: string @@ -3991,14 +3991,9 @@ components: items: type: string type: array - system: - items: - type: string - type: array required: - npm - python - - system type: object v1.EnvironmentPackagesInput: properties: @@ -4014,12 +4009,6 @@ components: type: string type: array - type: 'null' - system: - anyOf: - - items: - type: string - type: array - - type: 'null' type: object v1.EnvironmentTemplate: properties: diff --git a/apps/docs/openapi/sources.json b/apps/docs/openapi/sources.json index d9d0f0b17..cfb8468c3 100644 --- a/apps/docs/openapi/sources.json +++ b/apps/docs/openapi/sources.json @@ -1,14 +1,14 @@ { "sources": { - "contracts/agents-api/openapi.yaml": "e54687e7b3c8cba023432fc5b8ec56ee4e1312f840b8f355b5ca2ddb41917d1d", - "contracts/agents-api/core.openapi.yaml": "7801fc77259893815fe9cdcb404f045b375711b9b075eab2b2ed8c5dba55e24b", + "contracts/agents-api/openapi.yaml": "1433d6ef0d5c4b3598ae2c906faacce4eeefea37bcc125243ab25d04f61e620b", + "contracts/agents-api/core.openapi.yaml": "3e02dacf7047e1179d334cc6fcb5110e9968410571c8d1732bc9cd0681a68939", "contracts/agents-api/runtime.openapi.yaml": "23852205d3b45892f2de9ecf3d52518c0dd0808962c0c512f219b565aa7ccef2" }, "outputs": { "content/docs/api-reference/agents.mdx": "1f7697959e9c52c9d24fbe70e116e807f49912b9a27ac638b86782e2f326af44", "content/docs/api-reference/environments.mdx": "6a9e08a67479a745983c45b9711137617c13fdf7599a64533997b00a26ac7282", - "content/docs/api-reference/environment-templates.mdx": "c1daeaec159aea40b0aa5ed72b1e77ac1715471431b558d0d89e05bfe9ad8945", - "content/docs/api-reference/sessions.mdx": "9709476af57c645f441bb287c3455ee09dd1fd2d1043558439dc6c32676a3c82", + "content/docs/api-reference/environment-templates.mdx": "68df9276e858e525e15bb88af624fc8adee8bcdcc03a788faaff56b560fd038c", + "content/docs/api-reference/sessions.mdx": "7c12ac70dfdfef86b5d08b9151a7fafdbec6df4848760c81f059b6a331db81a1", "content/docs/api-reference/artifacts.mdx": "8df8d008126b49b1df88f28a676c4b237ccffc45a470b35ec30eab2daf1c0f7f", "content/docs/api-reference/events.mdx": "83d7f56a788d073e0b19f0bc9197f39ec364375b9cc3a7ec69b79b54bc71e3eb", "content/docs/api-reference/items.mdx": "fa4d248ac5623beb3f00a4ebb5a6c4ff477865ebee351e0c543a970a7ec3654d", @@ -18,7 +18,7 @@ "content/docs/api-reference/skills.mdx": "774ea05a7a8250fe5af5164388b98dafca99d56dc2eae686e8f628acb4e7c10b", "content/docs/api-reference/vaults.mdx": "d80eade1c2f8100030866abbb0fb6b7a09c94250a859c97cdf1abce32fdfdf1e", "content/docs/api-reference/credentials.mdx": "f129a51f74a3c7021c016600518e2350023f8d29571666ac5d64b7584cd7c7d0", - "openapi/public-api.yaml": "17ae24606c26be371ffa0c90904fdba3deac5a744ac985034a68aa35873b9a94", + "openapi/public-api.yaml": "7228ef80c91b6b736dbfbf80d078ab3c56538e7ba91640a4dfa3c30fd9127aa3", "content/docs/api-reference/meta.json": "56bf1dc145ccc8adde38c5f956f1a06f18ef18b646122250d01a294c516f8f83", "content/docs/api-reference/index.mdx": "9fd6b1c54149874d2999235067c101326f9e8a2e707a70b6672c81eb3ebfe63f", "content/docs/api-reference/core/core-administration.mdx": "5fc3ada51d2190a141f13738151e4d513390cc3bd66ce6f5013994bda68569b0", @@ -40,7 +40,7 @@ "content/docs/api-reference/core/vaults.mdx": "12d0aac96cd4a3472ef4eaa287f7945f78862017956e79bfc3ed0ef8e772e78a", "content/docs/api-reference/core/credentials.mdx": "27d586b9a8fd738b9947802f32c80458195370252886bc26abedccf2125ee981", "content/docs/api-reference/core/sandbox-manager.mdx": "6be7c52e057365949f0247c18c710561d680847a955f78f1bbe57825d4d5f549", - "openapi/core-api.yaml": "7079694e447c0783f85aeb3b73f23af68ebb066540c67251bbae34e7e4bd8e62", + "openapi/core-api.yaml": "2ae4043550e953945fc184d2f82149ea9fe4d5f693f5c8bd31d7edb87e8a0c4c", "content/docs/api-reference/core/meta.json": "588b163ed2de2100b0f8376688274f80698a6bf6ac037eef56d17c93fa8a5062", "content/docs/api-reference/core/index.mdx": "ddf0a8d87a6c21761beac703a32f2a40486a1c74abb341bd726f40c44a866405", "content/docs/api-reference/machine/sandbox-node.mdx": "5b367a08d43cea82686345d2ba1c75cc90627a7b2970fe42a0df9d77fb3e1e80", diff --git a/apps/docs/scripts/generate-guides.mjs b/apps/docs/scripts/generate-guides.mjs index f403d7a78..b09e09273 100644 --- a/apps/docs/scripts/generate-guides.mjs +++ b/apps/docs/scripts/generate-guides.mjs @@ -8,7 +8,7 @@ const app = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const repo = path.resolve(app, '../..') const guides = JSON.parse(fs.readFileSync(path.join(app, 'scripts/guides.json'), 'utf8')) const routes = new Map(guides.map(g => [g.source, g.slug === 'index' ? '/' : '/' + g.slug])) -const sourceRevision = 'e35d3c9eda2cdb717fd4c3627404d362cc2e4dca' +const sourceRevision = '281f243aa785ea25512a6157daa1d44197b9723e' const sourceURL = relative => `https://github.com/MiniMax-AI/parsar-core/blob/${sourceRevision}/${relative}` const digest = text => crypto.createHash('sha256').update(text).digest('hex') function mdx(text) { diff --git a/apps/docs/scripts/guides.json b/apps/docs/scripts/guides.json index 0384a6a69..6b7b7ebf2 100644 --- a/apps/docs/scripts/guides.json +++ b/apps/docs/scripts/guides.json @@ -88,7 +88,14 @@ "source": "docs/getting-started/self-hosted.md", "title": "Self-hosted executors", "description": "Connect a user-owned Runtime to its Environment with a restricted executor credential.", - "zh": "self_hosted 执行器由用户拥有,Core 不管理它的容器。通过 Web 为对应 Environment 签发 executor 凭据,安装器通过隐藏输入或私有文件读取凭据;不要把凭据放入命令行。" + "zh": "self_hosted 执行器由用户拥有,通过 Web 为对应 Environment 签发凭据。Linux、macOS 和 Windows 使用同一 daemon 协议;daemon 以启动账户权限运行,隔离由外层环境负责。凭据通过私有文件提供,不放入命令行。" + }, + { + "slug": "self-hosted-native", + "source": "docs/self-hosted-native.md", + "title": "Native daemon installation", + "description": "Install and operate a self-hosted daemon on Linux, macOS or Windows.", + "zh": "使用当前平台的 oac-daemon 安装、启动和停止自托管 Runtime。系统依赖需提前安装,运行时不提权。Windows 已通过原生 CI 自动测试,尚未人工实机验收。" }, { "slug": "console", diff --git a/apps/docs/scripts/verify-docs-facts.mjs b/apps/docs/scripts/verify-docs-facts.mjs index 0265012a0..d9173603a 100644 --- a/apps/docs/scripts/verify-docs-facts.mjs +++ b/apps/docs/scripts/verify-docs-facts.mjs @@ -17,7 +17,7 @@ const source = slug => fs.readFileSync(path.join(app, 'content/docs', slug + '.m for (const token of ['/v1', '/core/v1', '/api/v1', 'Project API key', 'Core key', 'executor']) assert.ok(source('public-api').includes(token), 'Credential matrix omits ' + token) for (const token of ['config.json', 'oac apply']) assert.ok(source('configure').includes(token), 'Configuration guide omits ' + token) for (const token of ['oac-node', '~/.oac/nodes']) assert.ok(source('hosted-providers').includes(token), 'Node guide omits ' + token) -for (const token of ['oac-selfhost', '~/.oac/self-hosted']) assert.ok(source('self-hosted-execution').includes(token), 'Executor guide omits ' + token) +for (const token of ['oac-daemon install', 'OAC_RUNTIME_HOME', 'Linux, macOS and Windows']) assert.ok(source('self-hosted-execution').includes(token), 'Executor guide omits ' + token) // Installation policy must agree in both generated guides and reviewed reading notes. for (const [slug, tokens] of [ ['troubleshooting', ['In-place version upgrades, downgrades and historical conversions are not supported.', '.oac.lock']], diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 4c48e7b3b..30d9cf2c7 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -203,7 +203,7 @@ user-managed enrollment remain outside this qualification. | Area | Missing or unverified scope | | --- | --- | | Subagents / multi_agent | Six reads and same-child recovery have three-harness Docker evidence; optional native operations, live child progress, full lifecycle/interactions and tool combinations remain explicit gaps | -| Environment Templates | Unsupported restricted hostname forms and exact hosted errors remain gaps. Referenced null network and capability-list selection follow [qualified inheritance rules](template-null-selection.md). Template-reference env/files/commands/packages composition follows [qualified field rules](environment-templates.md#template-and-inline-configuration-composition). CRUD/list, files, env/setup/system/npm/Python, inline/referenced Skills, Plugins, workspace capability directories and Session references have recorded coverage. Environment Plugin MCP transport and placement limits are [listed separately](environment-templates.md#environment-origin-mcp-plugins) | +| Environment Templates | Unsupported restricted hostname forms and exact hosted errors remain gaps. Referenced null network and capability-list selection follow [qualified inheritance rules](template-null-selection.md). Template-reference env/files/commands/packages composition follows [qualified field rules](environment-templates.md#template-and-inline-configuration-composition). CRUD/list, files, env/setup/npm/Python, inline/referenced Skills, Plugins, workspace capability directories and Session references have recorded coverage. System-package and inner-isolation evidence is historical; current `packages.system` rejects and the daemon adds no sandbox. Environment Plugin MCP transport and placement limits are [listed separately](environment-templates.md#environment-origin-mcp-plugins) | | Input and configuration | Non-text initial input, broader content/configuration unions and reasoning/verbosity combinations; [structured output](structured-output.md) has qualified Claude function profiles on none and Core-managed Docker openai_hosted, with other combinations remaining gaps | | Tools and interactions | [Deferred discovery qualification](tool-search.md), other tool types, effective tool-set enforcement and result/cancel publication ordering; MiniMax public functions and service-origin MCP remain unsupported | | Vault and Credentials | Archive semantics, in-flight token withdrawal and exact hosted selection/error behavior; static/OAuth CRUD, replacement and scoped dispatch-time refresh are implemented (see credential guide for qualification) | @@ -490,15 +490,17 @@ model provider in the request or through a saved Agent; deployment defaults appl to `openai_hosted` and `none`, never to `self_hosted` ([model execution](model-execution.md)). Service-origin HTTP MCP is rejected on `self_hosted` and hosted local placements. This does not remove separately qualified Environment Plugin MCP. The [Docker lifecycle](environments.md#basic-public-docker-hosted-profile) retains -workspace Files/Artifacts, cancellation and recovery with native isolation. +workspace Files/Artifacts, cancellation and recovery. Managed isolation belongs to +the outer Environment; native tools use the starting account's permissions. Configure immutable Runtime images explicitly: [Codex](../../services/agents-api/deploy/codex/README.md), [Claude](../../services/agents-api/deploy/claude/README.md), [MiniMax](../../services/agents-api/deploy/mcode/README.md). [E2B packaging](../../services/agents-api/deploy/e2b/README.md) reuses the Runtime with the official SDK; the user owns provisioning, renewal and destruction. -The shared initialization path supports env/setup and system/npm/Python packages; -see the [evidence and limits](environment-templates.md#verification). Remaining +The shared initialization path supports env/setup and user-directory npm/Python packages; +`packages.system` rejects explicitly and system dependencies must be preinstalled. +See the [evidence and limits](environment-templates.md#verification). Remaining unsupported startup installations, unqualified restricted hostname forms and hosted service-origin HTTP MCP remain outside these accepted profiles. Environment-origin MCP Plugins have a separate [Docker qualification and transport matrix](environment-templates.md#environment-origin-mcp-plugins): diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index 36cb3de15..887ce6620 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -101,8 +101,10 @@ apply to `self_hosted` Sessions, and creation without a provider fails with 400 `model_provider_required`. Core freezes the bundle in the Session's encrypted snapshot and sends it only over the connection of the executor enrolled for this Environment with a current credential of the Session creator's principal. The -executor keeps it in the Runtime's native harness home, which tools and public -Files cannot read; the executor host's owner can. Revocation does not erase a +executor keeps it in the Runtime's native harness home. Public Files remains +scoped to the authorized workspace, but native tools and the host owner can read +whatever the starting account can access. The daemon provides no same-user +credential isolation. Revocation does not erase a bundle already delivered. A saved Agent's provider key is delivered to the executor of every `self_hosted` Session created with that Agent in the Project, so anyone who can create `self_hosted` Sessions in the Project and run an executor diff --git a/contracts/agents-api/environment-templates.md b/contracts/agents-api/environment-templates.md index f548fc5f5..34a09ad82 100644 --- a/contracts/agents-api/environment-templates.md +++ b/contracts/agents-api/environment-templates.md @@ -14,11 +14,12 @@ and five-operation SandboxProvider path as inline configuration. CRUD/list works without an execution deployment. - Optional nullable name, preserved verbatim, with a local 1–256 Unicode character bound. Network supports `enabled`, `disabled` and exact-host `restricted`; omitted/null create network - defaults to the pinned enabled policy. Update omission preserves; supplied name + defaults to the pinned enabled policy. These are configuration values, not daemon + network enforcement; unsupported execution combinations reject. Update omission preserves; supplied name or network replaces, with null clearing name or resetting network. - Empty/null installation fields retain empty defaults. Responses contain safe metadata and never `env`, `setup_commands` or inline file data. Initial files are - supported as described below, together with inline/referenced Skills, env, ordered setup and system/npm/Python packages; remaining populated installations reject explicitly. + supported as described below, together with inline/referenced Skills, env, ordered setup and npm/Python packages; `packages.system` rejects explicitly and system dependencies must be preinstalled. - Listing uses `after`, `limit` (default 20; 0 is treated as 1 and larger values as 100), and `order` (default `desc`). Creation timestamp plus ID supplies stable local ordering. Missing/foreign IDs @@ -37,7 +38,7 @@ from openai import OpenAI client = OpenAI(base_url="https://your-core.example/v1", api_key="your-project-key") template = client.beta.agents.environments.templates.create( - name="Python workspace", network={"access": "disabled"}, + name="Python workspace", network={"access": "enabled"}, env={"APP_MODE": "analysis"}, packages={"python": ["packaging==26.0"]}, setup_commands=[{"command": "mkdir -p /workspace/outputs"}] ) @@ -57,8 +58,9 @@ an absolute destination inside `/workspace`: `inline` with standard-base64 `data `file_id` referencing a project-owned Files API upload. The guide's limits are 50 initial files, 5 MiB per inline file, 10 MiB total inline content, and 50 MiB per referenced file. Session/Template JSON requests allow 16 MiB for the base64 envelope. -Paths must be canonical, distinct and stay within the workspace; symlinks are not -followed. A failed install never starts native execution. +Paths must be canonical, distinct and stay within the logical workspace. Runtime +anchors the operation to its bound workspace; this is API path scope, not a +restriction on native tools running as the same user. A failed install never starts native execution. Configure `OAC_CREDENTIAL_KEY_FILE` with the existing execution-service base64 32-byte encryption key. Template writes and Session resolution need it; @@ -166,17 +168,18 @@ content after template update/deletion. Core transfers frozen Skill archives over the authenticated Runtime connection. Runtime's shared parser installs them under its operator-bound capability root -(default `/environment/initialization/capabilities/skills/` for each Skill) -before setup and native execution; +(`skills/` below the configured capability directory) +before setup and native execution. The same daemon handles initial files, configuration, packages and setup; Providers -only bootstrap it and manage compute resources. Setup and native tools can read -that tree but cannot write it. Completed recovery loads the protected installation +only bootstrap it and manage compute resources. Setup and native tools run with +the launching user's permissions; snapshot file modes are an integrity hint, not +protection from that user. Completed recovery loads the recorded installation without reinstalling. The common Runtime resolves installed metadata and paths before invoking the native executor factory. Codex registers native extra roots, Claude creates its own explicit Skill plugin envelope, and MiniMax points its native user-global catalog at the shared root. MiniMax retains disabled unrestricted built-in tools and uses its existing -isolated workspace tool worker. No Provider or model/tool loop is added. +workspace tool worker with the launching user's permissions. No Provider or model/tool loop is added. Codex nested `SKILL.md` discovery, `agents/openai.yaml` native dependency configuration and Claude inline/fenced shell preprocessing are not qualified in this batch and explicitly fail adapter @@ -202,7 +205,7 @@ Hosted Template and inline `capability_directories` accept clean absolute paths within `/workspace`; self-hosted local selections have their [separate public input](environments.md#runtime-capability-preparation). Initial files and setup can populate hosted directories. Runtime snapshots them after -setup and writes the shared protected `installed.json`, bound to the Session, +setup and writes the shared `installed.json`, bound to the Session, Environment and source-selection digest. Recovery loads those installed bytes even if a source directory changes or is removed; a new Session captures its own snapshot. Partial or conflicting installation fails rather than silently recapturing sources. @@ -270,13 +273,13 @@ model or daemon variable. Public `env_http_headers` remains unsupported; an adap may privately use native reference fields without changing public literal values. Caller-owned env remains available to caller code under the normal env contract. -Stdio executes through the existing Runtime sandbox and a fixed static helper, -which applies selected env/cwd and directly execs the package command. A small -single-threaded launcher monitors the native parent process with Linux pidfds; -bwrap retains its parent-death protection against that stable launcher. This avoids -killing MCP servers when Codex recycles a spawning thread. Native MCP owns the -transport; the launcher does not parse or forward protocol messages. Dependencies must be installed in the ordinary -Environment initialization flow. The current implemented transport boundaries are: +Stdio executes through the common daemon helper, which resolves the frozen package +and explicitly selected env/cwd, then launches the declared command. Unix replaces +the helper process; Windows forwards stdio within the owned process tree. Native +MCP still owns the protocol. Process groups and Windows Jobs handle cancellation, +not isolation. There is no Python launcher or inner mount/network sandbox. System +dependencies must be preinstalled; npm/Python dependencies may use the ordinary +Runtime initialization flow. The implemented transport boundaries are: | Adapter | Environment MCP implementation | | --- | --- | @@ -341,6 +344,10 @@ complete upstream protocol compatibility. ### Composed initialization acceptance +This section records the historical September 21 fixture and binaries. Its +system-package and inner-isolation checks are not current support requirements; +`packages.system` now rejects and tools run with the starting user's permissions. + `services/agents-api/tests/official_environment_composition.py` combines the existing public fixtures in one enabled-network configuration: inline/referenced files, caller env, system/npm/Python packages, ordered setup, an uploaded Skill @@ -366,7 +373,7 @@ initial Turn alone does not pass this composed workflow. The final Docker composition passed on 2026-09-21 with SDK 3.13.0/raw HTTP, independent Core/PostgreSQL, Kimi K3 (Codex/Claude) and MiniMax-M2.7. Core and the unchanged Codex/MiniMax images are from main `8eb3c089`; the Claude image includes -the native-prefix correction described under System packages. All four public +the historical native-prefix correction recorded by that batch. All four public runs completed with zero cleanup errors: | Profile | Result under `~/.parsar/remediation/20260921/template-composed-acceptance/` | @@ -399,60 +406,41 @@ Templates workflow, not all upstream semantics, transports or Provider combinati ## Packaged Runtime initialization contract Template handlers and stores resolve public configuration without choosing a -harness, native path or compute backend. The common runner depends on neutral +harness, native path or compute backend. The common runner uses neutral Environment/Session identity and an authenticated Runtime peer. It sends initial -files, configure/system/npm/python/setup operations, Skills, Plugins and finalization +files, configure/npm/python/setup operations, Skills, Plugins and finalization through `runtime_prepare`, then uses the same daemon for execution. Providers own -placement, creation, daemon bootstrap, inspection, renewal and reclamation; they do -not run Core initialization commands. Harness-specific behavior stays in adapters. - -The protocol accepts logical `/workspace` file and working-directory addresses and -portable absolute source selections. Physical paths, executable selection and local -access checks belong to Runtime. The current implementation uses these existing -Linux packaging requirements; they are not public Template fields or requirements -for future Runtime implementations on other platforms: - -- `/workspace` is the logical workspace. The packaged Linux default binds it to - `/environment/workspace`; `/environment/staging` is private staging. -- The daemon invokes the fixed trusted, fd-anchored initial-file installer and the - shared atomic writer. These helpers do not select a harness or invoke native - model tools. -- Confidential content travels in bounded protocol frames and private helper stdin. - The daemon verifies native completion before sending a typed Runtime receipt. - Unknown effects use the existing allocation cleanup path rather than replay. -- Runtime adapters own native configuration. Initialization cannot access a - harness's private history, model credentials or native tool protocol. - -New Providers bootstrap the same Runtime contract. New harnesses reuse common -preparation, with native differences confined to their adapters. Neither addition -changes template validation, storage or resolution. This interface separation does -not claim a non-Linux installer, layout or isolation implementation. -The trusted `/usr/local/bin/oac-runtime-initialize` receives a bounded -version-1 JSON operation on stdin from the Linux daemon. This private helper format -is not the Core-to-Runtime protocol. It configures read-only tool env under -`/environment/initialization`, installs packages under `/environment/packages`, -and runs ordered commands through distro bubblewrap. The fixed mount/process map -excludes daemon credentials, native history and staging. User values are applied -inside isolation, never to the launcher. Receipt and process exit must both confirm -completion; child output is discarded because it can contain secrets. A failed step -that ran inside that isolation (a setup command or a package manager) adds only its -integer `exit_code` to the failed receipt; see -[Initialization failure](#initialization-failure--september-23). - -Runtime receives a `tool_environment` execution flag, without template identity or -provider information. Adapters validate the common files and apply them in their -native tool sandbox: Claude uses its native Bash hook, Codex its managed Bash hook, -and MiniMax its isolated native-tool worker. Native transports remain unchanged. -Files reads do not require initialized tool configuration. Packages and setup keep -enabled provisioning network access; requested Session network restrictions apply -to native execution after setup. Docker setup requires -the existing nested-sandbox deployment profile for every harness; E2B supplies -the same Runtime layout and kernel isolation. - -Codex 0.153.4 can execute an original command when a native hook process fails. -The adapter verifies the required trusted managed hook before preparation and -stops the Turn on an observed failed hook. Earlier command effects may already -exist; this is not an atomic hook-failure prevention guarantee. +placement, creation, bootstrap, inspection, renewal and reclamation; they do not +run Core initialization commands. Harness differences stay in adapters. + +One Go preparation implementation serves Linux, macOS and Windows. Initial files +and setup working directories use logical `/workspace` addresses; physical paths +and executable selection belong to Runtime. Initial files use the shared atomic +writer, including replacement, while public Files creation retains its separate +semantics. Transfer input and stored tool configuration are bounded. Process +ownership and I/O settle before a typed receipt; unknown effects are never replayed. + +Initialization and packages default under `OAC_RUNTIME_HOME`. Operators can select +`OAC_RUNTIME_INITIALIZATION_DIRECTORY` and `OAC_RUNTIME_PACKAGE_DIRECTORY`; +packaged Linux images set `/environment/initialization` and `/environment/packages`. +`OAC_RUNTIME_TOOL_ENV_FILE` selects explicit tool-variable JSON. Runtime does not +overwrite an existing configuration. No Python initialization wrapper, system-root +seed or managed shell hook is shipped. + +Setup uses Bash, with Git Bash on Windows; a missing dependency fails explicitly +rather than substituting a different shell. npm uses a local prefix and Python/pip +a local target under the Runtime package directory. Node/npm and Python/pip must +already be installed. No automatic system-package installation, sudo or daemon +privilege increase occurs. + +All commands use the launching user's permissions and host network. The daemon +provides no filesystem, permission or network sandbox, including on Linux. Managed +isolation belongs to the outer Environment. Configured user variables are applied +to the command, not automatically inherited from daemon credentials, but tools may +read any local state the same user can read. Output is discarded rather than +included in failure diagnostics. Only a confirmed command failure can report its +bounded integer exit status; cancellation or unknown effects retain a generic +reason. Files reads retain their separate authorization and readiness requirements. ## Initialization failure — September 23 @@ -476,7 +464,7 @@ The reason names only the failed step and its exit status: | --- | --- | | Setup command `i` | `Failed to provision environment: script "setup_commands[i]" failed with exit code N` (observed) | | Python packages | `... script "Python package installation" failed with exit code N` (observed label; the official reason appends raw pip output, Core never does) | -| npm or system packages | `... script "npm package installation"` / `"System package installation"` `failed with exit code N` (unverified) | +| npm packages | `... script "npm package installation" failed with exit code N` (unverified label; system packages now reject before initialization) | | Initial file write or Runtime Skill preparation with a confirmed failure | `Failed to provision environment: initial file installation failed` / `Skill installation failed` (unverified) | | Anything else | `Failed to provision environment: initialization did not complete` | @@ -484,9 +472,8 @@ The reason names only the failed step and its exit status: missing or malformed receipts, Plugin installation and directory finalization, bootstrap rejection and Core restart during initialization. All initialization operations return typed Runtime `rejected`, `failed` or `unknown` outcomes. The -Linux daemon confirms the trusted helper's process exit and private receipt before -reporting completion or failure. Only `failed` can carry a bounded `exit_code`; -For setup and package steps, Core projects a confirmed nonzero status, never +daemon confirms process exit and I/O settlement before reporting completion or +failure. Only `failed` can carry a bounded `exit_code`. For setup and package steps, Core projects a confirmed nonzero status, never process output; zero or missing status retains the generic reason. Only a confirmed Skill preparation failure gets the Skill label. Plugin/finalization failures and uncertain effects retain the generic reason. The Store composes the reason from a fixed label and integers, so commands, env values, package names, @@ -496,37 +483,19 @@ not retried and later steps do not run. ## System packages -`packages.system` accepts package names for the Runtime's Debian apt repositories, -in both templates and inline hosted configuration. Real apt/dpkg installs packages -and runs package scripts before npm/Python dependencies and setup commands. Template -updates replace the package object; omission preserves it and null clears it. -Referencing Sessions freeze the existing template configuration. - -Each Runtime image supplies a seed built before daemon, harness and credential -installation. The common initializer extracts it into -`/environment/packages/system` under the unprivileged Runtime identity. Matching -package databases and base tools are included; private Runtime files and native -history are absent. Installation uses its own process/filesystem view. Package -output is not exposed in public diagnostics. A failed or uncertain installation -fails the Environment through the existing lifecycle and is not replayed. - -Setup and native shell tools enter this installed root read-only, with the same -workspace and adapter-owned temporary storage. Trusted launchers stay outside the -package-controlled root. Codex uses its managed hook, Claude its full-shell prefix, -and MiniMax Code its existing tool worker; native execution and cancellation retain -their existing owners. Core carries only the required initialized-tool condition. -Files operations retain their existing authorization and initialization boundary. -The Claude image includes an adapter-specific prefix dispatcher: its exact Runtime -MCP entry starts directly, while complete native Bash commands enter the tool root -unchanged. This preserves native sandboxing and cwd receipts without wrapping MCP -inside a second tool root or introducing Core engine branches. - -This is a single-UID tool environment, not a full operating-system service manager. -Packages requiring additional Unix identities, privileged operations or background -system services may fail explicitly. There is no apt mirror, package cache, arbitrary -root installation or package retry mechanism. Existing operation and initialization -time budgets apply. New harnesses implement the same Runtime contract rather than -adding template-specific business logic. +`packages.system` is unsupported in both Templates and inline configuration. +Supplying the member, including null or an empty list, returns an explicit +validation error with param `packages.system`: preinstall system dependencies in +the sandbox image/template or on the host machine. The field is not silently +ignored, inherited into a privileged action or translated to apt. + +The daemon runs as its starting account. It does not install system packages, +request sudo or elevate permissions. Operators build managed images/templates +with required system dependencies; self-hosted users prepare them before execution. +A missing executable or library fails the operation that requires it. npm/Python +packages and setup retain the common user-directory initialization path. +Historical system-package qualification below applies only to its old binaries; +there is no system-root seed, package-root launcher or upgrade compatibility path. ## Restricted network policy @@ -539,14 +508,13 @@ Public reads preserve supplied spelling, order and duplicates. Effective native comparison uses a separate lowercase, deduplicated copy; updates cannot change existing Session snapshots or retry intent. -Provider bootstrap and preparation carry the same frozen policy. Runtime rejects -mismatches and missing hosted execution policy. Read-only workspace access retains -its existing minimal prerequisites. Core owns no native proxy configuration: -Codex uses its managed network ceiling, while Claude and MiniMax use native sandbox -allowlists. Provisioning remains a separate phase before runtime restrictions. -Current qualification evidence must cover real Docker native execution, permitted -and denied hosts, credential isolation, Files/Artifacts, cancellation and retained -policy on recovery; resource tests alone do not establish execution compatibility. +The daemon does not enforce `disabled` or `restricted` network modes. Stored +configuration and successful Template CRUD do not establish execution support. +An execution combination must reject unless its outer Environment provides and +qualifies the requested restriction; it must not silently run unrestricted. +The normal native self-hosted combination uses the host's existing network. +Historical native-proxy or sandbox allowlist results below do not qualify current +outer enforcement. Read-only Files retains its separate minimal prerequisites. ## Explicit gaps and evidence boundaries @@ -567,19 +535,18 @@ Template updates replace each supplied field; omission preserves it and null cle it. Referencing Sessions use the composition rules below; these differ from Template.update replacement rules. -Files and resolved Skills are installed first, followed by system, npm/Python packages and ordered commands; +Files and resolved Skills are installed first, followed by npm/Python packages and ordered commands; the default cwd is `/workspace`. One command or package operation has the existing two-minute local budget, within the thirty-minute initialization budget. No command is retried after unknown effects. Completed setup never runs on reconnect. Package dependencies are available to native tools across working directories. -System packages use the isolated tool root described above. +System dependencies must already be installed; `packages.system` rejects. The [update Reference](https://developers.openai.com/api/reference/python/resources/beta/subresources/agents/subresources/environments/subresources/templates/methods/update) defines runtime network as post-setup and packages as preceding that policy. -Initialization therefore uses its isolated provisioning network; native tools -apply the requested enabled/disabled/restricted policy afterward. Allowing setup internet is -an implementation inference from that phase boundary, not an explicit upstream -guarantee. Env values are intentionally readable by Agent code; they must not +Runtime initialization uses the host's existing network. The daemon does not +provide the reference's later restricted-network enforcement. Unsupported execution +combinations must reject; the phase ordering is not an isolation guarantee. Env values are intentionally readable by Agent code; they must not appear automatically in public metadata or initialization diagnostics. The [current Template reference](https://developers.openai.com/api/reference/python/resources/beta/subresources/agents/subresources/environments/subresources/templates) @@ -592,14 +559,16 @@ unsupported hostname forms still reject. This is not full protocol compatibility ## Template and inline configuration composition The pinned Session description applies the template before inline configuration. -Owned official API probes on 2026-09-23 establish the following narrower behavior: +Owned official API probes on 2026-09-23 established composition behavior. The +current implementation applies it only to supported fields; the table below +excludes the now-unsupported `packages.system` member: | Session field | Omitted or null | Non-null inline value | |---|---|---| | `env` | Inherit template keys | Overlay by key; inline value wins, `{}` preserves all keys | | `setup_commands` | Inherit template sequence | Replace the sequence; `[]` clears it | | `files` | Inherit template file set | Replace the complete set; `[]` clears it | -| `packages` | Inherit all managers | Resolve Python/npm/system independently; `{}` inherits all | +| `packages` | Inherit supported managers | Resolve Python/npm independently; `{}` inherits both; supplying `system` rejects | | Individual package manager | Inherit its template list | Replace that list; `[]` clears it | Core performs this composition once, before the existing encrypted Session snapshot @@ -654,6 +623,10 @@ does not add MiniMax, Claude or E2B native qualification. ### Real Codex Docker composition acceptance (2026-09-23) +This is historical evidence for the recorded images, including their former +system-package and inner-sandbox implementation. It does not qualify current +Runtime permissions or permit `packages.system`. + The exact `8019ac5` production build ran independently with Core, PostgreSQL and Docker Runtime against the real Kimi API. Three completed native model Turns prove: @@ -669,8 +642,7 @@ Docker Runtime against the real Kimi API. Three completed native model Turns pro Five owned Core Sessions were created over the acceptance attempts. The first two failed before any model Turn because the reused private Skill-only runner omitted -Docker `nested_sandbox: true`, already required by the documented initialization -profile. Correcting that operator setting resolved the proc-mount failure without +Docker `nested_sandbox: true`, required by that historical initialization profile. Correcting that operator setting resolved the proc-mount failure without production changes. In the corrected pair, the populated Session completed its native command, but the private runner then required an optional assistant `phase` and unwrapped JSON. The original message instead contained matching fenced JSON @@ -687,6 +659,11 @@ or complete Template/Agents API semantics. ## Verification +The dated results in this section are historical evidence for their exact source, +images and inputs. They do not qualify current bypass execution, cross-platform +preparation, or outer network enforcement. In particular, former system-package +installation and private-file denial results are not current support promises. + ### Restricted-network Docker acceptance (2026-09-21) The fixed SDK 3.13.0 and raw HTTP passed restricted-policy CRUD, tenant isolation, @@ -813,9 +790,10 @@ One MiniMax inline post-restart model request reported an upstream timeout after transport changes; this does not establish or fix the timeout cause. All completed runs confirmed owned resource cleanup. The three-harness-by-two-Provider matrix was not repeated: shared E2B initialization and the changed native adapter paths -were covered separately. System packages were outside that batch; their current -qualification is recorded separately. Unconfirmed reference overrides remain gaps, -and native Codex hook failure retains the limitation stated above. +were covered separately. System packages were outside that batch; a later +historical qualification is recorded below. Unconfirmed reference overrides +remained gaps in that evidence, and the retired native Codex hook's failure +limitation was not resolved by this run. Private sanitized run/check/build evidence is retained under `~/.parsar/remediation/20260920/environment-template-setup/` and the linked board. These results do not establish complete Template or Agents API compatibility. diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index f0bf8ea7d..0143f158a 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -36,7 +36,8 @@ See the [enrollment guide](../../services/agents-api/README.md#user-managed-runt An explicitly configured default managed Docker provider enables `type=openai_hosted` for the qualified Codex, Claude Code and MiniMax Code profiles. Each uses the same -Runtime lifecycle and workspace interfaces with its own native adapter/isolation. +Runtime lifecycle and workspace interfaces with its native adapter. The outer +Environment provides managed isolation; the daemon adds no inner sandbox. See the [engine profile guides](README.md#public-engine-profiles) for setup and limits. The standalone [operator configuration](../../services/agents-api/deploy/codex/README.md#standalone-operator-configuration) selects the qualified immutable Runtime image; advertised capabilities alone do @@ -45,15 +46,17 @@ and retry identity before the existing leased Worker provisions its allocation. A committed creation interrupted before bootstrap is recovered without replaying an existing allocation's Create. -Omitted/null network defaults to enabled. Enabled, disabled and exact-host restricted -policies use the same qualified image with adapter-selected immutable native policy. +Omitted/null network defaults to enabled. The daemon does not enforce disabled +or restricted networking. An execution combination must reject unless its outer +Environment implements and qualifies the requested behavior. Templates and inline configuration share initial files, env, packages, ordered setup and inline or tenant-owned referenced Skills through ordered managed initialization. The same authenticated daemon handles initial files, tool configuration, packages, setup, Skill/Plugin import and directory snapshot finalization through `runtime_prepare`. Providers place, create, bootstrap, inspect, renew and reclaim -compute. Package installation and setup retain enabled provisioning network access; -the requested network policy constrains native execution after setup. +compute. npm/Python installation and setup use the host's existing network and +starting account. System dependencies must be preinstalled; `packages.system` +rejects explicitly and never invokes apt, sudo or elevated execution. Unsupported hostname forms and installation combinations reject explicitly; see the [Template coverage and limits](environment-templates.md). Empty/null installation defaults produce safe empty metadata, not a live workspace inventory. Service-origin @@ -162,11 +165,11 @@ accepts only local `capability_directories` with `workspace_directory`, not mana Skill/Plugin archive fields or a hosted Template. Local directory discovery does not populate the public API-managed `skills` or `plugins` installation arrays. -Both origins use the packaged Linux Runtime's shared parser and protected -`installed.json`. Its default root is `/environment/initialization/capabilities`; -the operator may freeze another root with `OAC_RUNTIME_CAPABILITY_DIRECTORY`, kept -separate from workspace and private Runtime state. Core and public requests cannot -select this installation root. The manifest binds one +Both origins use the common Runtime parser and `installed.json` on Linux, macOS +and Windows. The operator selects the capability root; the native installer +uses `capabilities` under `OAC_RUNTIME_HOME` by default. Core and public requests +cannot select that destination. Read-only snapshot modes are integrity hints, +not protection from the launching user. The manifest binds one Session and Environment to the source-selection digest. The admitted asynchronous preparation owner verifies or creates it before native execution, including for an empty selection. Reconnect and replacement executors reuse installed contents; @@ -186,11 +189,12 @@ Core does not resolve these paths on its own host. The daemon applies its local path and access checks. Initial files and setup working directories use logical `/workspace` paths. Harness differences remain in adapters. -The protocol is platform-neutral, but this implementation retains the existing Linux -helpers, protected initialization storage and isolation requirements. It does not provide -a new native-host installer, another platform layout, or Windows/macOS isolation. -This implementation description does not extend historical real-deployment acceptance; -new managed and self-hosted capability coverage requires its own evidence. See the +The protocol and Go preparation implementation are shared by all three native +platforms. Resource directories are operator settings, not deployment branches. +Managed Providers remain Linux-only. The daemon does not sandbox tools, files or +network access; tools use the starting account's permissions. Native installation +and current validation limits are in the [native guide](../../docs/self-hosted-native.md). +Historical deployment acceptance does not qualify later platform or capability changes. See the [canonical preparation rules](../../CONTRIBUTING.md#shared-runtime-capability-preparation). ## Contract inventory diff --git a/contracts/agents-api/harnesses.md b/contracts/agents-api/harnesses.md index 5c8976f84..1b77f2d65 100644 --- a/contracts/agents-api/harnesses.md +++ b/contracts/agents-api/harnesses.md @@ -58,8 +58,10 @@ not a different contract or authority level. The documented extension remains se work merely because a connection closed. - Resume only the bound Session's native history. Missing, ambiguous or foreign history fails closed. Device identity is not native Session ownership. -- Native tools and public Files operate on the same authorized workspace. - Generated code cannot access daemon/model credentials or foreign history. +- Native tools and public Files use the same bound workspace. Public Files retains + tenant and path authorization. Native tools run with the starting account's + permissions; daemon/model credentials are not isolated from that same user. + Managed outer Environments must exclude other tenants' resources. - Emit verified measurements; absence of native usage detail is not a zero value. Explicit unsupported operations remain implementation gaps in protocol coverage. diff --git a/contracts/agents-api/mcode-workspace-v1.md b/contracts/agents-api/mcode-workspace-v1.md index 45aaf3b99..7bd3067d6 100644 --- a/contracts/agents-api/mcode-workspace-v1.md +++ b/contracts/agents-api/mcode-workspace-v1.md @@ -1,6 +1,10 @@ # MiniMax Code workspace Runtime qualification -Status: implementation, public qualification and independent review passed, +Historical evidence only: the current daemon runs tools with its starting user's +permissions and no inner sandbox. The isolation probes below apply to the recorded +old binaries, not current native or managed support. + +Status at the recorded revision: implementation, public qualification and independent review passed, 2026-09-20. Baseline main `55502c87c3f722bbefc5449348309e3fcc03a7ee`. Board batch: `MCODE-WORKSPACE-V1-001`, under `ENGINE-EXTENSIBILITY-001`. diff --git a/contracts/agents-api/model-execution.md b/contracts/agents-api/model-execution.md index 244d542a5..2b4969999 100644 --- a/contracts/agents-api/model-execution.md +++ b/contracts/agents-api/model-execution.md @@ -129,8 +129,11 @@ options only over the daemon connection bound to the Session. For `self_hosted`, that is the executor enrolled for the Session's own Environment with a current executor credential of the Session creator's principal; rotation or revocation closes the socket before further dispatch. The executor host stores the bundle in -its native harness home, as hosted Runtimes do; tools and public Files cannot reach -that home. Revocation does not erase a bundle already delivered. +its native harness home, as hosted Runtimes do. Public Files remains scoped to the +bound workspace, but native tools use the starting account's permissions and can +access whatever that user can read. The daemon does not isolate its local +credentials from same-user tools. Revocation does not erase an already delivered +bundle. ## Deployment defaults diff --git a/contracts/agents-api/official-semantics-alignment.md b/contracts/agents-api/official-semantics-alignment.md index a581097d2..ae3500ce5 100644 --- a/contracts/agents-api/official-semantics-alignment.md +++ b/contracts/agents-api/official-semantics-alignment.md @@ -751,7 +751,13 @@ package; both were deleted. The batch plan is | H7 | Delete | 200 `agent.session.deleted`, as officially, then 404. Deletion while provisioning is unchanged (HI-05 awaits a decision). | | H8 | Unchanged | `self_hosted` and `none` Environments, successful initialization and its timing, the two-minute step limit (HI-06), expiry and tenant isolation. | -Decisions: +Historical implementation decisions at the September 23 revision follow. The +current daemon uses shared Go preparation and process settlement, with no bwrap, +Python receipt decoder or old-image compatibility. System packages now reject +before initialization. Safe fixed labels and bounded exit statuses remain the +current public failure policy; see the [current initialization contract](environment-templates.md#packaged-runtime-initialization-contract). + +Recorded decisions: - **No output.** The shared Runtime initializer adds only an integer `exit_code` to its failed receipt, and only for a step run inside its bwrap isolation; diff --git a/contracts/agents-api/user-managed-runtime-v1.md b/contracts/agents-api/user-managed-runtime-v1.md index 52335471c..1c48b6a80 100644 --- a/contracts/agents-api/user-managed-runtime-v1.md +++ b/contracts/agents-api/user-managed-runtime-v1.md @@ -17,7 +17,10 @@ file operations continue through Core and daemon, not E2B commands or files. Historical qualification recorded on 2026-09-22 against source candidate `8f0cd2530d7b58cb7fb3ea124a1fc43dacecca36`. These records qualify only those binaries and tested inputs. Later implementation -changes, including shared Runtime capability preparation, require separate acceptance. +changes, including shared Runtime capability preparation and native-platform +support, require separate acceptance. The current daemon has no inner sandbox; +private-file denial and namespace probes below describe the former tested +implementation, not current tool permissions or an installation requirement. | Deployment | Codex | Claude Code | MiniMax Code | | --- | --- | --- | --- | diff --git a/contracts/agents-api/workspace-placement.md b/contracts/agents-api/workspace-placement.md index 2e02d85ea..e44456128 100644 --- a/contracts/agents-api/workspace-placement.md +++ b/contracts/agents-api/workspace-placement.md @@ -8,9 +8,11 @@ provider. Users manage local or E2B Runtime creation, renewal and destruction through the official SDK. All three harnesses reuse typed `LocalEnvironment`, existing preparation/start/ -cancel ownership and protected local Files/Artifacts. Native credentials and -histories remain inaccessible to generated tools. Strict resume requires retained -history; connectivity alone establishes neither readiness nor isolation. Current +cancel ownership and authorized local Files/Artifacts. Native tools use the +starting account's permissions and may access that account's Runtime credentials +and history. The daemon adds no inner sandbox; managed isolation belongs to the +outer Environment. Strict resume requires retained history; connectivity alone +establishes neither readiness nor isolation. Current implementation is recorded in the [Environment profile](environments.md#initial-public-self-hosted-profile); [real qualification](user-managed-runtime-v1.md) identifies accepted deployments and limits. @@ -23,8 +25,9 @@ hosted Template Plugin MCP retain their separate boundaries. ## Historical two-engine assessment The assessment below records the earlier topology and native prerequisites at its -original scope. It is not current installation guidance or proof of the V1 daemon -enrollment chain. Referenced retired package/probe sources remain in Git history. +original scope. Its inner-sandbox prerequisites and denied private-file probes +are historical only and must not be reinstated as current daemon requirements. +It is not current installation guidance or proof of the current daemon chain. Referenced retired package/probe sources remain in Git history. ### Current implementation and missing prerequisites diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md index cc22d12c0..d37ecff09 100644 --- a/docs/getting-started/self-hosted.md +++ b/docs/getting-started/self-hosted.md @@ -11,7 +11,7 @@ filesystem, permission or network sandbox. Its tools may access that user's file and Runtime credentials. Managed isolation belongs to the outer Environment; self-hosted operators choose any outer isolation they need. See the [native installation guide](../self-hosted-native.md) for prerequisites, commands -and the current platform validation status. +and supported platforms. An executor credential works for one Environment only. It cannot call the Agent API, Core API or node enrollment. Default model providers never apply to diff --git a/docs/self-hosted-native.md b/docs/self-hosted-native.md index b536e3811..e21570f2c 100644 --- a/docs/self-hosted-native.md +++ b/docs/self-hosted-native.md @@ -13,23 +13,20 @@ Core creates that outer boundary for managed Docker/E2B/microsandbox Environment The daemon does not enforce `disabled` or `restricted` network modes; combinations without corresponding outer enforcement are unsupported. -## Prerequisites and validation status +## Supported platforms -Use a current-version daemon binary for the host's OS and architecture, and install -the native harness selected by the Session. The daemon installer saves local -configuration; it does not install the harness, create a container, or register an -OS service. Native harness dependencies must already be available to its process. -Claude on Windows requires Git Bash. The MiniMax adapter does not currently support -Windows. +| Platform | Codex | Claude Code | MiniMax Code | +| --- | --- | --- | --- | +| Linux | Supported | Supported | Supported | +| macOS | Supported | Supported | Supported | +| Windows | Supported | Supported | Not supported by the current adapter | -| Platform | Current validation status | -| --- | --- | -| Linux | Existing Runtime platform; the removal of inner sandboxing needs the current change's focused checks | -| macOS | Native daemon lifecycle and Files tests passed with the race detector; native harness validation is in progress | -| Windows | Native CI build and automated execution tests are required and have not yet passed for this change; cross-compilation is not acceptance, and no manual Windows acceptance is recorded | +Native CI passing is the platform support criterion. Windows has not yet had +manual machine acceptance. Managed Providers support Linux only. -These are validation results for the current implementation work, not a claim that -every harness/model combination has passed on every platform. +Use a current-version daemon binary for the host's OS and architecture. The daemon +installer saves local configuration; native harnesses and their dependencies must +already be available to its process. Claude on Windows requires Git Bash. Create a `self_hosted` Session with its model provider and an existing absolute `workspace_directory` on the executor host. Obtain its `environment.id` and @@ -78,8 +75,7 @@ oac-daemon.exe start Replace the example URL and UUID with the Session's actual values. On Windows, keep the credential file under the intended user's normal private storage permissions. -The Windows commands describe the installation interface; they do not replace the -pending native CI validation above. + Optional install flags: @@ -152,6 +148,7 @@ home and Session when moving versions. System dependencies must be present before Runtime execution: install them while building the managed image/template, or prepare them as the self-hosted user. Runtime does not run apt, request sudo or elevate the daemon's privileges. -`system_packages` is unsupported; a missing dependency fails the operation that -requires it. npm/Python package installation and setup retain their supported +`packages.system` is explicitly rejected on Session and Template input, including +an empty or null value; it is never ignored. A missing preinstalled dependency +fails the operation that requires it. npm/Python package installation and setup retain their supported initialization flow using the launching user's existing permissions. From 5c2cd42267da6ce3bdea0a82de656b080f75a6be Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:31:33 +0000 Subject: [PATCH 41/78] fix(runtime): align native smoke names with name guard --- scripts/name-allowlist.json | 13 +++++++++---- scripts/native-harness-smoke.mjs | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/scripts/name-allowlist.json b/scripts/name-allowlist.json index 33861441c..8017f17c6 100644 --- a/scripts/name-allowlist.json +++ b/scripts/name-allowlist.json @@ -224,6 +224,11 @@ "regex": "\"agents-api-(?:session)?\"", "reason": "AgentStateKey is a persisted daemon/native-session resume identity carried on the existing machine wire contract; changing it would select another session." }, + { + "path": "apps/parsar-daemon/internal/localworkspace/native_binding.go", + "regex": "\"agents-api-(?:session)?\"", + "reason": "AgentStateKey is a persisted daemon/native-session resume identity carried on the existing machine wire contract; changing it would select another session." + }, { "path": "apps/parsar-daemon/internal/localworkspace/binding_test.go", "regex": "\"agents-api-(?:session)?\"", @@ -562,22 +567,22 @@ { "path": "apps/web/src/features/sessions/items/ItemRenderers.tsx", "regex": "parseParsarApplyPatch|Parsar apply_patch", - "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by \u00a79." + "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by §9." }, { "path": "apps/web/src/features/sessions/items/apply-patch.ts", "regex": "parseParsarApplyPatch|Parsar apply_patch", - "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by \u00a79." + "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by §9." }, { "path": "apps/web/src/features/sessions/items/apply-patch.test.tsx", "regex": "parseParsarApplyPatch|Parsar apply_patch", - "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by \u00a79." + "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by §9." }, { "path": "apps/web/src/features/sessions/trace/TraceView.tsx", "regex": "parseParsarApplyPatch|Parsar apply_patch", - "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by \u00a79." + "reason": "The parser identifies the existing product-origin apply_patch event format; renaming private parser symbols is deferred by §9." }, { "path": "apps/web/src/features/sandbox/deployment-specification.test.ts", diff --git a/scripts/native-harness-smoke.mjs b/scripts/native-harness-smoke.mjs index 76d48c757..26227b29a 100644 --- a/scripts/native-harness-smoke.mjs +++ b/scripts/native-harness-smoke.mjs @@ -71,7 +71,7 @@ async function codexSmoke(root) { } finally { clearTimeout(timer); pending = undefined; } }; try { - await request('initialize', { clientInfo: { name: 'parsar-native-smoke', version: '1' }, capabilities: { experimentalApi: true } }); + await request('initialize', { clientInfo: { name: 'oac-native-smoke', version: '1' }, capabilities: { experimentalApi: true } }); child.stdin.write(JSON.stringify({ method: 'initialized' }) + '\n'); const result = await request('thread/start', { cwd: workspace, approvalPolicy: 'never', sandbox: 'danger-full-access', persistExtendedHistory: true }); if (typeof result?.thread?.id !== 'string' || !result.thread.id) throw failure('codex_thread_identity'); @@ -101,7 +101,7 @@ async function claudeSmoke() { return { status: 'passed', sdk: info.sdk, native: info.native }; } -const root = await realpath(await mkdtemp(join(tmpdir(), 'parsar-native-smoke-'))); +const root = await realpath(await mkdtemp(join(tmpdir(), 'oac-native-smoke-'))); const report = { platform: process.platform, arch: process.arch, model_requests: 0 }; try { for (const [name, run] of [['codex', () => codexSmoke(root)], ['claude', claudeSmoke]]) { From cb0e6568953f7cc888e64c9c9dc3a98af85b544b Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:31:38 +0000 Subject: [PATCH 42/78] build: package pinned native installer distributions --- .github/workflows/native-installer.yml | 64 ++++++++++ scripts/build-native-installer-ci.mjs | 63 ++++++++++ scripts/build-native-installer.mjs | 151 ++++++++++++++++++++++++ scripts/build-native-installer.test.mjs | 61 ++++++++++ 4 files changed, 339 insertions(+) create mode 100644 .github/workflows/native-installer.yml create mode 100644 scripts/build-native-installer-ci.mjs create mode 100644 scripts/build-native-installer.mjs create mode 100644 scripts/build-native-installer.test.mjs diff --git a/.github/workflows/native-installer.yml b/.github/workflows/native-installer.yml new file mode 100644 index 000000000..45743a68e --- /dev/null +++ b/.github/workflows/native-installer.yml @@ -0,0 +1,64 @@ +name: native-installer + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + bundle: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-14, windows-2022] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - uses: actions/setup-node@v6 + with: + node-version: '22.22.0' + - name: Build the native daemon and test bundle boundaries + run: | + go build -o "$RUNNER_TEMP/oac-daemon${{ runner.os == 'Windows' && '.exe' || '' }}" ./apps/parsar-daemon/cmd/parsar-daemon + node --test scripts/build-native-installer.test.mjs + - name: Build pinned native components + run: | + npm install --global pnpm@10.30.3 + pnpm install --frozen-lockfile --filter @parsar/claude-sdk-adapter... + pnpm --filter @parsar/claude-sdk-adapter build --outDir "$RUNNER_TEMP/claude-compiled" + pnpm --config.inject-workspace-packages=true --config.extend-node-path=false --filter @parsar/claude-sdk-adapter deploy --prod "$RUNNER_TEMP/claude-runtime" + rm -rf "$RUNNER_TEMP/claude-runtime/dist" + mv "$RUNNER_TEMP/claude-compiled" "$RUNNER_TEMP/claude-runtime/dist" + # Reify the dedicated frozen export lock without pnpm's symlink layout. + # Flattening isolated package links changes Node dependency resolution. + rm -rf "$RUNNER_TEMP/claude-runtime/node_modules" + pnpm --dir "$RUNNER_TEMP/claude-runtime" install --prod --frozen-lockfile --config.node-linker=hoisted --ignore-scripts + npm install --prefix "$RUNNER_TEMP/native-tools" --no-save @openai/codex@0.153.4 + - name: Package and exercise CLI-only installation, additions and reuse + run: | + if [[ "$RUNNER_OS" == Windows ]]; then + export CLAUDE_CODE_GIT_BASH_PATH='C:\Program Files\Git\bin\bash.exe' + fi + node scripts/build-native-installer-ci.mjs + - name: Verify native Harness protocols without model requests + run: | + if [[ "$RUNNER_OS" == Windows ]]; then + export CLAUDE_CODE_GIT_BASH_PATH='C:\Program Files\Git\bin\bash.exe' + fi + node scripts/native-harness-smoke.mjs --codex-binary "$RUNNER_TEMP/native-installer/components/codex/bin/codex${{ runner.os == 'Windows' && '.exe' || '' }}" --claude-runtime "$RUNNER_TEMP/native-installer/components/claude" + - name: Archive the native distribution + run: tar -C "$RUNNER_TEMP/native-installer" -czf "$RUNNER_TEMP/oac-native-installer-${{ runner.os }}-${{ runner.arch }}.tar.gz" . + - uses: actions/upload-artifact@v6 + with: + name: oac-native-installer-${{ runner.os }}-${{ runner.arch }} + path: ${{ runner.temp }}/oac-native-installer-*.tar.gz + if-no-files-found: error diff --git a/scripts/build-native-installer-ci.mjs b/scripts/build-native-installer-ci.mjs new file mode 100644 index 000000000..fba7b973e --- /dev/null +++ b/scripts/build-native-installer-ci.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// Native CI assembly and installation smoke; no provider credentials or model calls. +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { access, mkdir, readdir, realpath, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { buildBundle } from './build-native-installer.mjs'; + +const root = resolve(process.env.RUNNER_TEMP ?? ''); +if (!process.env.RUNNER_TEMP) throw new Error('RUNNER_TEMP is required'); +const windows = process.platform === 'win32'; +const daemon = join(root, windows ? 'oac-daemon.exe' : 'oac-daemon'); +// setup-node extracts the complete official Node distribution, including npm. +const node = windows ? dirname(process.execPath) : dirname(dirname(process.execPath)); +async function findCodex(directory, depth = 0) { + if (depth > 6) return []; + try { + await access(join(directory, 'bin', windows ? 'codex.exe' : 'codex')); + await access(join(directory, 'codex-resources')); + return [directory]; + } catch { /* Search the installed platform package rather than hard-code npm aliases. */ } + const results = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.isDirectory()) results.push(...await findCodex(join(directory, entry.name), depth + 1)); + } + return results; +} +const candidates = await findCodex(join(root, 'native-tools', 'node_modules', '@openai')); +assert.equal(candidates.length, 1, 'Expected exactly one native Codex artifact'); +const bundle = join(root, 'native-installer'); +const receipt = await buildBundle({ daemon, node: await realpath(node), codex: candidates[0], claude: join(root, 'claude-runtime'), output: bundle }); +console.log(JSON.stringify({ stage: 'bundle', os: receipt.os, arch: receipt.arch, components: Object.keys(receipt.components), model_requests: 0 })); +const workspace = join(root, 'native-install-workspace'); await mkdir(workspace); +const credential = join(root, 'native-install-credential.json'); +const environment = randomUUID(); +await writeFile(credential, JSON.stringify({ key_id: randomUUID(), executor_token: 'fixture-only-not-a-real-credential', environment_id: environment }), { mode: 0o600 }); +const installDir = join(root, 'native-install-smoke'); +const args = ['install', '--non-interactive', '--bundle-dir', bundle, + '--install-dir', installDir, '--remote', 'ws://localhost:1/api/v1/agent-daemon/ws', + '--environment-id', environment, '--workspace', workspace, '--credential-file', credential]; +const command = join(bundle, windows ? 'oac-daemon.exe' : 'oac-daemon'); +const env = { ...process.env, OAC_RUNTIME_HOME: join(root, 'native-install-state') }; +function install(extra, expected) { + const result = spawnSync(command, [...args, ...extra], { env, encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024 }); + // Keep native output out of CI logs, even for fixture-only credentials. + assert.equal(Boolean(result.error), false, 'Installation command did not settle'); + assert.equal(result.status === 0, expected, 'Unexpected installation result'); + assert.ok(!`${result.stdout}${result.stderr}`.includes('fixture-only-not-a-real-credential'), 'Installer echoed a credential'); +} +install(['--harness', 'codex'], true); +install(['--harness', 'codex,claude'], true); +install(['--harness', 'codex,claude'], true); +if (windows) install(['--harness', 'minimax'], false); +const installedCommand = join(installDir, 'bin', windows ? 'oac-daemon.exe' : 'oac-daemon'); +const installedEnv = { ...process.env }; delete installedEnv.OAC_RUNTIME_HOME; +const installedVersion = spawnSync(installedCommand, ['version'], { env: installedEnv, encoding: 'utf8', timeout: 10000 }); +assert.equal(installedVersion.status, 0, 'Installed daemon could not start'); +assert.equal(installedVersion.stdout.trim(), receipt.daemon_version, 'Installed daemon identity differs'); +const incomplete = spawnSync(command, ['install', '--non-interactive'], { env, input: '', encoding: 'utf8', timeout: 10000 }); +assert.equal(Boolean(incomplete.error), false, 'Missing arguments waited for interaction'); +assert.notEqual(incomplete.status, 0, 'Missing required arguments succeeded'); +console.log(JSON.stringify({ stage: 'install', install: 'passed', additive: 'passed', reuse: 'passed', missing_arguments: 'rejected', connection: 'not_attempted', model: 'not_configured', model_requests: 0 })); diff --git a/scripts/build-native-installer.mjs b/scripts/build-native-installer.mjs new file mode 100644 index 000000000..c83f60541 --- /dev/null +++ b/scripts/build-native-installer.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +// Package trusted, already-built native artifacts. This does not install on a host. +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const pins = { node: '22.22.0', codex: '0.153.4', claude: '0.3.269', minimax: '0.4.12' }; +const platforms = { linux: 'linux', darwin: 'darwin', win32: 'windows' }; +const architectures = { x64: 'amd64', arm64: 'arm64' }; +const inside = (root, path) => { + const rel = relative(root, path); + return rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel); +}; + +// Flatten contained links, including directory links used by pnpm exports. Every +// traversal checks its resolved target; active ancestors detect directory cycles. +export async function copyComponent(source, destination) { + const root = await realpath(source); + if (!(await stat(root)).isDirectory()) throw new Error('Component source must be a directory'); + const files = {}; + async function visit(path, target, ancestors) { + const resolved = await realpath(path); + if (!inside(root, resolved)) throw new Error('Component link escapes its source'); + const info = await stat(resolved); + if (info.isDirectory()) { + if (ancestors.has(resolved)) throw new Error('Component contains a directory link cycle'); + const next = new Set([...ancestors, resolved]); + await mkdir(target); + for (const name of (await readdir(resolved)).sort()) { + // Manifest paths are portable slash paths, never Windows aliases/streams. + if (/[\\:\x00-\x1f]/.test(name) || /[. ]$/.test(name) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)) { + throw new Error('Component contains a non-portable file name'); + } + await visit(join(resolved, name), join(target, name), next); + } + return; + } + if (!info.isFile()) throw new Error('Component contains a non-regular file'); + await copyFile(resolved, target); + const executable = Boolean(info.mode & 0o111) || /\.(exe|cmd|bat)$/i.test(target); + await chmod(target, executable ? 0o755 : 0o644); + files[relative(destination, target).split(sep).join('/')] = { + sha256: createHash('sha256').update(await readFile(target)).digest('hex'), executable, + }; + } + await visit(root, destination, new Set()); + if (!Object.keys(files).length) throw new Error('Component is empty'); + return files; +} + +function probe(command, args, cwd, env) { + const result = spawnSync(command, args, { cwd, env, input: '', encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 }); + if (result.error || result.status !== 0) throw new Error('Native component compatibility probe failed'); + return result.stdout.trim(); +} +const json = async path => JSON.parse(await readFile(path, 'utf8')); +const requireFile = async path => { if (!(await stat(path)).isFile()) throw new Error('Required component file is missing'); }; + +export async function validateComponents(root, names, daemonVersion) { + const windows = process.platform === 'win32'; + const nodeRoot = join(root, 'components', 'node'); + const node = join(nodeRoot, windows ? 'node.exe' : 'bin/node'); + const npm = join(nodeRoot, windows ? 'node_modules/npm/bin/npm-cli.js' : 'lib/node_modules/npm/bin/npm-cli.js'); + const env = { ...process.env, PATH: [dirname(node), join(root, 'components', 'minimax', 'bin'), process.env.PATH ?? ''].join(sep === '\\' ? ';' : ':') }; + if (probe(node, ['--version'], root, env) !== `v${pins.node}`) throw new Error('Node version does not match the pin'); + const identity = JSON.parse(probe(node, ['-p', 'JSON.stringify([process.platform,process.arch])'], root, env)); + if (identity[0] !== process.platform || identity[1] !== process.arch) throw new Error('Node platform does not match the build host'); + await requireFile(npm); + probe(node, [npm, '--version'], root, env); + const daemon = join(root, windows ? 'oac-daemon.exe' : 'oac-daemon'); + if (probe(daemon, ['version'], root, env) !== daemonVersion) throw new Error('Daemon identity changed during packaging'); + for (const name of names) { + const component = join(root, 'components', name); + if (name === 'codex') { + if (!(await stat(join(component, 'codex-resources'))).isDirectory()) throw new Error('Codex resources are missing'); + const binary = join(component, 'bin', windows ? 'codex.exe' : 'codex'); + if (probe(binary, ['--version'], component, env) !== `codex-cli ${pins.codex}`) throw new Error('Codex version does not match the pin'); + } else if (name === 'claude') { + const manifest = await json(join(component, 'package.json')); + if (manifest.dependencies?.['@anthropic-ai/claude-agent-sdk']?.split('(')[0] !== pins.claude) throw new Error('Claude SDK pin mismatch'); + await requireFile(join(component, 'pnpm-lock.yaml')); + await requireFile(join(component, 'dist/main.js')); + const info = JSON.parse(probe(node, [join(component, 'dist/runtime_check.js')], component, env)); + if (info.type !== 'runtime_ready' || info.protocol !== 3 || info.sdk !== pins.claude || info.native !== '2.1.269 (Claude Code)') throw new Error('Claude runtime compatibility failed'); + } else if (name === 'minimax') { + if (windows) throw new Error('MiniMax is not supported on Windows'); + const upstream = await json(join(component, 'source.json')); + const expected = await json(new URL('../packages/mcode-harness/source.json', import.meta.url)); + if (upstream.version !== pins.minimax || upstream.revision !== expected.revision) throw new Error('MiniMax source pin mismatch'); + for (const entry of ['launch.mjs', 'dist/worker.mjs', 'provenance.json', 'native-patch.json']) await requireFile(join(component, entry)); + if (probe(node, [join(component, 'native/cli.js'), '--version'], component, env) !== pins.minimax) throw new Error('MiniMax native pin mismatch'); + const info = JSON.parse(probe(node, [join(component, 'check.mjs')], component, env)); + if (info.protocol !== 1 || info.native !== pins.minimax || info.source !== expected.revision) throw new Error('MiniMax companion compatibility failed'); + } + } +} + +export async function buildBundle(options) { + if (!platforms[process.platform] || !architectures[process.arch]) throw new Error('Unsupported native bundle platform'); + for (const required of ['daemon', 'node', 'output']) if (!options[required]) throw new Error(`Missing --${required}`); + for (const path of Object.values(options)) if (!isAbsolute(path)) throw new Error('Bundle paths must be absolute'); + const names = ['codex', 'claude', 'minimax'].filter(name => options[name]); + if (!names.length) throw new Error('At least one Harness source is required'); + if (process.platform === 'win32' && options.minimax) throw new Error('MiniMax is not supported on Windows'); + try { await lstat(options.output); throw new Error('Bundle output already exists'); } catch (error) { if (error.code !== 'ENOENT') throw error; } + // Refuse staging inside a source: recursive copying must not ingest its own output. + for (const name of ['node', ...names]) if (inside(await realpath(options[name]), resolve(options.output))) throw new Error('Output must be outside component sources'); + const daemonVersion = probe(options.daemon, ['version'], dirname(options.daemon), process.env); + if (!/^[0-9A-Za-z][0-9A-Za-z.+_-]{0,127}$/.test(daemonVersion)) throw new Error('Invalid daemon version'); + await mkdir(dirname(options.output), { recursive: true }); + const staging = await mkdtemp(join(dirname(options.output), '.native-bundle-')); + try { + const daemon = join(staging, process.platform === 'win32' ? 'oac-daemon.exe' : 'oac-daemon'); + await copyFile(options.daemon, daemon); await chmod(daemon, 0o755); + await mkdir(join(staging, 'components')); + const components = {}; + for (const name of ['node', ...names]) components[name] = { version: pins[name], files: await copyComponent(options[name], join(staging, 'components', name)) }; + if (components.minimax) { + const component = join(staging, 'components', 'minimax'); + const rg = join(component, 'native', 'node_modules', '@vscode', `ripgrep-${process.platform}-${process.arch}`, 'bin', 'rg'); + await requireFile(rg); + await mkdir(join(component, 'bin'), { recursive: true }); + await copyFile(rg, join(component, 'bin', 'rg')); await chmod(join(component, 'bin', 'rg'), 0o755); + components.minimax.files['bin/rg'] = { sha256: createHash('sha256').update(await readFile(rg)).digest('hex'), executable: true }; + } + await validateComponents(staging, names, daemonVersion); + const manifest = { schema: 1, daemon_version: daemonVersion, os: platforms[process.platform], arch: architectures[process.arch], components }; + await writeFile(join(staging, 'bundle.json'), JSON.stringify(manifest, null, 2) + '\n'); + await rename(staging, options.output); + return manifest; + } finally { await rm(staging, { recursive: true, force: true }); } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const options = {}; + for (let i = 2; i < process.argv.length; i += 2) { + const key = process.argv[i].replace(/^--/, ''); + if (!process.argv[i].startsWith('--') || !['daemon', 'node', 'codex', 'claude', 'minimax', 'output'].includes(key) || options[key] || !process.argv[i + 1]) throw new Error('Expected --daemon, --node, --codex, --claude, --minimax or --output with an absolute path'); + options[key] = process.argv[i + 1]; + } + const manifest = await buildBundle(options); + console.log(JSON.stringify({ status: 'built', os: manifest.os, arch: manifest.arch, components: Object.keys(manifest.components), model_requests: 0 })); + } catch (error) { + // Native output and environment values are never printed by the packager. + console.error(error.code ? 'Native bundle source is unavailable' : error instanceof SyntaxError ? 'Native component metadata is invalid' : error.message); + process.exitCode = 1; + } +} diff --git a/scripts/build-native-installer.test.mjs b/scripts/build-native-installer.test.mjs new file mode 100644 index 000000000..83816a828 --- /dev/null +++ b/scripts/build-native-installer.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { chmod, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { buildBundle, copyComponent } from './build-native-installer.mjs'; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'oac-native-bundle-test-')); + t.after(() => rm(root, { recursive: true, force: true })); + const source = join(root, 'source'); await mkdir(source); + return { root, source, output: join(root, 'output') }; +} + +test('component preserves bytes and executable metadata with contained links flattened', async t => { + const { source, output } = await fixture(t); + await mkdir(join(source, 'lib')); + await writeFile(join(source, 'lib', 'native'), 'native bytes'); + await chmod(join(source, 'lib', 'native'), 0o755); + await symlink(join(source, 'lib'), join(source, 'linked'), process.platform === 'win32' ? 'junction' : 'dir'); + const files = await copyComponent(source, output); + assert.equal(await readFile(join(output, 'linked/native'), 'utf8'), 'native bytes'); + assert.equal((await lstat(join(output, 'linked'))).isSymbolicLink(), false); + assert.equal(files['linked/native'].sha256, createHash('sha256').update('native bytes').digest('hex')); + if (process.platform !== 'win32') assert.equal(files['linked/native'].executable, true); + assert.deepEqual(Object.keys(files).sort(), ['lib/native', 'linked/native']); +}); + +test('component rejects escaping directory links', async t => { + const { root, source, output } = await fixture(t); + await mkdir(join(root, 'outside')); await writeFile(join(root, 'outside/secret'), 'not bundled'); + await symlink(join(root, 'outside'), join(source, 'external'), process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects(copyComponent(source, output), /escapes/); +}); + +test('component rejects cyclic links without recursing forever', async t => { + const { source, output } = await fixture(t); + await symlink(source, join(source, 'cycle'), process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects(copyComponent(source, output), /cycle/); +}); + +test('bundle does not replace an existing output or accept relative paths', async t => { + const { source, output } = await fixture(t); + await mkdir(output); await writeFile(join(output, 'keep'), 'existing install'); + await assert.rejects(buildBundle({ daemon: join(source, 'daemon'), node: source, codex: source, output }), /already exists/); + assert.equal(await readFile(join(output, 'keep'), 'utf8'), 'existing install'); + await assert.rejects(buildBundle({ daemon: 'relative', node: source, codex: source, output }), /absolute/); +}); + +test('bundle requires a selected harness and rejects output inside sources', async t => { + const { source, output } = await fixture(t); + await assert.rejects(buildBundle({ daemon: join(source, 'daemon'), node: source, output }), /Harness source/); + await assert.rejects(buildBundle({ daemon: join(source, 'daemon'), node: source, codex: source, output: join(source, 'out') }), /outside/); +}); + +test('component rejects nonportable paths', { skip: process.platform === 'win32' }, async t => { + const { source, output } = await fixture(t); + await writeFile(join(source, 'bad:name'), 'bad'); + await assert.rejects(copyComponent(source, output), /non-portable/); +}); From 67c8c031b1232a5f5fefb72fd9acb641a032f902 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:34:51 +0000 Subject: [PATCH 43/78] refactor(install): retire user-managed Docker installer --- deploy/install/install.py | 7 +- deploy/install/installer_fakes.py | 3 +- deploy/install/self_hosted_install.py | 466 ------------------ .../test_generation_review_regressions.py | 2 +- deploy/install/test_install.py | 2 +- deploy/install/test_self_hosted_connection.py | 136 ----- deploy/install/test_self_hosted_install.py | 397 --------------- scripts/build-core-distribution.sh | 3 +- scripts/core-distribution-manifest.py | 22 +- scripts/core-distribution-manifest.test.py | 10 +- scripts/name-allowlist.json | 10 - services/agents-api/cmd/runtime/main.go | 154 ------ services/agents-api/cmd/runtime/main_test.go | 43 -- .../internal/sandbox/docker/self_hosted.go | 185 ------- .../sandbox/docker/self_hosted_test.go | 221 --------- services/core-console/distribution_test.go | 9 +- services/core-console/node_installation.go | 18 +- .../core-console/node_installation_test.go | 5 +- services/core-console/server.go | 18 +- 19 files changed, 42 insertions(+), 1669 deletions(-) delete mode 100644 deploy/install/self_hosted_install.py delete mode 100644 deploy/install/test_self_hosted_connection.py delete mode 100644 deploy/install/test_self_hosted_install.py delete mode 100644 services/agents-api/cmd/runtime/main.go delete mode 100644 services/agents-api/cmd/runtime/main_test.go delete mode 100644 services/agents-api/internal/sandbox/docker/self_hosted.go delete mode 100644 services/agents-api/internal/sandbox/docker/self_hosted_test.go diff --git a/deploy/install/install.py b/deploy/install/install.py index b22ac7522..66d8514e5 100644 --- a/deploy/install/install.py +++ b/deploy/install/install.py @@ -75,7 +75,7 @@ def verify_bundle(bundle): required = {"manifest.json", "install.sh", "install.py", "configuration.py", "config_model.py", "config.schema.json", "oac_cli.py", "convert.py", "rename.py", "oac.pyz", "native_service.py", "sandbox_setup.py", "standard-sizes.json", "node_spec.py", "node-install.pyz", - "self-hosted-install.pyz", "distribution.py", "runtime/seccomp.json"} + "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 ("oac-core", "oac-core-migrate")) required.add("native/e2b/oac-e2b-provider") @@ -331,7 +331,8 @@ def publish(source): revision = manifest.get("source_commit", "") if not re.fullmatch(r"[0-9a-f]{40}", revision): raise InstallError("Invalid node payload release identity") - names = ["node-install.pyz", "self-hosted-install.pyz", "manifest.json", "SHA256SUMS", "runtime/seccomp.json"] + metadata_names = ("node-install.pyz", "manifest.json", "SHA256SUMS", "runtime/seccomp.json") + names = list(metadata_names) for logical in manifest.get("artifacts", {}): entry = artifact(manifest, logical) name = "artifacts/" + entry["filename"] @@ -349,7 +350,7 @@ def publish(source): if target.exists(): # Validate the complete published metadata and every existing declared # artifact before filling any absence. Existing bytes are immutable. - for name in names[:5]: + for name in metadata_names: previous = target / name if (previous.parent.is_symlink() or previous.is_symlink() or not previous.is_file() or digest(previous) != digest(source / name)): diff --git a/deploy/install/installer_fakes.py b/deploy/install/installer_fakes.py index 8e33a501a..527466f5e 100644 --- a/deploy/install/installer_fakes.py +++ b/deploy/install/installer_fakes.py @@ -370,8 +370,7 @@ def make_bundle(directory, manifest, commit=None): for name in MODULES: (bundle / name).write_bytes(Path(__file__).with_name(name).read_bytes()) (bundle / "standard-sizes.json").write_bytes(STANDARD_SIZES.read_bytes()) - for name in ("node-install.pyz", "self-hosted-install.pyz"): - (bundle / name).write_bytes(b"synthetic verified Python bootstrap") + (bundle / "node-install.pyz").write_bytes(b"synthetic verified Python bootstrap") (bundle / "oac.pyz").write_bytes(b"synthetic oac command " + manifest["source_commit"].encode()) manifest["artifacts"] = {} for name in ("images/runtime.tar.gz", "native/bin/oac-node", diff --git a/deploy/install/self_hosted_install.py b/deploy/install/self_hosted_install.py deleted file mode 100644 index 1f9538e0c..000000000 --- a/deploy/install/self_hosted_install.py +++ /dev/null @@ -1,466 +0,0 @@ -#!/usr/bin/env python3 -"""Start one user-owned V1 Runtime from the connected Core's distribution.""" -import argparse -import fcntl -import http.client -import json -import os -from pathlib import Path -import platform -import re -import signal -import stat -import subprocess -import sys -import tempfile -import termios -import time -import urllib.error -import urllib.request -from urllib.parse import urlencode, urlsplit -import uuid - -from distribution import DistributionError, load_manifest, obtain_artifact, runtime_archive, image_identities, ensure_docker_image - - -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 prompt_credential(environment): - """Read the credential JSON from the terminal with echo off. - - Input is read until one complete JSON object parses, so both the compact - form and Web's pretty-printed file work. The secret never enters argv, the - environment, shell history or the screen; leftover typed-ahead input is - discarded so no fragment reaches the shell afterwards. - """ - try: - tty = os.open('/dev/tty', os.O_RDWR | os.O_NOCTTY) - except OSError: - raise InstallError('Use --credential-file in noninteractive sessions; credentials are never echoed') from None - try: - previous = termios.tcgetattr(tty) - hidden = termios.tcgetattr(tty) - hidden[3] &= ~(termios.ECHO | termios.ECHONL) - raw = b'' - try: - # Echo is off before the prompt appears, so nothing pasted is shown. - termios.tcsetattr(tty, termios.TCSAFLUSH, hidden) - os.write(tty, b'Paste the restricted executor credential JSON (input hidden), then press Enter: ') - while True: - chunk = os.read(tty, 4096) - if not chunk: - raise InstallError('No executor credential was entered') - raw += chunk - text = raw.decode('utf-8', 'replace').strip() - if len(raw) > 16384 or text[:1] not in ('', '{'): - raise InstallError('Invalid restricted executor credential JSON') - if not text: - continue - try: - json.loads(text) - break - except ValueError: - # The credential is one flat object: a closing brace ends it. - if text.endswith('}'): - raise InstallError('Invalid restricted executor credential JSON') from None - finally: - termios.tcsetattr(tty, termios.TCSAFLUSH, previous) - os.write(tty, b'\n') - except termios.error: - raise InstallError('Cannot read the credential from this terminal; use --credential-file') from None - finally: - os.close(tty) - return credential(raw, environment) - - -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') - - -class NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, request, fp, code, message, headers, url): - raise InstallError('Core connection redirects are not supported; check the returned remote_url') - - -def open_connection(request, timeout): - return urllib.request.build_opener(NoRedirect()).open(request, timeout=timeout) - - -class _ConnectionDeadline(Exception): - pass - - -def connection_request(remote, environment, key): - # Only the validated daemon origin receives the restricted credential. - identity(environment, remote) - address = urlsplit(remote) - endpoint = 'https://' + address.netloc + '/api/v1/agent-daemon/connection?' - return urllib.request.Request(endpoint + urlencode({'environment_id': environment}), - headers={'Authorization': 'Bearer ' + key['executor_token']}) - - -def credential_verdict(remote, environment, key): - """One connection read: 'accepted', 401 or 409 when Core rejects the key, or None when Core cannot answer.""" - try: - with open_connection(connection_request(remote, environment, key), 10) as response: - response.read(4097) - return 'accepted' - except urllib.error.HTTPError as error: - return error.code if error.code in (401, 409) else None - except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.HTTPException): - return None - - -def wait_connected(remote, environment, key, container, timeout=60): - request = connection_request(remote, environment, key) - guidance = (' Inspect with: docker --host unix:///var/run/docker.sock logs --tail 100 ' + container - + '. Check the Runtime network, TLS and executor credential, then rerun the same installation command.' - + ' Keep the existing container, volumes and installation state; do not replace history.') - started_at = time.monotonic() - deadline = started_at + timeout - detail = 'Core has not confirmed this Environment connection' - - def deadline_expired(_signal, _frame): - raise _ConnectionDeadline() - - # Socket timeouts only limit inactivity. The Linux CLI needs a process timer - # as well so a slow response cannot keep the overall deadline alive. - previous_handler = signal.signal(signal.SIGALRM, deadline_expired) - previous_timer = signal.setitimer(signal.ITIMER_REAL, max(0.001, timeout)) - try: - while time.monotonic() < deadline: - try: - with open_connection(request, min(10, max(0.1, deadline - time.monotonic()))) as response: - raw = response.read(4097) - if len(raw) > 4096: - raise ValueError() - result = json.loads(raw) - if (not isinstance(result, dict) or set(result) != {'environment_id', 'status'} - or result['environment_id'] != environment - or result['status'] not in ('connected', 'disconnected')): - raise ValueError() - if time.monotonic() >= deadline: - raise _ConnectionDeadline() - if result['status'] == 'connected': - print('Runtime connected to Environment ' + environment + ': ' + container) - return - detail = 'Core reports this Environment disconnected' - except urllib.error.HTTPError as error: - if error.code == 404: - raise InstallError('Core connection check was not found (HTTP 404); route /api/v1 on the Core origin' - ' directly to Core, not to Web.' + guidance) from None - if error.code not in (408, 429, 500, 502, 503, 504): - raise InstallError('Core connection check rejected (HTTP ' + str(error.code) - + '); verify the exact Environment and active executor key.' + guidance) from None - detail = 'Core connection check unavailable (HTTP ' + str(error.code) + ')' - except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead): - detail = 'Cannot reach the Core connection endpoint; check DNS, TLS and network access' - except (ValueError, TypeError, UnicodeError): - raise InstallError('Core returned an invalid connection response.' + guidance) from None - except InstallError as error: - raise InstallError(str(error) + guidance) from None - remaining = deadline - time.monotonic() - if remaining > 0: - time.sleep(min(2, remaining)) - except _ConnectionDeadline: - pass - finally: - signal.setitimer(signal.ITIMER_REAL, 0) - signal.signal(signal.SIGALRM, previous_handler) - if previous_timer[0] > 0: - remaining_timer = max(0.001, previous_timer[0] - (time.monotonic() - started_at)) - signal.setitimer(signal.ITIMER_REAL, remaining_timer, previous_timer[1]) - raise InstallError('Runtime connection timed out: ' + detail + '.' + guidance) - - -def inspect_prior_launch(root, state, replacing=False): - docker = 'docker --host unix:///var/run/docker.sock' - filters = (' --filter label=io.oac.installation=' + state['installation_id'] - + ' --filter label=io.oac.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'oac-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}} {{.Image}}'], 'Cannot inspect retained Runtime') - labels, status, image = raw.strip().rsplit(' ', 2) - labels = json.loads(labels) - except (InstallError, ValueError): - raise InstallError('The prior Runtime cannot be confirmed.' + guidance) from None - expected = {'io.oac.installation': state['installation_id'], - 'io.oac.environment': state['environment_id'], - 'io.oac.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 image not in (state['runtime_image'], state['runtime_manifest']): - raise InstallError('The retained container image does not match this distribution.' + guidance) - if replacing and status in ('running', 'exited', 'restarting', 'created'): - return name - if status == 'running': - print('Runtime already running: ' + name) - return name - if status == 'exited': - raise InstallError('The existing Runtime is stopped. Preserve its history and resume that same container with: ' - + docker + ' start ' + name + '. Then rerun the same installation command to confirm connection.') - raise InstallError('The retained Runtime requires inspection before continuing.' + guidance) - - -def replace_credential(args, manifest, root, state, stored, rejection, supplied): - """Give the same container a rotated credential after Core rejected the stored one. - - rejection is Core's answer for the stored credential: 401 when it was revoked - or rotated, so only that same key rotated can replace it; 409 when the - Environment is bound to a different credential. The container, its volumes and - native history stay. The stored copy changes last, so every interruption - leaves it rejected and rerunning the same command replaces it again. - """ - environment = args.environment_id - name = inspect_prior_launch(root, state, replacing=True) - if rejection == 401: - advice = ('Rotate this same credential in Web (Session > Executor credentials > Rotate); ' - 'a newly issued credential cannot replace it.') - print('Executor credential ' + stored['key_id'] + ' is no longer accepted by Core. ' + advice - + ('' if supplied else ' Then paste the rotated credential.')) - else: - advice = ('Rotate the credential first used for this Environment in Web (Session > Executor credentials > Rotate) ' - 'instead of issuing a new one.') - print('Environment ' + environment + ' is bound to a different executor credential than ' + stored['key_id'] + '. ' - + advice + ('' if supplied else ' Then paste the rotated credential.')) - replacement = supplied or prompt_credential(environment) - if rejection == 401 and replacement['key_id'] != stored['key_id']: - raise InstallError('The pasted credential is ' + replacement['key_id'] + ', not ' + stored['key_id'] + '. ' - + advice + ' Then rerun this command. Nothing was changed.') - verdict = credential_verdict(args.remote, environment, replacement) - if verdict == 409: - raise InstallError('Environment ' + environment + ' is bound to a different executor credential. Rotate the credential ' - 'first used for this Environment instead of issuing a new one, then rerun this command. Nothing was changed.') - if verdict != 'accepted': - raise InstallError('Core did not accept the replacement credential (' - + ('HTTP 401' if verdict == 401 else 'Core unavailable') - + '). Nothing was changed; rerun this command with a currently valid credential.') - launcher = obtain_artifact(manifest, 'native/bin/oac-selfhost', root / 'native/bin/oac-selfhost', args.offline_root) - docker = ['docker', '--host', 'unix:///var/run/docker.sock'] - fd, staged = tempfile.mkstemp(prefix='.executor-key-', dir=root) - try: - with os.fdopen(fd, 'wb') as stream: - stream.write(json.dumps(replacement).encode()) - stream.flush() - os.fsync(stream.fileno()) - checked(docker + ['stop', name], 'Cannot stop the Runtime to replace its credential; rerun this command', timeout=60) - checked([str(launcher), 'replace-credential', '--container', name, '--credential-file', staged], - 'Runtime credential replacement failed; rerun this command', timeout=90) - checked(docker + ['start', name], 'Cannot start the Runtime ' + name + '; rerun this command') - os.replace(staged, root / 'executor-key.json') - finally: - if os.path.exists(staged): - os.unlink(staged) - print('Executor credential replaced; restarted the same Runtime: ' + name) - wait_connected(args.remote, environment, replacement, name) - - -def install(args, root): - target = identity(args.environment_id, args.remote) - refuse_legacy_executor(args.environment_id) - manifest = load_manifest(source_url=args.source_url, offline_root=args.offline_root) - revision = manifest.get('source_commit', '') - runtime_image, runtime_manifest = image_identities(manifest, '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, runtime_manifest=runtime_manifest) - 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(): - key = credential(private_read(root / 'executor-key.json'), args.environment_id) - supplied = credential(private_read(Path(args.credential_file)), args.environment_id) if args.credential_file else None - rejection = credential_verdict(args.remote, args.environment_id, key) - if rejection in (401, 409): - replace_credential(args, manifest, root, state, key, rejection, supplied) - return - name = inspect_prior_launch(root, state) - if supplied and supplied != key: - raise InstallError('Stored executor credential differs; inspect the existing installation') - wait_connected(args.remote, args.environment_id, key, name) - 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 args.credential_file: - key = credential(private_read(Path(args.credential_file)), args.environment_id) - else: - key = prompt_credential(args.environment_id) - write_private(key_file, key) - launcher = obtain_artifact(manifest, 'native/bin/oac-selfhost', root / 'native/bin/oac-selfhost', args.offline_root) - seccomp = obtain_artifact(manifest, 'runtime/seccomp.json', root / 'runtime/seccomp.json', args.offline_root) - runtime_image = ensure_docker_image( - manifest, 'runtime', lambda: runtime_archive(manifest, root, args.offline_root), - ('docker', '--host', 'unix:///var/run/docker.sock')) - 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'oac-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']) - wait_connected(args.remote, args.environment_id, key, result['container']) - print('Stop this user-owned Runtime with: docker --host unix:///var/run/docker.sock stop ' + result['container']) - - -def refuse_legacy_executor(environment): - """Old executors retain their own image and history; this installer never adopts them.""" - path = Path.home() / '.parsar/self-hosted' / environment - try: - path.lstat() - except FileNotFoundError: - return - except OSError: - raise InstallError('Cannot inspect the previous executor directory ' + str(path) - + '; check its ownership before installing. Nothing was changed.') from None - raise InstallError('An executor for this Environment was installed before the OpenAgentCore rename at ' - + str(path) + ' (container parsar-selfhost-…). It keeps working with this Core. ' - 'To replace it, stop and remove that container, then rerun this command; its volumes are not reused.') - - -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) - refuse_legacy_executor(args.environment_id) - root = args.install_dir or Path.home() / '.oac/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_generation_review_regressions.py b/deploy/install/test_generation_review_regressions.py index 648258901..1f9961e44 100644 --- a/deploy/install/test_generation_review_regressions.py +++ b/deploy/install/test_generation_review_regressions.py @@ -132,7 +132,7 @@ def payload_fixture(self, directory): for name in ('node', 'helper'): raw = (name + ' verified artifact').encode() entries[name] = {'filename': name + '-' + source, 'size': len(raw), 'sha256': hashlib.sha256(raw).hexdigest()} - files = {'node-install.pyz': b'node', 'self-hosted-install.pyz': b'self', 'SHA256SUMS': b'fixture', 'runtime/seccomp.json': b'{}', 'manifest.json': json.dumps({'source_commit': source, 'artifacts': entries}).encode()} + files = {'node-install.pyz': b'node', 'SHA256SUMS': b'fixture', 'runtime/seccomp.json': b'{}', 'manifest.json': json.dumps({'source_commit': source, 'artifacts': entries}).encode()} for name, raw in files.items(): path = bundle / name; path.parent.mkdir(parents=True, exist_ok=True); path.write_bytes(raw) install.prepare_node_payload(root, {'mode': 'all'}, bundle) diff --git a/deploy/install/test_install.py b/deploy/install/test_install.py index dfc533548..542f0e10e 100644 --- a/deploy/install/test_install.py +++ b/deploy/install/test_install.py @@ -416,7 +416,7 @@ def test_node_payload_exports_only_matched_distribution_files(self): self.install() payload = self.root / "node-payload/releases" / ("a" * 40) exported = {str(path.relative_to(payload)) for path in payload.rglob("*") if path.is_file()} - self.assertEqual(exported, {"node-install.pyz", "self-hosted-install.pyz", "manifest.json", "SHA256SUMS", + self.assertEqual(exported, {"node-install.pyz", "manifest.json", "SHA256SUMS", "runtime/seccomp.json"}) (payload / "node-install.pyz").write_text("changed") with self.assertRaisesRegex(install.InstallError, "node payload differs"): diff --git a/deploy/install/test_self_hosted_connection.py b/deploy/install/test_self_hosted_connection.py deleted file mode 100644 index 7de3743e8..000000000 --- a/deploy/install/test_self_hosted_connection.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Core confirmation is independent of Docker launch and never recreates history.""" -import contextlib -import io -import http.server -import json -import signal -import threading -import time -import unittest -from unittest.mock import patch -import urllib.error -import urllib.request - -import self_hosted_install as installer - - -class ConnectionTests(unittest.TestCase): - environment = '55b5311c-4df9-43ce-b875-faf901e6d10f' - remote = 'wss://core.example:8443/api/v1/agent-daemon/ws' - key = {'executor_token': 'synthetic-private-token'} - container = 'oac-selfhost-' + 'c' * 32 - - def response(self, status='connected', environment=None): - return io.BytesIO(json.dumps({'status': status, 'environment_id': environment or self.environment}).encode()) - - def wait(self): - installer.wait_connected(self.remote, self.environment, self.key, self.container) - - def test_waits_for_core_and_recovers_transient_failure(self): - failure = urllib.error.HTTPError('url', 503, 'private body', {}, None) - with patch.object(installer, 'open_connection', side_effect=[failure, self.response('disconnected'), self.response()]) as request, \ - patch.object(installer.time, 'sleep') as sleep, contextlib.redirect_stdout(io.StringIO()) as output: - self.wait() - self.assertEqual(request.call_count, 3) - self.assertEqual(sleep.call_count, 2) - sent = request.call_args.args[0] - self.assertEqual(sent.full_url, 'https://core.example:8443/api/v1/agent-daemon/connection?environment_id=' + self.environment) - self.assertEqual(sent.get_header('Authorization'), 'Bearer ' + self.key['executor_token']) - self.assertIn('Runtime connected to Environment', output.getvalue()) - self.assertNotIn(self.key['executor_token'], output.getvalue()) - - def test_permanent_denial_does_not_retry_or_echo_server_body(self): - for status in (401, 403, 404, 409): - with patch.object(installer, 'open_connection', side_effect=urllib.error.HTTPError('url', status, 'private body', {}, None)) as request: - with self.assertRaisesRegex(installer.InstallError, 'HTTP ' + str(status)) as failure: - self.wait() - self.assertEqual(request.call_count, 1) - self.assertIn('logs --tail 100 ' + self.container, str(failure.exception)) - self.assertNotIn('private body', str(failure.exception)) - self.assertEqual('route /api/v1' in str(failure.exception), status == 404) - - def test_deadline_retains_container_and_has_retry_guidance(self): - with patch.object(installer, 'open_connection', return_value=self.response('disconnected')), \ - patch.object(installer.time, 'monotonic', side_effect=[0, 0, 0, 0, 60, 60]), \ - patch.object(installer, 'checked') as mutation: - with self.assertRaisesRegex(installer.InstallError, 'timed out.*disconnected') as failure: - self.wait() - mutation.assert_not_called() - self.assertIn('rerun the same installation command', str(failure.exception)) - self.assertIn('do not replace history', str(failure.exception)) - - def test_wrong_or_malformed_response_cannot_confirm_connection(self): - for response in (self.response(environment='other'), self.response(status='running'), io.BytesIO(b'{}'), io.BytesIO(b'x' * 4097)): - with patch.object(installer, 'open_connection', return_value=response): - with self.assertRaisesRegex(installer.InstallError, 'invalid connection response'): - self.wait() - - def test_slow_response_cannot_outlive_deadline_or_report_success(self): - body = self.response().getvalue() - class SlowResponse(http.server.BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(200) - self.send_header('Content-Length', str(len(body))) - self.end_headers() - try: - for offset in range(0, len(body), 10): - self.wfile.write(body[offset:offset + 10]) - self.wfile.flush() - time.sleep(0.07) - except (BrokenPipeError, ConnectionResetError): - pass - - def log_message(self, *_args): - pass - - server = http.server.HTTPServer(('127.0.0.1', 0), SlowResponse) - worker = threading.Thread(target=server.serve_forever, daemon=True) - worker.start() - previous_handler = signal.getsignal(signal.SIGALRM) - try: - def open_local(request, timeout): - local = urllib.request.Request('http://127.0.0.1:' + str(server.server_port), - headers=dict(request.header_items())) - return urllib.request.urlopen(local, timeout=timeout) - with patch.object(installer, 'open_connection', side_effect=open_local), \ - contextlib.redirect_stdout(io.StringIO()) as output: - started = time.monotonic() - with self.assertRaisesRegex(installer.InstallError, 'connection timed out'): - installer.wait_connected(self.remote, self.environment, self.key, self.container, timeout=0.2) - elapsed = time.monotonic() - started - self.assertLess(elapsed, 0.5) - self.assertNotIn('Runtime connected', output.getvalue()) - self.assertEqual(signal.getitimer(signal.ITIMER_REAL), (0, 0)) - self.assertEqual(signal.getsignal(signal.SIGALRM), previous_handler) - finally: - server.shutdown() - server.server_close() - worker.join(timeout=2) - - def test_timer_and_handler_are_restored_on_success_and_rejection(self): - previous_handler = signal.getsignal(signal.SIGALRM) - try: - signal.setitimer(signal.ITIMER_REAL, 30) - for response in (self.response(), io.BytesIO(b'{}')): - with patch.object(installer, 'open_connection', return_value=response), \ - contextlib.redirect_stdout(io.StringIO()): - try: - self.wait() - except installer.InstallError: - pass - remaining, interval = signal.getitimer(signal.ITIMER_REAL) - self.assertGreater(remaining, 20) - self.assertLessEqual(remaining, 30) - self.assertEqual(interval, 0) - self.assertEqual(signal.getsignal(signal.SIGALRM), previous_handler) - finally: - signal.setitimer(signal.ITIMER_REAL, 0) - - def test_redirect_never_forwards_bearer(self): - for target in ('https://other.example/connection', 'https://core.example/new'): - with self.assertRaisesRegex(installer.InstallError, 'redirects'): - installer.NoRedirect().redirect_request(None, None, 302, '', {}, target) - - -if __name__ == '__main__': - unittest.main() diff --git a/deploy/install/test_self_hosted_install.py b/deploy/install/test_self_hosted_install.py deleted file mode 100644 index fbe4bf9fa..000000000 --- a/deploy/install/test_self_hosted_install.py +++ /dev/null @@ -1,397 +0,0 @@ -import argparse -import contextlib -import io -import json -import os -from pathlib import Path -import pty -import tempfile -import termios -from types import SimpleNamespace - -import distribution -import unittest -from unittest.mock import patch -import uuid - -import self_hosted_install as installer - - -class SelfHostedInstallTests(unittest.TestCase): - def setUp(self): - base = Path.home() / '.oac/tests/selfhost-install' - base.mkdir(parents=True, exist_ok=True) - self.temporary = tempfile.TemporaryDirectory(dir=base) - 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.wait = patch.object(installer, 'wait_connected').start() - self.verdict = patch.object(installer, 'credential_verdict', return_value='accepted').start() - self.addCleanup(patch.stopall) - self.manifest = {'source_commit': 'a' * 40, 'platform': 'linux/amd64', - 'images': {'runtime': 'sha256:' + 'b' * 64}, - 'image_manifest_digests': {'runtime': 'sha256:' + 'd' * 64}} - patch.object(distribution, 'docker_command', side_effect=self.docker_command).start() - - def docker_command(self, arguments, timeout=30): - try: - return SimpleNamespace(returncode=0, stdout=installer.checked(arguments, 'image missing', timeout=timeout)) - except installer.InstallError: - return SimpleNamespace(returncode=1, stdout='') - - 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('oac-selfhost'): - self.assertTrue((self.root / 'launch.json').exists()) - return json.dumps({'container': 'oac-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_connection_failure_retry_preserves_same_container_and_credential(self): - commands = [] - name = 'oac-selfhost-'+'c'*32 - def checked(command, message, timeout=30): - commands.append(command) - if 'container' in command: - state = json.loads(installer.private_read(self.root/'installation.json')) - labels = {'io.oac.installation': state['installation_id'], - 'io.oac.environment': self.environment, - 'io.oac.user-owned': 'true'} - return json.dumps(labels)+' running '+state['runtime_image'] - if 'inspect' in command: return self.manifest['images']['runtime'] + ' linux/amd64' - return json.dumps({'container': name, 'status': 'started'}) - self.wait.side_effect = [installer.InstallError('connection timed out'), None] - 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, 'checked', side_effect=checked), contextlib.redirect_stdout(io.StringIO()): - with self.assertRaisesRegex(installer.InstallError, 'timed out'): - installer.install(self.args, self.root) - original = {name: (self.root/name).read_bytes() for name in ('installation.json', 'launch.json', 'started.json', 'executor-key.json')} - installer.install(self.args, self.root) - self.assertEqual(len([c for c in commands if c[0].endswith('oac-selfhost')]), 1) - self.assertEqual(self.wait.call_count, 2) - self.wait.assert_called_with(self.remote, self.environment, self.key, name) - self.assertEqual(original, {name: (self.root/name).read_bytes() for name in original}) - - def test_hidden_prompt_reads_pretty_or_compact_credential_without_echo(self): - for text in (json.dumps(self.key, indent=2) + '\n', json.dumps(self.key) + '\n'): - pid, terminal = pty.fork() - if pid == 0: - try: - accepted = installer.prompt_credential(self.environment) == self.key - os._exit(0 if accepted and termios.tcgetattr(0)[3] & termios.ECHO else 3) - except BaseException: - os._exit(4) - output = b'' - while b'input hidden' not in output: - output += os.read(terminal, 1024) - os.write(terminal, text.encode()) - while True: - try: - chunk = os.read(terminal, 1024) - except OSError: - break - if not chunk: - break - output += chunk - os.close(terminal) - self.assertEqual(os.waitstatus_to_exitcode(os.waitpid(pid, 0)[1]), 0) - self.assertNotIn(self.key['executor_token'].encode(), output) - - def launched_runtime(self): - """Launch with self.key, then rerun without --credential-file; docker commands in `commands`.""" - name = 'oac-selfhost-' + 'c' * 32 - commands, interrupt = [], [] - def checked(command, message, timeout=30): - commands.append(command) - self.assertNotIn('private-token', json.dumps(command)) - if interrupt and interrupt[0] in command: - interrupt.pop(0) - raise installer.InstallError(message) - if 'container' in command: - state = json.loads(installer.private_read(self.root/'installation.json')) - labels = {'io.oac.installation': state['installation_id'], - 'io.oac.environment': self.environment, - 'io.oac.user-owned': 'true'} - return json.dumps(labels)+' running '+state['runtime_image'] - if 'inspect' in command: return self.manifest['images']['runtime'] + ' linux/amd64' - if command[1:2] == ['replace-credential']: - self.assertEqual(command[2:4], ['--container', name]) - return json.dumps({'container': name, 'status': 'started'}) - patch.object(installer, 'load_manifest', return_value=self.manifest).start() - patch.object(installer, 'obtain_artifact', side_effect=lambda m, n, dest, o: dest).start() - patch.object(installer, 'runtime_archive', return_value=self.root/'runtime.tar').start() - patch.object(installer, 'checked', side_effect=checked).start() - with contextlib.redirect_stdout(io.StringIO()): - installer.install(self.args, self.root) - self.args.credential_file = None - return name, commands, interrupt - - def stored(self): - return json.loads(installer.private_read(self.root/'executor-key.json')) - - def rerun(self, pasted): - with patch.object(installer, 'prompt_credential', return_value=pasted), contextlib.redirect_stdout(io.StringIO()) as output: - installer.install(self.args, self.root) - return output.getvalue() - - def test_revoked_credential_is_replaced_only_by_the_same_key_in_the_same_container(self): - name, commands, _ = self.launched_runtime() - rotated = dict(self.key, executor_token='rotated-private-token') - issued = dict(self.key, key_id=str(uuid.uuid4()), executor_token='issued-private-token') - # Core's connection check accepts a new key while the revoked bound key has no authority. - verdicts = {self.key['executor_token']: 401, rotated['executor_token']: 'accepted', issued['executor_token']: 'accepted'} - self.verdict.side_effect = lambda remote, environment, key: verdicts[key['executor_token']] - retained = {n: (self.root/n).read_bytes() for n in ('installation.json', 'launch.json', 'started.json')} - launched = len(commands) - with self.assertRaisesRegex(installer.InstallError, 'not ' + self.key['key_id'] + '.*same credential'): - self.rerun(issued) - self.assertFalse([c for c in commands[launched:] if 'stop' in c or c[0].endswith('oac-selfhost')]) - self.assertEqual(self.stored(), self.key) - output = self.rerun(rotated) - replaced = [c for c in commands[launched:] if 'inspect' not in c] - self.assertEqual([(c[-2], c[-1]) if c[0] == 'docker' else c[1] for c in replaced], - [('stop', name), 'replace-credential', ('start', name)]) - self.assertEqual(len([c for c in commands if c[0].endswith('oac-selfhost') and c[1] != 'replace-credential']), 1) - self.assertEqual(self.stored(), rotated) - self.assertEqual(retained, {n: (self.root/n).read_bytes() for n in retained}) - self.assertFalse(list(self.root.glob('.executor-key-*'))) - self.wait.assert_called_with(self.remote, self.environment, rotated, name) - self.assertIn('no longer accepted', output) - self.assertNotIn('private-token', output) - - def test_binding_conflict_asks_for_the_credential_first_used(self): - self.launched_runtime() - self.verdict.side_effect = lambda remote, environment, key: 409 - other = dict(self.key, key_id=str(uuid.uuid4()), executor_token='other-private-token') - with patch.object(installer, 'prompt_credential', return_value=other), contextlib.redirect_stdout(io.StringIO()) as output, \ - self.assertRaisesRegex(installer.InstallError, 'first used for this Environment') as failure: - installer.install(self.args, self.root) - self.assertIn('first used for this Environment', output.getvalue()) - self.assertNotIn('same credential', output.getvalue() + str(failure.exception)) - self.assertEqual(self.stored(), self.key) - - def test_replacement_interrupted_before_start_or_store_is_finished_by_rerunning(self): - rotated = dict(self.key, executor_token='rotated-private-token') - name, commands, interrupt = self.launched_runtime() - verdicts = {self.key['executor_token']: 401, rotated['executor_token']: 'accepted'} - self.verdict.side_effect = lambda remote, environment, key: verdicts[key['executor_token']] - replace = os.replace - def store_interrupted(source, target): - if str(target).endswith('executor-key.json'): - raise OSError('interrupted') - return replace(source, target) - for point in ('start', 'store'): - interrupt[:] = ['start'] if point == 'start' else [] - with self.subTest(point=point), self.assertRaises((installer.InstallError, OSError)), \ - patch.object(installer.os, 'replace', side_effect=store_interrupted if point == 'store' else replace): - self.rerun(rotated) - self.assertEqual(commands[-1][-2:], ['start', name]) - self.assertEqual(self.stored(), self.key) - self.assertFalse(list(self.root.glob('.executor-key-*'))) - launched = len(commands) - self.rerun(rotated) - self.assertEqual([c[3] if c[0] == 'docker' else c[1] for c in commands[launched:] if 'inspect' not in c], - ['stop', 'replace-credential', 'start']) - self.assertEqual(self.stored(), rotated) - self.wait.assert_called_with(self.remote, self.environment, rotated, name) - - 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': 'oac-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 'inspect' in command and not any('load' in item for item in commands): - raise installer.InstallError('image missing') - if 'inspect' in command: return self.manifest['images']['runtime'] + ' linux/amd64' - if command[0].endswith('oac-selfhost'): - return json.dumps({'container': 'oac-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[2]) - self.assertIn('inspect', commands[3]) - self.assertTrue(commands[4][0].endswith('oac-selfhost')) - - def test_containerd_cache_passes_actual_id_to_launcher_without_changing_manifest(self): - expected = self.manifest['image_manifest_digests']['runtime'] - published = json.dumps(self.manifest, sort_keys=True) - def checked(command, message, timeout=30): - if 'inspect' in command: - if command[command.index('inspect') + 1] == self.manifest['images']['runtime']: - raise installer.InstallError('image missing') - return expected + ' linux/amd64' - self.assertEqual(command[command.index('--image') + 1], expected) - return json.dumps({'container': 'oac-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', side_effect=AssertionError('warm Runtime download')), \ - patch.object(installer, 'checked', side_effect=checked), contextlib.redirect_stdout(io.StringIO()): - installer.install(self.args, self.root) - self.assertEqual(json.dumps(self.manifest, sort_keys=True), published) - state = json.loads(installer.private_read(self.root/'installation.json')) - self.assertEqual(state['runtime_image'], self.manifest['images']['runtime']) - self.assertEqual(state['runtime_manifest'], expected) - - def test_wrong_loaded_id_or_platform_prevents_launch_receipt(self): - for observed in ('sha256:' + 'f' * 64 + ' linux/amd64', self.manifest['images']['runtime'] + ' linux/arm64'): - loaded = False - def checked(command, message, timeout=30): - nonlocal loaded - if 'load' in command: - loaded = True - return '' - if 'inspect' in command: - if not loaded: - raise installer.InstallError('image missing') - return observed - self.fail('Runtime launcher must not run') - with self.subTest(observed=observed), 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), \ - self.assertRaisesRegex(distribution.DistributionError, 'identity or platform'): - installer.install(self.args, self.root) - self.assertFalse((self.root/'launch.json').exists()) - - def test_existing_launch_verifies_labels_and_reports_state(self): - name = 'oac-selfhost-'+'c'*32 - state = {'installation_id': str(uuid.uuid4()), 'environment_id': self.environment, - 'runtime_image': self.manifest['images']['runtime'], - 'runtime_manifest': self.manifest['image_manifest_digests']['runtime']} - labels = {'io.oac.installation': state['installation_id'], - 'io.oac.environment': self.environment, - 'io.oac.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 '+state['runtime_image']), \ - contextlib.redirect_stdout(io.StringIO()) as output: - installer.inspect_prior_launch(self.root, state) - self.assertIn('already running', output.getvalue()) - self.assertNotIn('connected to Environment', output.getvalue()) - with patch.object(installer, 'checked', return_value=json.dumps(labels)+' running '+state['runtime_manifest']), \ - contextlib.redirect_stdout(io.StringIO()): - self.assertEqual(installer.inspect_prior_launch(self.root, state), name) - with patch.object(installer, 'checked', return_value=json.dumps(labels)+' exited '+state['runtime_image']): - with self.assertRaisesRegex(installer.InstallError, 'start '+name): - installer.inspect_prior_launch(self.root, state) - for status in ('exited', 'restarting', 'created'): - with patch.object(installer, 'checked', return_value=json.dumps(labels)+' '+status+' '+state['runtime_image']): - self.assertEqual(installer.inspect_prior_launch(self.root, state, replacing=True), name) - with patch.object(installer, 'checked', return_value=json.dumps(labels)+' running sha256:'+'f'*64): - with self.assertRaisesRegex(installer.InstallError, 'container image does not match'): - installer.inspect_prior_launch(self.root, state) - labels['io.oac.environment'] = str(uuid.uuid4()) - with patch.object(installer, 'checked', return_value=json.dumps(labels)+' running '+state['runtime_image']): - 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, - 'runtime_image': self.manifest['images']['runtime'], - 'runtime_manifest': self.manifest['image_manifest_digests']['runtime']} - 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.oac.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('oac-selfhost'): 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) - - - - -class LegacySelfHostedTests(unittest.TestCase): - def test_same_environment_refuses_even_with_custom_install_dir(self): - base = Path.home() / '.oac/tests/selfhost-legacy' - base.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(dir=base) as temporary: - home = Path(temporary) - environment = str(uuid.uuid4()) - legacy = home / '.parsar/self-hosted' / environment - legacy.mkdir(parents=True) - retained = legacy / 'executor-key.json' - retained.write_text('private-retained-key') - destination = home / 'custom-new-root' - argv = ['self-hosted-install.pyz', '--source-url', 'https://core.example', '--environment-id', environment, - '--remote', 'wss://core.example/api/v1/agent-daemon/ws', '--install-dir', str(destination)] - with patch.object(installer.Path, 'home', return_value=home), patch.object(installer, 'preflight'), \ - patch.object(installer.sys, 'argv', argv), patch.object(installer, 'install') as install: - with self.assertRaisesRegex(installer.InstallError, 'before the OpenAgentCore rename.*volumes are not reused') as error: - installer.main() - self.assertNotIn('private-retained-key', str(error.exception)) - install.assert_not_called() - self.assertFalse(destination.exists()) - self.assertEqual(retained.read_text(), 'private-retained-key') - installer.refuse_legacy_executor(str(uuid.uuid4())) - - -if __name__ == '__main__': - unittest.main() diff --git a/scripts/build-core-distribution.sh b/scripts/build-core-distribution.sh index 4308ae997..97f83ae1f 100755 --- a/scripts/build-core-distribution.sh +++ b/scripts/build-core-distribution.sh @@ -99,7 +99,7 @@ if [[ "$(go env GOVERSION)" != "$required_go" ]]; then exit 1 fi for file in install.sh install.py configuration.py config_model.py config.schema.json oac_cli.py convert.py rename.py \ - native_service.py node_install.py node_spec.py node_generations.py node_update.py sandbox_setup.py distribution.py self_hosted_install.py \ + native_service.py node_install.py node_spec.py node_generations.py node_update.py sandbox_setup.py distribution.py \ model_provider_sessions.py; do cp "deploy/install/$file" "$bundle/$file" done @@ -158,7 +158,6 @@ export AGENTS_EXECUTOR_BUILD_DIR="$stage/helpers" scripts/build-agents-executor.sh CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$stage/oac-daemon" ./apps/parsar-daemon/cmd/parsar-daemon cp "$stage/oac-daemon" "$bundle/native/bin/oac-daemon" -CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$bundle/native/bin/oac-selfhost" ./services/agents-api/cmd/runtime codex_image="${CORE_DISTRIBUTION_CODEX_IMAGE:-}" claude_image="${CORE_DISTRIBUTION_CLAUDE_IMAGE:-}" mcode_image="${CORE_DISTRIBUTION_MCODE_IMAGE:-}" diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index cd8aa7c29..b7deba672 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -23,7 +23,6 @@ "images/runtime.tar.gz": "runtime.tar.gz", "native/bin/oac-node": "sandbox-node", "native/bin/oac-daemon": "daemon", - "native/bin/oac-selfhost": "runtime-launcher", "native/bin/oac-microsandbox-provider": "microsandbox-provider", "native/microsandbox/msb": "msb", "native/microsandbox/libkrunfw.so.5.6.1": "libkrunfw.so.5.6.1", @@ -240,19 +239,14 @@ def bootstraps(bundle, epoch, revision): if not re.fullmatch(r"[0-9a-f]{40}", revision): raise ValueError("Invalid operator source revision") 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))) - if source == "node_install.py": - for name in ("node_spec.py", "node_generations.py", "node_update.py"): - target = pathlib.Path(directory) / name - shutil.copyfile(bundle / name, target) - os.utime(target, (int(epoch), int(epoch))) - zipapp.create_archive(directory, bundle / output, compressed=True) + with tempfile.TemporaryDirectory(dir=bundle.parent) as directory: + modules = (("node_install.py", "__main__.py"), ("distribution.py", "distribution.py"), + *((name, name) for name in ("node_spec.py", "node_generations.py", "node_update.py"))) + for original, packaged in modules: + target = pathlib.Path(directory) / packaged + shutil.copyfile(bundle / original, target) + os.utime(target, (int(epoch), int(epoch))) + zipapp.create_archive(directory, bundle / "node-install.pyz", compressed=True) with tempfile.TemporaryDirectory(dir=bundle.parent) as directory: for name in OAC_CLI_MODULES: shutil.copyfile(bundle / name, pathlib.Path(directory) / name) diff --git a/scripts/core-distribution-manifest.test.py b/scripts/core-distribution-manifest.test.py index 4051f64d6..df955049c 100644 --- a/scripts/core-distribution-manifest.test.py +++ b/scripts/core-distribution-manifest.test.py @@ -232,7 +232,7 @@ def test_release_location_and_explicit_offline_requirements(self): 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", "node_generations.py", "node_update.py", "self_hosted_install.py", *distribution.OAC_CLI_MODULES): + for name in ("node_install.py", "node_generations.py", "node_update.py", *distribution.OAC_CLI_MODULES): (self.bundle / name).write_text("# " + name + "\n") distribution.bootstraps(self.bundle, "1700000000", "a" * 40) first = [(self.bundle / name).read_bytes() for name in ("node-install.pyz", "oac.pyz")] @@ -242,11 +242,9 @@ def test_bootstraps_include_shared_downloader_and_are_reproducible(self): with zipfile.ZipFile(self.bundle / "oac.pyz") as contents: self.assertEqual(set(contents.namelist()), {"__main__.py", *distribution.OAC_CLI_MODULES}) self.assertIn(("oac_cli.SOURCE_COMMIT = " + repr("a" * 40)).encode(), contents.read("__main__.py")) - 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"} | ({"node_spec.py", "node_generations.py", "node_update.py"} if script == "node_install.py" else set())) - self.assertEqual(contents.read("__main__.py"), (self.bundle / script).read_bytes()) + with zipfile.ZipFile(self.bundle / "node-install.pyz") as contents: + self.assertEqual(set(contents.namelist()), {"__main__.py", "distribution.py", "node_spec.py", "node_generations.py", "node_update.py"}) + self.assertEqual(contents.read("__main__.py"), (self.bundle / "node_install.py").read_bytes()) class BundledDocsTests(unittest.TestCase): diff --git a/scripts/name-allowlist.json b/scripts/name-allowlist.json index 33861441c..6ca4e2a28 100644 --- a/scripts/name-allowlist.json +++ b/scripts/name-allowlist.json @@ -409,16 +409,6 @@ "regex": "parsar-node(?:-)?|\\.parsar(?:/nodes)?|PARSAR_NODE_ENROLLMENT_TOKEN", "reason": "Only the old node service/state paths and retired enrollment variable are recognized for scoped refusal and uninstall guidance." }, - { - "path": "deploy/install/self_hosted_install.py", - "regex": "\\.parsar(?:/self-hosted)?|parsar-selfhost-", - "reason": "The self-hosted installer refuses the old same-Environment installation and retains its existing resources." - }, - { - "path": "deploy/install/test_self_hosted_install.py", - "regex": "\\.parsar(?:/self-hosted)?|parsar-selfhost-", - "reason": "The self-hosted installer refuses the old same-Environment installation and retains its existing resources." - }, { "path": "deploy/install/install.py", "regex": "\\.parsar/core|\\bparsar\\b", diff --git a/services/agents-api/cmd/runtime/main.go b/services/agents-api/cmd/runtime/main.go deleted file mode 100644 index c8ad99068..000000000 --- a/services/agents-api/cmd/runtime/main.go +++ /dev/null @@ -1,154 +0,0 @@ -// 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 { - if len(args) > 0 && args[0] == "replace-credential" { - return replaceCredential(args[1:]) - } - var input docker.SelfHostedLaunch - var credential, seccomp string - flags := flag.NewFlagSet("oac-selfhost", 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 err := supportedHost(); err != nil { - return err - } - key, err := readCredential(credential) - if err != nil { - return err - } - input.Credential = key - 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"}) -} - -// replaceCredential writes a rotated executor credential into the stopped -// user-owned Runtime container; its volumes and native history stay. -func replaceCredential(args []string) error { - var name, credential string - flags := flag.NewFlagSet("oac-selfhost replace-credential", flag.ContinueOnError) - flags.SetOutput(io.Discard) - flags.StringVar(&name, "container", "", "user-owned Runtime container name") - flags.StringVar(&credential, "credential-file", "", "private restricted executor credential JSON") - if flags.Parse(args) != nil || flags.NArg() != 0 || name == "" { - return errors.New("invalid credential replacement arguments") - } - if err := supportedHost(); err != nil { - return err - } - key, err := readCredential(credential) - if 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(), time.Minute) - defer cancel() - if err = docker.ReplaceSelfHostedCredential(ctx, c, name, key); err != nil { - return err - } - return json.NewEncoder(os.Stdout).Encode(map[string]string{"container": name, "status": "credential_replaced"}) -} - -func supportedHost() error { - 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") - } - return nil -} - -func readCredential(path string) (docker.ExecutorCredential, error) { - var key docker.ExecutorCredential - raw, err := readPrivateCredential(path) - if err != nil { - return key, err - } - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if decoder.Decode(&key) != nil || decoder.Decode(new(any)) != io.EOF { - return key, errors.New("invalid restricted executor credential file") - } - return key, nil -} - -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 deleted file mode 100644 index 079988cd6..000000000 --- a/services/agents-api/cmd/runtime/main_test.go +++ /dev/null @@ -1,43 +0,0 @@ -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/sandbox/docker/self_hosted.go b/services/agents-api/internal/sandbox/docker/self_hosted.go deleted file mode 100644 index c12737a66..000000000 --- a/services/agents-api/internal/sandbox/docker/self_hosted.go +++ /dev/null @@ -1,185 +0,0 @@ -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/api/types/mount" - "github.com/moby/moby/client" -) - -// selfHostedCredentialPath is the daemon's credential file, relative to /home. -const selfHostedCredentialPath = "runtime/.oac/daemon/executor-key.json" - -// 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) || !v.Credential.restrictedTo(v.EnvironmentID) { - 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 (c ExecutorCredential) restrictedTo(environment string) bool { - return validID(c.KeyID) && c.EnvironmentID == environment && c.Token != "" && len(c.Token) <= 16384 && !strings.ContainsAny(c.Token, " \t\r\n\x00") -} - -func (v SelfHostedLaunch) Name() string { - hash := sha256.Sum256([]byte(v.InstallationID + ":" + v.EnvironmentID)) - return "oac-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) - // --self-hosted-install makes the daemon name this installer's rerun as the fix for a rejection. - options.Config.Cmd = []string{"connect", "--profile", "default", "--remote", v.RemoteURL, "--environment-id", v.EnvironmentID, "--credential-file", "/home/" + selfHostedCredentialPath, "--self-hosted-install"} - 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/.oac", directory: true}, - {name: "runtime/.oac/daemon", directory: true}, - {name: selfHostedCredentialPath, 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 -} - -// ReplaceSelfHostedCredential writes a rotated executor credential into a -// stopped user-owned Runtime, keeping its container, volumes and native history. -// The container must carry this installation's labels and name for the -// credential's Environment; a running or unlabeled container is refused. -func ReplaceSelfHostedCredential(ctx context.Context, c *client.Client, name string, credential ExecutorCredential) error { - if c == nil || !validID(credential.EnvironmentID) || !credential.restrictedTo(credential.EnvironmentID) { - return sandbox.ErrInvalid - } - inspected, err := c.ContainerInspect(ctx, name, client.ContainerInspectOptions{}) - if err != nil { - return errors.New("cannot inspect Docker Runtime") - } - current := inspected.Container - if err = selfHostedOwned(current, name, credential.EnvironmentID); err != nil { - return err - } - if current.State.Status != container.StateExited && current.State.Status != container.StateCreated { - return errors.New("stop the Runtime before replacing its executor credential") - } - // A planted directory symlink would redirect the write out of the private home. - for _, path := range []string{"/home/runtime", "/home/runtime/.oac", "/home/runtime/.oac/daemon"} { - stat, err := c.ContainerStatPath(ctx, current.ID, client.ContainerStatPathOptions{Path: path}) - if err != nil || !stat.Stat.Mode.IsDir() || stat.Stat.LinkTarget != "" { - return errors.New("the Runtime's private credential directory is missing or redirected") - } - } - raw, _ := json.Marshal(credential) - if err = copyRuntimeFiles(ctx, c, current.ID, "/home", []entry{{name: selfHostedCredentialPath, content: raw}}); err != nil { - return errors.New("cannot replace the Runtime executor credential; inspect the retained container") - } - return nil -} - -// selfHostedOwned requires the labels, name and exactly the volume layout that -// LaunchSelfHosted creates, so a container copying the name and labels cannot -// direct the credential into another volume. -func selfHostedOwned(current container.InspectResponse, name, environment string) error { - if current.Config == nil || current.State == nil || current.HostConfig == nil { - return sandbox.ErrOwnership - } - labels := current.Config.Labels - owner := SelfHostedLaunch{InstallationID: labels[labelPrefix+"installation"], EnvironmentID: labels[labelPrefix+"environment"]} - if labels[labelPrefix+"user-owned"] != "true" || !validID(owner.InstallationID) || owner.EnvironmentID != environment || owner.Name() != name { - return sandbox.ErrOwnership - } - want := map[string]string{"/home": name + "-home", "/environment": name + "-environment", "/workspace": name + "-environment"} - if len(current.Mounts) != len(want) { - return sandbox.ErrOwnership - } - for _, m := range current.Mounts { - if m.Type != mount.TypeVolume || m.Name == "" || want[m.Destination] != m.Name { - return sandbox.ErrOwnership - } - delete(want, m.Destination) - } - for path := range current.HostConfig.Tmpfs { - if path != "/tmp" { - return sandbox.ErrOwnership - } - } - return 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 deleted file mode 100644 index 5e6776cf9..000000000 --- a/services/agents-api/internal/sandbox/docker/self_hosted_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package docker - -import ( - "archive/tar" - "encoding/base64" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "os" - "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) || configuration.Cmd[len(configuration.Cmd)-1] != "--self-hosted-install" { - t.Fatal("credential in argv or remote URL changed") - } - key := "/home/runtime/.oac/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) - } -} - -func TestSelfHostedCredentialReplacementOnlyWritesStoppedOwnedRuntime(t *testing.T) { - value := selfHostedFixture() - name := value.Name() - labels := map[string]string{labelPrefix + "user-owned": "true", labelPrefix + "installation": value.InstallationID, labelPrefix + "environment": value.EnvironmentID} - mounts := func(home string) []map[string]string { - return []map[string]string{{"Type": "volume", "Name": home, "Destination": "/home"}, - {"Type": "volume", "Name": name + "-environment", "Destination": "/environment"}, - {"Type": "volume", "Name": name + "-environment", "Destination": "/workspace"}} - } - rotated := value.Credential - rotated.Token = "rotated-private-token" - for _, tc := range []struct { - name, status, container, home, symlink string - labels map[string]string - replaced bool - }{ - {"stopped", "exited", name, name + "-home", "", labels, true}, - {"running", "running", name, name + "-home", "", labels, false}, - {"unlabeled", "exited", name, name + "-home", "", map[string]string{}, false}, - {"other installation", "exited", "oac-selfhost-" + strings.Repeat("0", 32), name + "-home", "", labels, false}, - {"victim volume at /home", "exited", name, "victim-home", "", labels, false}, - {"redirected daemon directory", "exited", name, name + "-home", "/home/runtime/.oac/daemon", labels, false}, - } { - t.Run(tc.name, func(t *testing.T) { - files := map[string]*tar.Header{} - contents := map[string]string{} - 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" && path == "/containers/"+tc.container+"/json": - _ = json.NewEncoder(w).Encode(map[string]any{"Id": "runtime-id", "Config": map[string]any{"Labels": tc.labels}, - "State": map[string]any{"Status": tc.status}, "HostConfig": map[string]any{"Tmpfs": map[string]string{"/tmp": "rw"}}, "Mounts": mounts(tc.home)}) - case r.Method == "HEAD" && path == "/containers/runtime-id/archive": - stat := container.PathStat{Name: r.URL.Query().Get("path"), Mode: os.ModeDir | 0700} - if stat.Name == tc.symlink { - stat.Mode, stat.LinkTarget = os.ModeSymlink|0777, "/home/victim" - } - raw, _ := json.Marshal(stat) - w.Header().Set("X-Docker-Container-Path-Stat", base64.StdEncoding.EncodeToString(raw)) - case r.Method == "PUT" && path == "/containers/runtime-id/archive": - reader := tar.NewReader(r.Body) - for header, err := reader.Next(); err == nil; header, err = reader.Next() { - content, _ := io.ReadAll(reader) - files[r.URL.Query().Get("path")+"/"+header.Name] = header - contents[r.URL.Query().Get("path")+"/"+header.Name] = string(content) - } - w.WriteHeader(200) - 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() - err = ReplaceSelfHostedCredential(t.Context(), c, tc.container, rotated) - if !tc.replaced { - if err == nil || len(files) != 0 { - t.Fatal("credential written into a running, foreign or redirected Runtime", err) - } - return - } - key := "/home/runtime/.oac/daemon/executor-key.json" - if err != nil || len(files) != 1 || files[key] == nil || files[key].Uid != 1000 || files[key].Gid != 1000 || files[key].Mode != 0600 || - !strings.Contains(contents[key], rotated.Token) { - t.Fatal("credential not replaced privately", err) - } - }) - } -} diff --git a/services/core-console/distribution_test.go b/services/core-console/distribution_test.go index 0372022c5..b76fc158b 100644 --- a/services/core-console/distribution_test.go +++ b/services/core-console/distribution_test.go @@ -12,7 +12,7 @@ import ( 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"}, {payload, "self-hosted-install.pyz", "bootstrap"}} { + 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) } @@ -20,12 +20,12 @@ func TestOfflineArtifactsAreManifestAllowlisted(t *testing.T) { if err := os.Mkdir(filepath.Join(payload, "artifacts"), 0700); err != nil { t.Fatal(err) } - manifest := map[string]any{"artifacts": map[string]any{"native/bin/oac-node": map[string]string{"filename": "matched-node"}, "private/key": map[string]string{"filename": "private-key"}}} + manifest := map[string]any{"artifacts": map[string]any{"native/bin/oac-node": map[string]string{"filename": "matched-node"}, "private/key": map[string]string{"filename": "private-key"}, "native/bin/oac-selfhost": map[string]string{"filename": "retired-launcher"}}} 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"} { + for _, name := range []string{"matched-node", "private-key", "undeclared", "retired-launcher"} { if err := os.WriteFile(filepath.Join(payload, "artifacts", name), []byte("payload"), 0600); err != nil { t.Fatal(err) } @@ -38,7 +38,7 @@ func TestOfflineArtifactsAreManifestAllowlisted(t *testing.T) { defer h.Close() server := httptest.NewServer(h) defer server.Close() - for _, name := range []string{"matched-node", "private-key", "undeclared"} { + for _, name := range []string{"matched-node", "private-key", "undeclared", "retired-launcher"} { req := consoleRequest(t, server, "GET", "/node-install/artifacts/"+name) response, body := responseBody(t, server, req) if name == "matched-node" { @@ -67,7 +67,6 @@ func TestConsoleReportsServableNodeProviders(t *testing.T) { } write(filepath.Join(dist, "index.html"), "console") write(filepath.Join(payload, "node-install.pyz"), "bootstrap") - write(filepath.Join(payload, "self-hosted-install.pyz"), "executor bootstrap") artifacts := map[string]any{} for logical := range map[string]bool{"native/bin/oac-node": true, "images/runtime.tar.gz": true, "native/microsandbox/msb": true} { name := strings.ReplaceAll(logical, "/", "-") diff --git a/services/core-console/node_installation.go b/services/core-console/node_installation.go index 464d7bbda..032615479 100644 --- a/services/core-console/node_installation.go +++ b/services/core-console/node_installation.go @@ -15,15 +15,15 @@ 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.pyz": true, "self-hosted-install.pyz": true, - "manifest.json": true, "SHA256SUMS": true, "runtime/seccomp.json": true, + "node-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/oac-node": true, "native/bin/oac-daemon": true, - "native/bin/oac-selfhost": true, "native/bin/oac-microsandbox-provider": true, - "native/microsandbox/msb": true, "native/microsandbox/libkrunfw.so.5.6.1": true, + "native/bin/oac-microsandbox-provider": true, + "native/microsandbox/msb": true, "native/microsandbox/libkrunfw.so.5.6.1": true, "images/runtime.tar.gz": true, "runtime/seccomp.json": true, } @@ -202,10 +202,8 @@ func (h *console) nodeArtifacts() []string { func (h *console) serveConsoleConfiguration(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(struct { - NodeInstaller bool `json:"node_installer"` - NodeInstallerSHA256 string `json:"node_installer_sha256"` - NodeArtifacts []string `json:"node_artifacts"` - SelfHostedInstaller bool `json:"self_hosted_installer"` - SelfHostedInstallerSHA256 string `json:"self_hosted_installer_sha256"` - }{h.nodePayload != nil, h.nodeInstallerDigest, h.nodeArtifacts(), h.nodePayload != nil, h.selfHostedInstallerDigest}) + NodeInstaller bool `json:"node_installer"` + NodeInstallerSHA256 string `json:"node_installer_sha256"` + NodeArtifacts []string `json:"node_artifacts"` + }{h.nodePayload != nil, h.nodeInstallerDigest, h.nodeArtifacts()}) } diff --git a/services/core-console/node_installation_test.go b/services/core-console/node_installation_test.go index f7983a2df..5c5c3edf1 100644 --- a/services/core-console/node_installation_test.go +++ b/services/core-console/node_installation_test.go @@ -62,6 +62,7 @@ func TestPairedConsoleProxiesOnlyAdministration(t *testing.T) { {"GET", "/console/config", "none", 401}, {"GET", "/node-install/node-install.pyz", "none", 200}, {"GET", "/node-install/caller.key", "none", 404}, + {"GET", "/node-install/self-hosted-install.pyz", "none", 404}, {"POST", "/node-install/node-install.pyz", "none", 405}, } { r := consoleRequest(t, server, tc.method, tc.path) @@ -82,9 +83,9 @@ func TestPairedConsoleProxiesOnlyAdministration(t *testing.T) { t.Fatal("credential leaked") } // Web verifies each downloaded installer against these digests before running it. - selfHostedDigest := sha256.Sum256([]byte("print('self-hosted')")) + nodeDigest := sha256.Sum256([]byte("print('installer')")) if tc.path == "/console/config" && tc.status == 200 && (!strings.Contains(body, `"node_installer":true`) || - !strings.Contains(body, `"self_hosted_installer":true,"self_hosted_installer_sha256":"`+hex.EncodeToString(selfHostedDigest[:])+`"`) || + !strings.Contains(body, `"node_installer_sha256":"`+hex.EncodeToString(nodeDigest[:])+`"`) || strings.Contains(body, "self_hosted_installer") || strings.Contains(body, "sandbox_admin") || strings.Contains(body, "api_keys")) { t.Fatalf("console configuration = %s", body) } diff --git a/services/core-console/server.go b/services/core-console/server.go index ed5e54ff4..faa24ee08 100644 --- a/services/core-console/server.go +++ b/services/core-console/server.go @@ -15,14 +15,13 @@ import ( type console struct { config - root *os.Root - nodePayload *os.Root - nodeInstallerDigest string - selfHostedInstallerDigest string - proxy *httputil.ReverseProxy - transport *http.Transport - host string - auth *consoleAuth + root *os.Root + nodePayload *os.Root + nodeInstallerDigest string + proxy *httputil.ReverseProxy + transport *http.Transport + host string + auth *consoleAuth } type consoleActorContextKey struct{} @@ -49,9 +48,6 @@ func newConsole(c config) (*console, error) { return nil, errors.New("cannot open node installation payload") } h.nodeInstallerDigest, err = installerDigest(h.nodePayload, "node-install.pyz") - if err == nil { - h.selfHostedInstallerDigest, err = installerDigest(h.nodePayload, "self-hosted-install.pyz") - } if err != nil { h.nodePayload.Close() root.Close() From 71b025cd793c22842ca4afbb861773a66c497055 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:35:28 +0000 Subject: [PATCH 44/78] feat(installer): install verified native Harnesses with additive recovery --- apps/parsar-daemon/internal/cli/connect.go | 5 +- .../internal/cli/connect_environment.go | 12 +- .../cli/connect_environment_park_test.go | 22 +- .../internal/cli/native_bundle.go | 204 ++++++++++++ .../internal/cli/native_harness.go | 139 ++++++++ .../internal/cli/native_install.go | 298 ++++++++++++++---- .../internal/cli/native_install_input.go | 114 +++++++ .../internal/cli/native_install_test.go | 214 +++++++++++-- apps/parsar-daemon/internal/cli/root.go | 6 +- 9 files changed, 903 insertions(+), 111 deletions(-) create mode 100644 apps/parsar-daemon/internal/cli/native_bundle.go create mode 100644 apps/parsar-daemon/internal/cli/native_harness.go create mode 100644 apps/parsar-daemon/internal/cli/native_install_input.go diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index 400d49fd6..8ae92131f 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -62,7 +62,6 @@ func runConnect(ctx *runContext, args []string) error { remote = fs.String("remote", "", "self-hosted Environment remote_url, unchanged") environment = fs.String("environment-id", "", "self-hosted Environment ID") credentialFile = fs.String("credential-file", "", "absolute path to protected executor credential JSON") - selfHosted = fs.Bool("self-hosted-install", false, "started by the self-hosted installer: a rejection names its rerun as the fix") ) if err := fs.Parse(args); err != nil { return fmt.Errorf("connect: parse flags: %w", err) @@ -78,11 +77,11 @@ func runConnect(ctx *runContext, args []string) error { if err := paths.ValidateProfile(*profile); err != nil { return fmt.Errorf("connect: %w", err) } - if *remote != "" || *environment != "" || *credentialFile != "" || *selfHosted { + if *remote != "" || *environment != "" || *credentialFile != "" { if *serverURL != "" || *token != "" || *deviceName != "" || fs.NArg() != 0 { return errors.New("connect: Environment enrollment cannot use pairing options or positional arguments") } - return runEnvironmentConnect(ctx, *profile, *background, *remote, *environment, *credentialFile, *selfHosted) + return runEnvironmentConnect(ctx, *profile, *background, *remote, *environment, *credentialFile) } inlinePair := strings.TrimSpace(*serverURL) != "" || strings.TrimSpace(*token) != "" diff --git a/apps/parsar-daemon/internal/cli/connect_environment.go b/apps/parsar-daemon/internal/cli/connect_environment.go index 72ea39eb7..90ef04fa7 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment.go +++ b/apps/parsar-daemon/internal/cli/connect_environment.go @@ -129,7 +129,7 @@ func environmentBootstrap(ctx context.Context, prof auth.Profile, remote string) return boot, nil } -func runEnvironmentConnect(rc *runContext, profile string, background bool, remote, environment, credentialFile string, selfHosted bool) error { +func runEnvironmentConnect(rc *runContext, profile string, background bool, remote, environment, credentialFile string) error { base, err := environmentBase(remote) if err != nil { return err @@ -148,7 +148,7 @@ func runEnvironmentConnect(rc *runContext, profile string, background bool, remo // the connection parks instead. parks := !background || daemonize.IsBackgroundChild() rejected := func(err error) error { - if message := environmentRejection(err, keyID, environment, selfHosted); parks && message != "" { + if message := environmentRejection(err, keyID, environment); parks && message != "" { return parkEnvironment(rc.stderr, message) } return err @@ -182,13 +182,9 @@ var ( // environmentRejection names the fix for a permanent Environment rejection: // enrollment 401 or 409, or a permanent WebSocket rejection or close. It returns // "" for anything else (transport failures, 5xx, 404), which keeps the ordinary -// failure exit so the Runtime's restart policy retries it. Only a Runtime the -// self-hosted installer started names that installer's rerun and container. -func environmentRejection(err error, keyID, environment string, selfHosted bool) string { +// failure exit so the Runtime's restart policy retries it. +func environmentRejection(err error, keyID, environment string) string { reconnect, remove := "install it for this Runtime and restart it", "stop this Runtime" - if selfHosted { - reconnect, remove = "rerun the self-hosted install command on this host and paste it", "stop this container" - } switch { case errors.Is(err, errEnvironmentBindingConflict): return fmt.Sprintf("executor credential %s cannot connect: Environment %s is bound to a different executor credential. This Runtime will not retry. Rotate the credential first used for this Environment instead of issuing a new one, then %s. To remove this Runtime instead, %s.", keyID, environment, reconnect, remove) diff --git a/apps/parsar-daemon/internal/cli/connect_environment_park_test.go b/apps/parsar-daemon/internal/cli/connect_environment_park_test.go index d1cd31cf4..9d15f20ef 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_park_test.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_park_test.go @@ -76,7 +76,7 @@ func TestEnvironmentRejectionParksUntilTerminated(t *testing.T) { t.Fatal(err) } args, _ := json.Marshal([]string{"connect", "--remote", "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/agent-daemon/ws", - "--environment-id", environment, "--credential-file", credential, "--self-hosted-install"}) + "--environment-id", environment, "--credential-file", credential}) cmd := exec.Command(os.Args[0], "-test.run=^TestEnvironmentRejectionParksUntilTerminated$") cmd.Env = append(os.Environ(), "OAC_TEST_ENVIRONMENT_CONNECT="+string(args), "OAC_RUNTIME_HOME="+home) var stderr lockedBuffer @@ -137,19 +137,17 @@ func TestEnvironmentRejectionParksUntilTerminated(t *testing.T) { func TestEnvironmentRejectionClassifiesConnectionErrors(t *testing.T) { for _, tc := range []struct { - err error - selfHosted bool - want string + err error + want string }{ - {fmt.Errorf("connect: runtime deleted: %w", transport.ErrPermanent), true, "rerun the self-hosted install command"}, - {fmt.Errorf("connect: runtime deleted: %w", transport.ErrPermanent), false, "install it for this Runtime and restart it"}, - {fmt.Errorf("connect: permanent error: %w: %w", transport.ErrPermanent, transport.ErrIncompatibleVersion), true, "daemon version"}, - {errors.New("connect: bootstrap: Environment bootstrap failed"), true, ""}, - {nil, true, ""}, + {fmt.Errorf("connect: runtime deleted: %w", transport.ErrPermanent), "install it for this Runtime and restart it"}, + {fmt.Errorf("connect: permanent error: %w: %w", transport.ErrPermanent, transport.ErrIncompatibleVersion), "daemon version"}, + {errors.New("connect: bootstrap: Environment bootstrap failed"), ""}, + {nil, ""}, } { - got := environmentRejection(tc.err, uuid.NewString(), uuid.NewString(), tc.selfHosted) - if tc.want == "" && got != "" || !strings.Contains(got, tc.want) || !tc.selfHosted && strings.Contains(got, "container") { - t.Errorf("environmentRejection(%v, %v) = %q", tc.err, tc.selfHosted, got) + got := environmentRejection(tc.err, uuid.NewString(), uuid.NewString()) + if tc.want == "" && got != "" || !strings.Contains(got, tc.want) || strings.Contains(got, "container") { + t.Errorf("environmentRejection(%v) = %q", tc.err, got) } } } diff --git a/apps/parsar-daemon/internal/cli/native_bundle.go b/apps/parsar-daemon/internal/cli/native_bundle.go new file mode 100644 index 000000000..e4baf6d44 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_bundle.go @@ -0,0 +1,204 @@ +package cli + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "reflect" + "runtime" + "slices" + "strings" +) + +// This is release content, never an input file for installation options. +type nativeBundle struct { + Schema int `json:"schema"` + DaemonVersion string `json:"daemon_version"` + OS string `json:"os"` + Arch string `json:"arch"` + Components map[string]nativeComponent `json:"components"` +} +type nativeComponent struct { + Version string `json:"version"` + Files map[string]nativeFile `json:"files"` +} +type nativeFile struct { + SHA256 string `json:"sha256"` + Executable bool `json:"executable"` +} + +var nativePins = map[string]string{"node": "22.22.0", "codex": "0.153.4", "claude": "0.3.269", "minimax": "0.4.12"} + +func readNativeJSON(file string, value any) error { + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() + raw, err := io.ReadAll(io.LimitReader(f, 16<<20+1)) + if err != nil || len(raw) > 16<<20 { + return errors.New("installation metadata unavailable") + } + return decodeEnvironmentJSON(raw, value) +} + +func readNativeBundle(directory string, selected []string) (nativeBundle, error) { + var b nativeBundle + if readNativeJSON(filepath.Join(directory, "bundle.json"), &b) != nil { + return b, errors.New("install: use the native installer distribution containing bundle.json") + } + if b.Schema != 1 || b.DaemonVersion != Version || b.OS != runtime.GOOS || b.Arch != runtime.GOARCH { + return b, errors.New("install: distribution version or platform mismatch; use a matching current release") + } + for _, name := range append([]string{"node"}, selected...) { + c, ok := b.Components[name] + if !ok || c.Version != nativePins[name] || len(c.Files) == 0 { + return b, fmt.Errorf("install: pinned %s component missing from this distribution", name) + } + for file, info := range c.Files { + if !validBundlePath(file) || len(info.SHA256) != 64 { + return b, errors.New("install: invalid component file manifest") + } + if _, err := hex.DecodeString(info.SHA256); err != nil { + return b, errors.New("install: invalid component digest") + } + } + } + return b, nil +} + +func validBundlePath(name string) bool { + return name != "." && path.Clean(name) == name && !strings.ContainsAny(name, "\\\x00\r\n:") && filepath.IsLocal(filepath.FromSlash(name)) && name != ".oac-install.json" +} + +func nativeComponentRoot(root, name string) string { return filepath.Join(root, "components", name) } + +func componentReceipt(root string) (nativeComponent, error) { + var c nativeComponent + err := readNativeJSON(filepath.Join(root, ".oac-install.json"), &c) + return c, err +} + +func checkComponentFiles(directory string, c nativeComponent) error { + root, err := os.OpenRoot(directory) + if err != nil { + return err + } + defer root.Close() + for name, expected := range c.Files { + if !validBundlePath(name) { + return errors.New("invalid installed path") + } + f, err := root.Open(filepath.FromSlash(name)) + if err != nil { + return err + } + info, e := f.Stat() + h := sha256.New() + _, copyErr := io.Copy(h, f) + f.Close() + if e != nil || !info.Mode().IsRegular() || copyErr != nil || hex.EncodeToString(h.Sum(nil)) != expected.SHA256 || (runtime.GOOS != "windows" && expected.Executable && info.Mode().Perm()&0100 == 0) { + return errors.New("installed files do not match the verified release") + } + } + return nil +} + +// A complete component is published once. An interruption cannot turn a partial +// copy into an installation; a subsequent run can reuse an already-published one. +func installNativeComponent(source, root, name string, expected nativeComponent) error { + dest := nativeComponentRoot(root, name) + if _, err := os.Lstat(dest); err == nil { + got, e := componentReceipt(dest) + if e != nil || !reflect.DeepEqual(got, expected) { + return fmt.Errorf("install: existing %s is incompatible; preserve it and use a separate installation directory", name) + } + if checkComponentFiles(dest, got) != nil { + return fmt.Errorf("install: existing %s is incomplete or modified; reinstall separately", name) + } + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + parent := filepath.Dir(dest) + if err := os.MkdirAll(parent, 0700); err != nil { + return err + } + tmp, err := os.MkdirTemp(parent, ".install-"+name+"-") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + src, err := os.OpenRoot(filepath.Join(source, "components", name)) + if err != nil { + return err + } + defer src.Close() + for name, expectedFile := range expected.Files { + if !validBundlePath(name) { + return errors.New("install: invalid component path") + } + in, err := src.Open(filepath.FromSlash(name)) + if err != nil { + return errors.New("install: component file unavailable") + } + info, err := in.Stat() + if err != nil || !info.Mode().IsRegular() { + in.Close() + return errors.New("install: component must contain regular files") + } + destination := filepath.Join(tmp, filepath.FromSlash(name)) + if err = os.MkdirAll(filepath.Dir(destination), 0700); err != nil { + in.Close() + return err + } + mode := os.FileMode(0600) + if expectedFile.Executable { + mode = 0700 + } + out, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + in.Close() + return err + } + h := sha256.New() + _, err = io.Copy(io.MultiWriter(out, h), in) + in.Close() + if err == nil { + err = out.Sync() + } + closeErr := out.Close() + if err != nil || closeErr != nil || hex.EncodeToString(h.Sum(nil)) != expectedFile.SHA256 { + return errors.New("install: component checksum failed") + } + } + raw, _ := json.Marshal(expected) + if err = os.WriteFile(filepath.Join(tmp, ".oac-install.json"), raw, 0600); err != nil { + return err + } + return os.Rename(tmp, dest) +} + +func selectedNativeHarnesses(value string) ([]string, error) { + var out []string + for _, name := range strings.Split(value, ",") { + name = strings.TrimSpace(name) + if name != "codex" && name != "claude" && name != "minimax" { + return nil, errors.New("install: --harness requires codex,claude,minimax (comma-separated)") + } + if name == "minimax" && runtime.GOOS == "windows" { + return nil, errors.New("install: MiniMax Code adapter is unsupported on Windows; select Codex or Claude Code") + } + if !slices.Contains(out, name) { + out = append(out, name) + } + } + slices.Sort(out) + return out, nil +} diff --git a/apps/parsar-daemon/internal/cli/native_harness.go b/apps/parsar-daemon/internal/cli/native_harness.go new file mode 100644 index 000000000..23144e643 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_harness.go @@ -0,0 +1,139 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudesdk" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" +) + +func nativeExe(name string) string { + if runtime.GOOS == "windows" { + return name + ".exe" + } + return name +} +func nativeNode(root string) string { + base := nativeComponentRoot(root, "node") + if runtime.GOOS != "windows" { + base = filepath.Join(base, "bin") + } + return filepath.Join(base, nativeExe("node")) +} + +func nativeHarnessEnvironment(root string, selected []string) map[string]string { + node := nativeNode(root) + values := map[string]string{"PATH": filepath.Dir(node) + string(os.PathListSeparator) + os.Getenv("PATH")} + for _, name := range selected { + dir := nativeComponentRoot(root, name) + switch name { + case "codex": + values["OAC_RUNTIME_CODEX_BIN"] = filepath.Join(dir, "bin", nativeExe("codex")) + case "claude": + values[claudeSDKEntrypointEnv] = filepath.Join(dir, "dist", "main.js") + values[claudeSDKNodeEnv] = node + case "minimax": + values["OAC_RUNTIME_MCODE_BIN"] = filepath.Join(dir, "native", "cli.js") + values["OAC_RUNTIME_MCODE_NODE"] = node + values["OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE"] = filepath.Join(dir, "launch.mjs") + values["OAC_RUNTIME_MCODE_AGENTS_API"] = "1" + values["PATH"] = filepath.Join(dir, "bin") + string(os.PathListSeparator) + values["PATH"] + } + } + return values +} + +func withNativeEnv(values map[string]string) []string { + env := make([]string, 0, len(os.Environ())+len(values)) + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + // Windows environment names are case-insensitive. + replaced := false + for k := range values { + if strings.EqualFold(k, key) { + replaced = true + break + } + } + if !replaced { + env = append(env, entry) + } + } + for key, value := range values { + env = append(env, key+"="+value) + } + return env +} + +// Native diagnostics are deliberately discarded: dependencies may echo their +// environment. Readiness is separate from model credentials and a live Turn. +func nativeProbeCommand(binary string, args, env []string, dir string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Env: env, Dir: dir, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) + if err != nil { + return "", errors.New("native component failed to start") + } + defer p.Cancel() + done := make(chan struct{}) + go func() { _, _ = io.Copy(io.Discard, p.Stderr); close(done) }() + raw, err := io.ReadAll(io.LimitReader(p.Stdout, 64*1024+1)) + if err != nil || len(raw) > 64*1024 { + p.Cancel() + } + _, _ = io.Copy(io.Discard, p.Stdout) + <-done + waitErr := p.Wait() + if err != nil || waitErr != nil || ctx.Err() != nil || len(raw) > 64*1024 { + return "", errors.New("native component compatibility check failed") + } + return strings.TrimSpace(string(raw)), nil +} + +var probeNativeInstallation = checkNativeInstallation + +func checkNativeInstallation(root string, selected []string) error { + values := nativeHarnessEnvironment(root, selected) + env := withNativeEnv(values) + node := nativeNode(root) + version, err := nativeProbeCommand(node, []string{"--version"}, env, root) + if err != nil || version != "v"+nativePins["node"] { + return errors.New("install: bundled Node is unavailable or incompatible; use the distribution for this operating system and architecture") + } + for _, name := range selected { + dir := nativeComponentRoot(root, name) + switch name { + case "codex": + version, err = nativeProbeCommand(values["OAC_RUNTIME_CODEX_BIN"], []string{"--version"}, env, root) + if err == nil && version != "codex-cli "+nativePins[name] { + err = errors.New("version mismatch") + } + case "claude": + var info claudesdk.RuntimeInfo + info, err = claudesdk.CheckRuntime(context.Background(), claudesdk.Config{Node: node, Entrypoint: values[claudeSDKEntrypointEnv], Env: env}) + if err == nil && (info.SDK != nativePins[name] || !info.SupportsLocalRuntime()) { + err = errors.New("adapter contract mismatch") + } + case "minimax": + version, err = nativeProbeCommand(node, []string{filepath.Join(dir, "native", "cli.js"), "--version"}, env, root) + if err == nil && version != nativePins[name] { + err = errors.New("version mismatch") + } + if err == nil { + _, err = nativeProbeCommand(node, []string{filepath.Join(dir, "check.mjs")}, env, root) + } + } + if err != nil { + return fmt.Errorf("install: %s compatibility check failed; verify OS dependencies (Claude on Windows needs Git Bash; MiniMax needs Bash) and use a matching release; no files were replaced", name) + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/cli/native_install.go b/apps/parsar-daemon/internal/cli/native_install.go index e1273a284..1b6022dd4 100644 --- a/apps/parsar-daemon/internal/cli/native_install.go +++ b/apps/parsar-daemon/internal/cli/native_install.go @@ -1,26 +1,32 @@ package cli import ( + "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" + "flag" "fmt" + "io" "os" "path/filepath" + "slices" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) -// Native installation is a user-owned configuration, not an OS service or sandbox. -// The same binary can be run by an operator's preferred service manager. +// Installation is local state, not an options-file input or an OS service. type nativeInstallation struct { - Version string `json:"version"` - Remote string `json:"remote_url"` - Environment string `json:"environment_id"` - Workspace string `json:"workspace_directory"` - Credential string `json:"credential_file"` - CapabilityDirectory string `json:"capability_directory"` - ToolEnvironmentFile string `json:"tool_environment_file,omitempty"` + Version string `json:"version"` + Remote string `json:"remote_url"` + Environment string `json:"environment_id"` + Workspace string `json:"workspace_directory"` + Credential string `json:"credential_file"` + CapabilityDirectory string `json:"capability_directory"` + ToolEnvironmentFile string `json:"tool_environment_file,omitempty"` + Harnesses []string `json:"harnesses"` } func nativeInstallationPath() (string, error) { @@ -31,109 +37,277 @@ func nativeInstallationPath() (string, error) { return filepath.Join(root, "daemon", "installation.json"), nil } -func runInstall(rc *runContext, args []string) error { - flags := newFlagSet("install") - remote := flags.String("remote", "", "Environment remote_url from Core") - environment := flags.String("environment-id", "", "Environment ID from Core") - workspace := flags.String("workspace", "", "existing absolute workspace directory") - credential := flags.String("credential-file", "", "absolute executor credential JSON file") - capabilities := flags.String("capability-directory", "", "installed capability snapshot directory (defaults to Runtime state)") - toolEnv := flags.String("tool-env-file", "", "optional absolute JSON file with tool environment variables") - if err := flags.Parse(args); err != nil { - return err +func lockNativeInstallation(root string) (*os.Root, func(), error) { + dir := filepath.Join(root, "daemon") + if err := runtimefs.EnsurePrivateDir(dir); err != nil { + return nil, nil, err } - if flags.NArg() != 0 { - return errors.New("install: unexpected positional arguments") + held, err := os.OpenRoot(dir) + if err != nil { + return nil, nil, err } - if _, err := environmentBase(*remote); err != nil { - return err + unlock, err := runtimefs.LockDirectory(held) + if err != nil { + held.Close() + return nil, nil, errors.New("installation is busy; wait for the other operation to finish") } - if !environmentUUID(*environment) { - return errors.New("install: canonical Environment ID required") + return held, func() { unlock(); held.Close() }, nil +} + +func validateNativeInstallation(c nativeInstallation) error { + if c.Version != Version { + return errors.New("installation version is unsupported; preserve this installation and reinstall in a separate directory") } - root, err := paths.Root() - if err != nil { + if _, err := environmentBase(c.Remote); err != nil { return err } - if *capabilities == "" { - *capabilities = filepath.Join(root, "capabilities") + if !environmentUUID(c.Environment) { + return errors.New("install: --environment-id requires a canonical Environment ID") } - for _, path := range []string{*workspace, *credential, *capabilities} { - if runtimefs.ValidateLocalPath(path) != nil { - return errors.New("install: clean absolute paths required") + for _, p := range []string{c.Workspace, c.Credential, c.CapabilityDirectory} { + if runtimefs.ValidateLocalPath(p) != nil { + return errors.New("install: --workspace, --credential-file and capability destination must be clean absolute paths") } } - info, err := os.Stat(*workspace) + info, err := os.Stat(c.Workspace) if err != nil || !info.IsDir() { - return errors.New("install: existing workspace directory required") + return errors.New("install: --workspace requires an existing directory") + } + if _, _, err = executorCredential(c.Credential, c.Environment); err != nil { + return err } - if _, _, err = executorCredential(*credential, *environment); err != nil { + if c.ToolEnvironmentFile != "" { + var values map[string]string + if runtimefs.ValidateLocalPath(c.ToolEnvironmentFile) != nil || readNativeJSON(c.ToolEnvironmentFile, &values) != nil { + return errors.New("install: --tool-env-file requires an absolute JSON file containing string values") + } + } + return nil +} + +func runInstall(rc *runContext, args []string) error { + o, err := parseNativeInstall(rc, args) + if errors.Is(err, flag.ErrHelp) { + return nil + } + if err != nil { return err } - if *toolEnv != "" && runtimefs.ValidateLocalPath(*toolEnv) != nil { - return errors.New("install: absolute tool environment file required") + if runtimefs.ValidateLocalPath(o.Directory) != nil || runtimefs.ValidateLocalPath(o.Bundle) != nil { + return errors.New("install: --install-dir and --bundle-dir must be clean absolute directories") } - config := nativeInstallation{Version: Version, Remote: *remote, Environment: *environment, Workspace: *workspace, Credential: *credential, CapabilityDirectory: *capabilities, ToolEnvironmentFile: *toolEnv} - path, err := nativeInstallationPath() + selected, err := selectedNativeHarnesses(o.Harness) if err != nil { return err } - if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil { + o.Version = Version + if o.CapabilityDirectory == "" { + o.CapabilityDirectory = filepath.Join(o.Directory, "capabilities") + } + if err = validateNativeInstallation(o.nativeInstallation); err != nil { return err } - held, err := os.OpenRoot(filepath.Dir(path)) + bundle, err := readNativeBundle(o.Bundle, selected) if err != nil { return err } - defer held.Close() - unlock, err := runtimefs.LockDirectory(held) + held, unlock, err := lockNativeInstallation(o.Directory) if err != nil { return err } defer unlock() - if _, err = held.Stat(filepath.Base(path)); err == nil { - return errors.New("install: installation already exists; in-place upgrades are unsupported, use its current binary or reinstall explicitly") + var previous nativeInstallation + raw, err := runtimefs.ReadPrivate(held, "installation.json", 1<<20) + if err == nil { + if decodeEnvironmentJSON(raw, &previous) != nil || previous.Version != Version || len(previous.Harnesses) == 0 { + return errors.New("install: existing installation is unsupported; preserve it and reinstall separately") + } + wanted := o.nativeInstallation + wanted.Harnesses = nil + comparison := previous + comparison.Harnesses = nil + before, _ := json.Marshal(comparison) + after, _ := json.Marshal(wanted) + if !bytes.Equal(before, after) { + return errors.New("install: existing connection settings differ; additive installation cannot replace them") + } } else if !errors.Is(err, os.ErrNotExist) { + return errors.New("install: cannot read existing installation") + } + all := append([]string{}, previous.Harnesses...) + for _, name := range selected { + if !slices.Contains(all, name) { + all = append(all, name) + } + } + slices.Sort(all) + // Verify existing components before adding any new one; never repair or + // upgrade an installed dependency as a side effect of adding a Harness. + if len(previous.Harnesses) > 0 { + if err = verifyNativeComponents(o.Directory, previous.Harnesses); err != nil { + return err + } + } + if err = installNativeBinary(o.Directory); err != nil { + return err + } + for _, name := range append([]string{"node"}, selected...) { + if err = installNativeComponent(o.Bundle, o.Directory, name, bundle.Components[name]); err != nil { + return err + } + } + if err = probeNativeInstallation(o.Directory, all); err != nil { return err } - raw, _ := json.MarshalIndent(config, "", " ") - if err = runtimefs.WritePrivateAtomic(held, filepath.Base(path), raw); err != nil { + o.Harnesses = all + raw, _ = json.MarshalIndent(o.nativeInstallation, "", " ") + if err = runtimefs.WritePrivateAtomic(held, "installation.json", raw); err != nil { return err } - fmt.Fprintln(rc.stdout, "Installed daemon configuration:", path) - fmt.Fprintln(rc.stdout, "Run oac-daemon start; use stop, status and logs with the same OAC_RUNTIME_HOME.") + fmt.Fprintln(rc.stdout, "Installation: ready; verified Harnesses:", all) + fmt.Fprintln(rc.stdout, "Daemon connection: not checked by install; run the installed oac-daemon start, then status.") + fmt.Fprintln(rc.stdout, "Model configuration: not checked; configure the Session model provider in Core and send a Turn.") return nil } +func verifyNativeComponents(root string, selected []string) error { + for _, name := range append([]string{"node"}, selected...) { + if _, ok := nativePins[name]; !ok { + return errors.New("installation contains an unsupported Harness") + } + c, err := componentReceipt(nativeComponentRoot(root, name)) + if err != nil || c.Version != nativePins[name] || len(c.Files) == 0 || checkComponentFiles(nativeComponentRoot(root, name), c) != nil { + return fmt.Errorf("installed %s is missing, modified or incompatible; reinstall separately (no automatic repair or upgrade)", name) + } + } + return nil +} + +func installNativeBinary(root string) error { + exe, err := os.Executable() + if err != nil { + return err + } + dir := filepath.Join(root, "bin") + if err = os.MkdirAll(dir, 0700); err != nil { + return err + } + dest := filepath.Join(dir, nativeExe("oac-daemon")) + digest := func(name string) (string, error) { + f, e := os.Open(name) + if e != nil { + return "", e + } + defer f.Close() + h := sha256.New() + _, e = io.Copy(h, f) + return hex.EncodeToString(h.Sum(nil)), e + } + want, err := digest(exe) + if err != nil { + return err + } + if got, e := digest(dest); e == nil { + if got != want { + return errors.New("install: existing daemon binary differs; in-place upgrades are unsupported") + } + return nil + } else if !errors.Is(e, os.ErrNotExist) { + return e + } + in, err := os.Open(exe) + if err != nil { + return err + } + defer in.Close() + out, err := os.CreateTemp(dir, ".oac-daemon-") + if err != nil { + return err + } + defer os.Remove(out.Name()) + _, err = io.Copy(out, in) + if err == nil { + err = out.Chmod(0700) + } + if err == nil { + err = out.Sync() + } + closeErr := out.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + return os.Rename(out.Name(), dest) +} + func runStart(rc *runContext, args []string) error { flags := newFlagSet("start") foreground := flags.Bool("foreground", false, "stay attached to this terminal") if err := flags.Parse(args); err != nil { - return err + return errors.New("start: invalid arguments") } if flags.NArg() != 0 { return errors.New("start: unexpected positional arguments") } - path, err := nativeInstallationPath() + root, err := paths.Root() + if err != nil { + return err + } + held, unlock, err := lockNativeInstallation(root) if err != nil { return err } - raw, err := os.ReadFile(path) var config nativeInstallation - if err != nil || decodeEnvironmentJSON(raw, &config) != nil { - return errors.New("start: installation configuration unavailable; run install first") + raw, err := runtimefs.ReadPrivate(held, "installation.json", 1<<20) + if err == nil { + err = decodeEnvironmentJSON(raw, &config) + } + if err == nil { + err = validateNativeInstallation(config) + } + if err == nil && len(config.Harnesses) == 0 { + err = errors.New("start: no installed Harnesses; rerun install with --harness") + } + if err == nil { + err = verifyNativeComponents(root, config.Harnesses) } - if config.Version != Version { - return errors.New("start: this installation version is unsupported; reinstall explicitly") + if err == nil { + err = probeNativeInstallation(root, config.Harnesses) } - for key, value := range map[string]string{ - "OAC_RUNTIME_WORKSPACE": config.Workspace, - "OAC_RUNTIME_CAPABILITY_DIRECTORY": config.CapabilityDirectory, - "OAC_RUNTIME_TOOL_ENV_FILE": config.ToolEnvironmentFile, - } { + unlock() + if err != nil { + return fmt.Errorf("start: installation unavailable or incompatible: %w", err) + } + values := nativeHarnessEnvironment(root, config.Harnesses) + values["OAC_RUNTIME_WORKSPACE"] = config.Workspace + values["OAC_RUNTIME_CAPABILITY_DIRECTORY"] = config.CapabilityDirectory + values["OAC_RUNTIME_TOOL_ENV_FILE"] = config.ToolEnvironmentFile + for key, value := range values { if err = os.Setenv(key, value); err != nil { return err } } - return runEnvironmentConnect(rc, paths.DefaultProfile, !*foreground, config.Remote, config.Environment, config.Credential, false) + return runEnvironmentConnect(rc, paths.DefaultProfile, !*foreground, config.Remote, config.Environment, config.Credential) +} + +func useInstalledNativeHome() { + if os.Getenv("OAC_RUNTIME_HOME") != "" { + return + } + exe, err := os.Executable() + if err != nil { + return + } + if resolved, e := filepath.EvalSymlinks(exe); e == nil { + exe = resolved + } + if filepath.Base(filepath.Dir(exe)) != "bin" { + return + } + root := filepath.Dir(filepath.Dir(exe)) + if _, err = os.Stat(filepath.Join(root, "daemon", "installation.json")); err == nil { + _ = os.Setenv("OAC_RUNTIME_HOME", root) + } } diff --git a/apps/parsar-daemon/internal/cli/native_install_input.go b/apps/parsar-daemon/internal/cli/native_install_input.go new file mode 100644 index 000000000..1ac4c9431 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_install_input.go @@ -0,0 +1,114 @@ +package cli + +import ( + "bufio" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +type nativeInstallOptions struct { + nativeInstallation + Directory, Bundle, Harness string + Interactive, NonInteractive bool +} + +func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, error) { + var o nativeInstallOptions + flags := newFlagSet("install") + flags.StringVar(&o.Remote, "remote", "", "Environment remote_url from Core") + flags.StringVar(&o.Environment, "environment-id", "", "Environment ID from Core") + flags.StringVar(&o.Workspace, "workspace", "", "existing absolute workspace directory") + flags.StringVar(&o.Credential, "credential-file", "", "absolute executor credential JSON file (never the token)") + flags.StringVar(&o.CapabilityDirectory, "capability-directory", "", "capability snapshot destination") + flags.StringVar(&o.ToolEnvironmentFile, "tool-env-file", "", "optional tool environment JSON file") + flags.StringVar(&o.Directory, "install-dir", "", "installation directory (default OAC_RUNTIME_HOME or ~/.oac)") + flags.StringVar(&o.Bundle, "bundle-dir", "", "native distribution directory (defaults beside the executable)") + flags.StringVar(&o.Harness, "harness", "", "comma-separated codex,claude,minimax") + flags.BoolVar(&o.Interactive, "interactive", false, "ask for missing installation options") + flags.BoolVar(&o.NonInteractive, "non-interactive", false, "require command-line options; never prompt") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + flags.SetOutput(rc.stdout) + flags.PrintDefaults() + return o, flag.ErrHelp + } + // flag errors can contain supplied values, including accidental secrets. + return o, errors.New("install: invalid arguments; run install --help (use --credential-file for credentials)") + } + if flags.NArg() != 0 || (o.Interactive && o.NonInteractive) { + return o, errors.New("install: unexpected arguments or conflicting interaction modes") + } + if o.Directory == "" { + var err error + o.Directory, err = paths.Root() + if err != nil { + return o, err + } + } + if o.Bundle == "" { + exe, err := os.Executable() + if err != nil { + return o, err + } + o.Bundle = filepath.Dir(exe) + } + if len(args) == 0 && !o.NonInteractive { + if info, err := os.Stdin.Stat(); err == nil && info.Mode()&os.ModeCharDevice != 0 { + o.Interactive = true + } + } + if o.Interactive { + input := rc.stdin + if input == nil { + input = os.Stdin + } + if err := promptNativeInstall(input, rc.stdout, &o); err != nil { + return o, err + } + } + return o, nil +} + +func promptNativeInstall(input io.Reader, output io.Writer, o *nativeInstallOptions) error { + r := bufio.NewReader(input) + // Only paths and connection identifiers are requested; tokens are read later + // from the private credential file, never entered or echoed by this prompt. + for _, p := range []struct { + label string + value *string + optional bool + }{ + {"Harnesses (comma-separated codex,claude,minimax)", &o.Harness, false}, + {"Installation directory (Enter uses current default)", &o.Directory, true}, + {"Distribution directory (Enter uses current default)", &o.Bundle, true}, + {"Environment remote URL", &o.Remote, false}, + {"Environment ID", &o.Environment, false}, + {"Existing workspace directory", &o.Workspace, false}, + {"Credential JSON file path (do not enter a token)", &o.Credential, false}, + {"Capability snapshot directory (optional)", &o.CapabilityDirectory, true}, + {"Tool environment JSON file path (optional)", &o.ToolEnvironmentFile, true}, + } { + if *p.value != "" && !p.optional { + continue + } + fmt.Fprint(output, p.label+": ") + line, err := r.ReadString('\n') + if err != nil { + return errors.New("install: interactive input ended; use --non-interactive with complete arguments") + } + value := strings.TrimSpace(line) + if value != "" { + *p.value = value + } else if !p.optional { + return errors.New("install: required interactive value missing") + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/cli/native_install_test.go b/apps/parsar-daemon/internal/cli/native_install_test.go index 21d21403f..55cf4b470 100644 --- a/apps/parsar-daemon/internal/cli/native_install_test.go +++ b/apps/parsar-daemon/internal/cli/native_install_test.go @@ -2,58 +2,224 @@ package cli import ( "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "os" "path/filepath" + "runtime" + "strings" "testing" "github.com/google/uuid" ) -func TestNativeInstallationPersistsWithoutStartingOrDeleting(t *testing.T) { +func nativeInstallFixture(t *testing.T) (*runContext, []string, string, string) { + t.Helper() root := t.TempDir() + bundle := t.TempDir() + workspace := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - workspace := filepath.Join(root, "workspace") - if err := os.MkdirAll(workspace, 0700); err != nil { - t.Fatal(err) - } key := filepath.Join(root, "credential.json") environment := uuid.NewString() - body, _ := json.Marshal(map[string]string{"key_id": uuid.NewString(), "executor_token": "test-token", "environment_id": environment}) + body, _ := json.Marshal(map[string]string{"key_id": uuid.NewString(), "executor_token": "private-test-credential", "environment_id": environment}) if err := os.WriteFile(key, body, 0600); err != nil { t.Fatal(err) } + b := nativeBundle{Schema: 1, DaemonVersion: Version, OS: runtime.GOOS, Arch: runtime.GOARCH, Components: map[string]nativeComponent{}} + for _, name := range []string{"node", "codex", "claude"} { + data := []byte("fixture " + name) + sum := sha256.Sum256(data) + b.Components[name] = nativeComponent{Version: nativePins[name], Files: map[string]nativeFile{"program": {SHA256: hex.EncodeToString(sum[:]), Executable: true}}} + dir := nativeComponentRoot(bundle, name) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "program"), data, 0700); err != nil { + t.Fatal(err) + } + } + raw, _ := json.Marshal(b) + if err := os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600); err != nil { + t.Fatal(err) + } + old := probeNativeInstallation + probeNativeInstallation = func(string, []string) error { return nil } + t.Cleanup(func() { probeNativeInstallation = old }) output := new(bytes.Buffer) - rc := &runContext{stdout: output, stderr: output} - args := []string{"--remote", "ws://localhost:12345/api/v1/agent-daemon/ws", "--environment-id", environment, "--workspace", workspace, "--credential-file", key} + rc := &runContext{stdin: strings.NewReader("must not read"), stdout: output, stderr: output} + args := []string{"--non-interactive", "--bundle-dir", bundle, "--harness", "codex", "--remote", "ws://localhost:12345/api/v1/agent-daemon/ws", "--environment-id", environment, "--workspace", workspace, "--credential-file", key} + return rc, args, root, bundle +} + +func TestNativeInstallationAdditiveReuseAndVersionRejection(t *testing.T) { + rc, args, root, _ := nativeInstallFixture(t) if err := runInstall(rc, args); err != nil { t.Fatal(err) } - path, _ := nativeInstallationPath() - raw, err := os.ReadFile(path) - if err != nil { + file := filepath.Join(root, "daemon", "installation.json") + first, _ := os.ReadFile(file) + if err := runInstall(rc, args); err != nil { + t.Fatalf("compatible rerun: %v", err) + } + again, _ := os.ReadFile(file) + if !bytes.Equal(first, again) { + t.Fatal("reuse rewrote settings") + } + if err := runInstall(rc, append(args, "--harness", "claude")); err != nil { t.Fatal(err) } var installed nativeInstallation - if json.Unmarshal(raw, &installed) != nil || installed.Environment != environment || installed.Workspace != workspace { - t.Fatal("installation did not persist its configuration") + if err := readNativeJSON(file, &installed); err != nil { + t.Fatal(err) + } + if strings.Join(installed.Harnesses, ",") != "claude,codex" { + t.Fatal("lost or failed to add Harness") } + if strings.Contains(rc.stdout.(*bytes.Buffer).String(), "private-test-credential") { + t.Fatal("credential leaked") + } + if err := runInstall(rc, append(args, "--workspace", t.TempDir())); err == nil { + t.Fatal("replaced connection settings") + } + installed.Version = "old-version" + raw, _ := json.Marshal(installed) + _ = os.WriteFile(file, raw, 0600) if err := runInstall(rc, args); err == nil { - t.Fatal("silently replaced existing installation") + t.Fatal("accepted historical installation") } - again, _ := os.ReadFile(path) - if !bytes.Equal(raw, again) { - t.Fatal("reinstall changed existing data") + if err := runStart(rc, []string{"--foreground"}); err == nil { + t.Fatal("started historical installation") } - if _, err := os.Stat(key); err != nil { - t.Fatal("credential file removed") + if got, _ := os.ReadFile(file); !bytes.Equal(got, raw) { + t.Fatal("modified historical state") } - installed.Version = "unsupported-old-version" - invalid, _ := json.Marshal(installed) - if err := os.WriteFile(path, invalid, 0600); err != nil { +} + +func TestNativeInstallationFailureAndRecoveryPreserveExisting(t *testing.T) { + rc, args, root, bundle := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { t.Fatal(err) } - if err := runStart(rc, []string{"--foreground"}); err == nil { - t.Fatal("old installation accepted") + file := filepath.Join(root, "daemon", "installation.json") + before, _ := os.ReadFile(file) + probeNativeInstallation = func(string, []string) error { return errors.New("synthetic unavailable") } + if err := runInstall(rc, append(args, "--harness", "claude")); err == nil { + t.Fatal("published failed installation") + } + if after, _ := os.ReadFile(file); !bytes.Equal(before, after) { + t.Fatal("failed addition changed settings") + } + probeNativeInstallation = func(string, []string) error { return nil } + if err := runInstall(rc, append(args, "--harness", "claude")); err != nil { + t.Fatalf("could not reuse complete unpublished component: %v", err) + } + // Source corruption cannot affect an existing component, and cannot be used + // to repair a modified installed one silently. + _ = os.WriteFile(filepath.Join(nativeComponentRoot(root, "codex"), "program"), []byte("changed"), 0700) + if err := runInstall(rc, args); err == nil { + t.Fatal("silently repaired modified executable") + } + if got, _ := os.ReadFile(filepath.Join(nativeComponentRoot(root, "codex"), "program")); string(got) != "changed" { + t.Fatal("overwrote installed data") + } + _ = bundle +} + +func TestNativeInstallationMissingInputAndSecrets(t *testing.T) { + rc, _, root, _ := nativeInstallFixture(t) + for _, args := range [][]string{{"--non-interactive"}, {"--non-interactive", "--remote", "ws://user:secret@host/x"}, {"--interactive=secret"}, {"--token", "private-test-credential"}} { + err := runInstall(rc, args) + if err == nil { + t.Fatal("accepted incomplete input") + } + if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "private-test-credential") { + t.Fatal("echoed sensitive argument") + } + } + if _, err := os.Stat(filepath.Join(root, "daemon", "installation.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("incomplete input wrote installation") + } +} + +func TestNativeInstallationInteractiveMultipleHarnesses(t *testing.T) { + rc, args, root, bundle := nativeInstallFixture(t) + var o nativeInstallOptions + // Prompt and parameter paths feed the same installer options. + o.Directory = root + o.Bundle = bundle + // Existing connection fields need no second interactive input. + for i := 0; i < len(args)-1; i++ { + switch args[i] { + case "--remote": + o.Remote = args[i+1] + case "--environment-id": + o.Environment = args[i+1] + case "--workspace": + o.Workspace = args[i+1] + case "--credential-file": + o.Credential = args[i+1] + } + } + input := "codex,claude\n\n\n\n\n" + if err := promptNativeInstall(strings.NewReader(input), rc.stdout, &o); err != nil { + t.Fatal(err) + } + if err := runInstall(rc, append(args, "--harness", o.Harness)); err != nil { + t.Fatal(err) + } + var c nativeInstallation + _ = readNativeJSON(filepath.Join(root, "daemon", "installation.json"), &c) + if len(c.Harnesses) != 2 { + t.Fatal("multi-selection not installed") + } + if err := promptNativeInstall(strings.NewReader(""), rc.stdout, &nativeInstallOptions{}); err == nil { + t.Fatal("EOF accepted") + } +} + +func TestNativeInstallationLockAndManifestContainment(t *testing.T) { + rc, args, root, bundle := nativeInstallFixture(t) + _, unlock, err := lockNativeInstallation(root) + if err != nil { + t.Fatal(err) + } + if err = runInstall(rc, args); err == nil { + t.Fatal("concurrent installation accepted") + } + unlock() + var b nativeBundle + _ = readNativeJSON(filepath.Join(bundle, "bundle.json"), &b) + c := b.Components["codex"] + c.Files["../escaped"] = c.Files["program"] + b.Components["codex"] = c + raw, _ := json.Marshal(b) + _ = os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600) + if err = runInstall(rc, args); err == nil { + t.Fatal("accepted manifest traversal") + } + if _, err = os.Stat(filepath.Join(root, "components")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("manifest validation happened after mutation") + } +} + +func TestNativeInstallationPlatformMismatchAndUnsupportedSelection(t *testing.T) { + rc, args, _, bundle := nativeInstallFixture(t) + var b nativeBundle + _ = readNativeJSON(filepath.Join(bundle, "bundle.json"), &b) + b.OS = "other" + raw, _ := json.Marshal(b) + _ = os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600) + if err := runInstall(rc, args); err == nil { + t.Fatal("foreign bundle accepted") + } + if _, err := selectedNativeHarnesses("anything"); err == nil { + t.Fatal("unknown Harness accepted") + } + if runtime.GOOS == "windows" { + if _, err := selectedNativeHarnesses("minimax"); err == nil { + t.Fatal("unsupported Windows MiniMax accepted") + } } } diff --git a/apps/parsar-daemon/internal/cli/root.go b/apps/parsar-daemon/internal/cli/root.go index 441da1bc7..9a47b0f84 100644 --- a/apps/parsar-daemon/internal/cli/root.go +++ b/apps/parsar-daemon/internal/cli/root.go @@ -22,18 +22,19 @@ type command struct { // runContext bundles the streams a command writes to. Tests inject // buffers; production uses the OS streams. type runContext struct { + stdin io.Reader stdout io.Writer stderr io.Writer } func defaultRunContext() *runContext { - return &runContext{stdout: os.Stdout, stderr: os.Stderr} + return &runContext{stdin: os.Stdin, stdout: os.Stdout, stderr: os.Stderr} } // commands lists subcommands in --help render order: the user's // likely flow connect → status → stop / logs → logout. var commands = []command{ - {name: "install", summary: "Configure a native self-hosted daemon", run: runInstall}, + {name: "install", summary: "Install a native daemon and selected Harnesses", run: runInstall}, {name: "start", summary: "Start the installed native daemon", run: runStart}, {name: "resume", summary: "Wake one planned hosted suspension", run: runResume}, {name: "runtime-mcp-exec", summary: "Execute an installed MCP server", run: runRuntimeMCP}, @@ -52,6 +53,7 @@ func Execute(argv []string) error { } func execute(ctx *runContext, argv []string) error { + useInstalledNativeHome() if err := validateRuntimeConfiguration(); err != nil { return err } From d9df2e135701043b0e3c546618a1976b1d597554 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:36:05 +0000 Subject: [PATCH 45/78] build: reuse verified MiniMax tool archive cache --- scripts/build-mcode-harness.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/build-mcode-harness.sh b/scripts/build-mcode-harness.sh index 32ea55c3f..d6e9e1a40 100644 --- a/scripts/build-mcode-harness.sh +++ b/scripts/build-mcode-harness.sh @@ -20,6 +20,12 @@ if [[ "$(git -C "$source" rev-parse HEAD)" != "$revision" ]]; then fi mkdir "$context/upstream" git -C "$source" archive "$revision" | tar -x -C "$context/upstream" +# Upstream validates the cached archive and embedded tool hashes before use. +# Preserve this single optional download cache across fresh source exports. +if [[ -f "$source/.cache/artifacts/code-0.3.11.tgz" ]]; then + mkdir -p "$context/upstream/.cache/artifacts" + cp "$source/.cache/artifacts/code-0.3.11.tgz" "$context/upstream/.cache/artifacts/" +fi printf '%s\n' "$revision" > "$context/upstream/.oac-source-revision" cp "$package/"*.mjs "$package/"*.ts "$package/"*.json "$context/" test "$(node "$native/cli.js" --version)" = 0.4.12 From 29dabf1d979e57eba511da574123080f299df754 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:40:31 +0000 Subject: [PATCH 46/78] refactor(installer): keep native activation and checks in Harness adapters --- .../internal/agent/claudesdk/installation.go | 23 +++++ .../internal/agent/codex/installation.go | 30 +++++++ .../internal/agent/installation.go | 11 +++ .../internal/agent/installroot/probe.go | 35 ++++++++ .../internal/agent/mcode/installation.go | 26 ++++++ .../internal/cli/native_bundle.go | 15 +++- .../internal/cli/native_harness.go | 87 +++++-------------- 7 files changed, 156 insertions(+), 71 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/installation.go create mode 100644 apps/parsar-daemon/internal/agent/codex/installation.go create mode 100644 apps/parsar-daemon/internal/agent/installation.go create mode 100644 apps/parsar-daemon/internal/agent/installroot/probe.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/installation.go diff --git a/apps/parsar-daemon/internal/agent/claudesdk/installation.go b/apps/parsar-daemon/internal/agent/claudesdk/installation.go new file mode 100644 index 000000000..f1f7777ee --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/installation.go @@ -0,0 +1,23 @@ +package claudesdk + +import ( + "context" + "fmt" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "path/filepath" + "runtime" +) + +func Installation() agent.Installation { + return agent.Installation{Version: "0.3.269", Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows" }, + Environment: func(dir, node string) map[string]string { + return map[string]string{"OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT": filepath.Join(dir, "dist", "main.js"), "OAC_RUNTIME_CLAUDE_SDK_NODE": node} + }, + Check: func(dir, node string, env []string) error { + got, err := CheckRuntime(context.Background(), Config{Node: node, Entrypoint: filepath.Join(dir, "dist", "main.js"), Env: env}) + if err != nil || got.SDK != "0.3.269" || !got.SupportsLocalRuntime() { + return fmt.Errorf("Claude installation is incompatible; Windows requires Git Bash") + } + return nil + }} +} diff --git a/apps/parsar-daemon/internal/agent/codex/installation.go b/apps/parsar-daemon/internal/agent/codex/installation.go new file mode 100644 index 000000000..478647966 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/installation.go @@ -0,0 +1,30 @@ +package codex + +import ( + "fmt" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" + "path/filepath" + "runtime" +) + +func Installation() agent.Installation { + binary := func(dir string) string { + name := "codex" + if runtime.GOOS == "windows" { + name += ".exe" + } + return filepath.Join(dir, "bin", name) + } + return agent.Installation{Version: "0.153.4", Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows" }, + Environment: func(dir, node string) map[string]string { + return map[string]string{"OAC_RUNTIME_CODEX_BIN": binary(dir)} + }, + Check: func(dir, node string, env []string) error { + got, err := installroot.Probe(binary(dir), []string{"--version"}, env, dir) + if err != nil || got != "codex-cli 0.153.4" { + return fmt.Errorf("Codex installation is incompatible") + } + return nil + }} +} diff --git a/apps/parsar-daemon/internal/agent/installation.go b/apps/parsar-daemon/internal/agent/installation.go new file mode 100644 index 000000000..aa5941f8d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/installation.go @@ -0,0 +1,11 @@ +package agent + +// Installation is an optional adapter-owned native distribution contract. +// It supplies activation paths and readiness checks, never execution or model +// configuration. Runtime owns copying, checksums, publication and installation locks. +type Installation struct { + Version string + Supported func() bool + Environment func(directory, node string) map[string]string + Check func(directory, node string, environment []string) error +} diff --git a/apps/parsar-daemon/internal/agent/installroot/probe.go b/apps/parsar-daemon/internal/agent/installroot/probe.go new file mode 100644 index 000000000..bbf7c8db1 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/installroot/probe.go @@ -0,0 +1,35 @@ +package installroot + +import ( + "context" + "errors" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "io" + "strings" + "time" +) + +// Native diagnostics are deliberately discarded: dependencies may echo their +// environment. Readiness is separate from model credentials and a live Turn. +func Probe(binary string, args, env []string, dir string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Env: env, Dir: dir, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) + if err != nil { + return "", errors.New("native component failed to start") + } + defer p.Cancel() + done := make(chan struct{}) + go func() { _, _ = io.Copy(io.Discard, p.Stderr); close(done) }() + raw, err := io.ReadAll(io.LimitReader(p.Stdout, 64*1024+1)) + if err != nil || len(raw) > 64*1024 { + p.Cancel() + } + _, _ = io.Copy(io.Discard, p.Stdout) + <-done + waitErr := p.Wait() + if err != nil || waitErr != nil || ctx.Err() != nil || len(raw) > 64*1024 { + return "", errors.New("native component compatibility check failed") + } + return strings.TrimSpace(string(raw)), nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/installation.go b/apps/parsar-daemon/internal/agent/mcode/installation.go new file mode 100644 index 000000000..3599dc295 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/installation.go @@ -0,0 +1,26 @@ +package mcode + +import ( + "fmt" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" + "path/filepath" + "runtime" +) + +func Installation() agent.Installation { + return agent.Installation{Version: SupportedVersion, Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" }, + Environment: func(dir, node string) map[string]string { + return map[string]string{"OAC_RUNTIME_MCODE_BIN": filepath.Join(dir, "native", "cli.js"), "OAC_RUNTIME_MCODE_NODE": node, "OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE": filepath.Join(dir, "launch.mjs"), "OAC_RUNTIME_MCODE_AGENTS_API": "1"} + }, + Check: func(dir, node string, env []string) error { + got, err := installroot.Probe(node, []string{filepath.Join(dir, "native", "cli.js"), "--version"}, env, dir) + if err != nil || got != SupportedVersion { + return fmt.Errorf("MiniMax installation is incompatible") + } + if _, err = installroot.Probe(node, []string{filepath.Join(dir, "check.mjs")}, env, dir); err != nil { + return fmt.Errorf("MiniMax dependencies are unavailable; Bash is required") + } + return nil + }} +} diff --git a/apps/parsar-daemon/internal/cli/native_bundle.go b/apps/parsar-daemon/internal/cli/native_bundle.go index e4baf6d44..95c0eaaeb 100644 --- a/apps/parsar-daemon/internal/cli/native_bundle.go +++ b/apps/parsar-daemon/internal/cli/native_bundle.go @@ -33,7 +33,13 @@ type nativeFile struct { Executable bool `json:"executable"` } -var nativePins = map[string]string{"node": "22.22.0", "codex": "0.153.4", "claude": "0.3.269", "minimax": "0.4.12"} +var nativePins = func() map[string]string { + out := map[string]string{"node": "22.22.0"} + for name, spec := range nativeHarnesses { + out[name] = spec.Version + } + return out +}() func readNativeJSON(file string, value any) error { f, err := os.Open(file) @@ -189,11 +195,12 @@ func selectedNativeHarnesses(value string) ([]string, error) { var out []string for _, name := range strings.Split(value, ",") { name = strings.TrimSpace(name) - if name != "codex" && name != "claude" && name != "minimax" { + spec, ok := nativeHarnesses[name] + if !ok { return nil, errors.New("install: --harness requires codex,claude,minimax (comma-separated)") } - if name == "minimax" && runtime.GOOS == "windows" { - return nil, errors.New("install: MiniMax Code adapter is unsupported on Windows; select Codex or Claude Code") + if !spec.Supported() { + return nil, fmt.Errorf("install: %s adapter is unsupported on %s; select a supported Harness", name, runtime.GOOS) } if !slices.Contains(out, name) { out = append(out, name) diff --git a/apps/parsar-daemon/internal/cli/native_harness.go b/apps/parsar-daemon/internal/cli/native_harness.go index 23144e643..4e94bcfea 100644 --- a/apps/parsar-daemon/internal/cli/native_harness.go +++ b/apps/parsar-daemon/internal/cli/native_harness.go @@ -1,18 +1,19 @@ package cli import ( - "context" "errors" "fmt" - "io" + "os" "path/filepath" "runtime" "strings" - "time" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudesdk" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/mcode" ) func nativeExe(name string) string { @@ -34,19 +35,11 @@ func nativeHarnessEnvironment(root string, selected []string) map[string]string values := map[string]string{"PATH": filepath.Dir(node) + string(os.PathListSeparator) + os.Getenv("PATH")} for _, name := range selected { dir := nativeComponentRoot(root, name) - switch name { - case "codex": - values["OAC_RUNTIME_CODEX_BIN"] = filepath.Join(dir, "bin", nativeExe("codex")) - case "claude": - values[claudeSDKEntrypointEnv] = filepath.Join(dir, "dist", "main.js") - values[claudeSDKNodeEnv] = node - case "minimax": - values["OAC_RUNTIME_MCODE_BIN"] = filepath.Join(dir, "native", "cli.js") - values["OAC_RUNTIME_MCODE_NODE"] = node - values["OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE"] = filepath.Join(dir, "launch.mjs") - values["OAC_RUNTIME_MCODE_AGENTS_API"] = "1" - values["PATH"] = filepath.Join(dir, "bin") + string(os.PathListSeparator) + values["PATH"] + values["PATH"] = filepath.Join(dir, "bin") + string(os.PathListSeparator) + values["PATH"] + for key, value := range nativeHarnesses[name].Environment(dir, node) { + values[key] = value } + } return values } @@ -73,66 +66,26 @@ func withNativeEnv(values map[string]string) []string { return env } -// Native diagnostics are deliberately discarded: dependencies may echo their -// environment. Readiness is separate from model credentials and a live Turn. -func nativeProbeCommand(binary string, args, env []string, dir string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) - defer cancel() - p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Env: env, Dir: dir, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) - if err != nil { - return "", errors.New("native component failed to start") - } - defer p.Cancel() - done := make(chan struct{}) - go func() { _, _ = io.Copy(io.Discard, p.Stderr); close(done) }() - raw, err := io.ReadAll(io.LimitReader(p.Stdout, 64*1024+1)) - if err != nil || len(raw) > 64*1024 { - p.Cancel() - } - _, _ = io.Copy(io.Discard, p.Stdout) - <-done - waitErr := p.Wait() - if err != nil || waitErr != nil || ctx.Err() != nil || len(raw) > 64*1024 { - return "", errors.New("native component compatibility check failed") - } - return strings.TrimSpace(string(raw)), nil +// Installation registration is local to Runtime; Core does not see native paths. +var nativeHarnesses = map[string]agent.Installation{ + "codex": codex.Installation(), "claude": claudesdk.Installation(), "minimax": mcode.Installation(), } - var probeNativeInstallation = checkNativeInstallation func checkNativeInstallation(root string, selected []string) error { - values := nativeHarnessEnvironment(root, selected) - env := withNativeEnv(values) + env := withNativeEnv(nativeHarnessEnvironment(root, selected)) node := nativeNode(root) - version, err := nativeProbeCommand(node, []string{"--version"}, env, root) + version, err := installroot.Probe(node, []string{"--version"}, env, root) if err != nil || version != "v"+nativePins["node"] { - return errors.New("install: bundled Node is unavailable or incompatible; use the distribution for this operating system and architecture") + return errors.New("install: bundled Node is unavailable or incompatible; use the matching native distribution") } for _, name := range selected { - dir := nativeComponentRoot(root, name) - switch name { - case "codex": - version, err = nativeProbeCommand(values["OAC_RUNTIME_CODEX_BIN"], []string{"--version"}, env, root) - if err == nil && version != "codex-cli "+nativePins[name] { - err = errors.New("version mismatch") - } - case "claude": - var info claudesdk.RuntimeInfo - info, err = claudesdk.CheckRuntime(context.Background(), claudesdk.Config{Node: node, Entrypoint: values[claudeSDKEntrypointEnv], Env: env}) - if err == nil && (info.SDK != nativePins[name] || !info.SupportsLocalRuntime()) { - err = errors.New("adapter contract mismatch") - } - case "minimax": - version, err = nativeProbeCommand(node, []string{filepath.Join(dir, "native", "cli.js"), "--version"}, env, root) - if err == nil && version != nativePins[name] { - err = errors.New("version mismatch") - } - if err == nil { - _, err = nativeProbeCommand(node, []string{filepath.Join(dir, "check.mjs")}, env, root) - } + spec, ok := nativeHarnesses[name] + if !ok || !spec.Supported() { + return fmt.Errorf("install: %s is unsupported on this platform", name) } - if err != nil { - return fmt.Errorf("install: %s compatibility check failed; verify OS dependencies (Claude on Windows needs Git Bash; MiniMax needs Bash) and use a matching release; no files were replaced", name) + if err = spec.Check(nativeComponentRoot(root, name), node, env); err != nil { + return fmt.Errorf("install: %s; no installed files were replaced", err) } } return nil From b549d02046c494fbedbcc25ef60e83504abdf544 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:39:06 +0000 Subject: [PATCH 47/78] Use native Runtime installation in Connect a host --- apps/web/DESIGN.md | 35 ++++--- apps/web/PRODUCT.md | 34 ++++--- apps/web/e2e/diagnostics.spec.ts | 6 +- apps/web/e2e/fixture-console.mjs | 6 +- apps/web/e2e/monitoring.spec.ts | 39 ++++---- .../features/fleet/use-sandbox-fleet.test.tsx | 2 +- .../features/sandbox/console-config.test.ts | 13 +-- .../src/features/sandbox/console-config.ts | 8 +- .../sandbox/enrollment-command.test.ts | 9 +- .../features/sandbox/enrollment-command.ts | 13 --- .../sandbox/sandbox-page-ownership.test.tsx | 2 +- .../sessions/ExecutorCredentialsSection.tsx | 18 ++-- .../sessions/ExecutorInstallPanel.tsx | 95 +++++-------------- .../web/src/features/sessions/SessionPage.tsx | 4 +- .../sessions/executor-credential-file.tsx | 28 ++---- .../sessions/executor-install.test.ts | 45 ++++----- .../src/features/sessions/executor-install.ts | 44 ++++----- apps/web/src/i18n/locales/en/sessions.ts | 45 +++------ apps/web/src/i18n/locales/zh-CN/sessions.ts | 45 +++------ 19 files changed, 164 insertions(+), 327 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index ef8ab9f2f..b91a0f50b 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -495,16 +495,16 @@ Every resource list, the Session log and the project list share one grammar: page body to the next failed Turn and focuses it. - An active project's page ends its keys with a **How to call** section (see Dialogs) before its write operations. -- A self-hosted Session's **Executor credentials** section ends with a **Connect - a host** card when the console serves the self-hosted installer: a 13px/600 - title with a help tip (what revoke and rotate do to the host, how to reconnect, - how to remove the Runtime), one Graphite line with the steps (run the command, - paste a credential at its hidden prompt, safe to rerun), the command in a - Margin Gray Terminal block with an icon copy button, and the host requirements - on one dot-separated line. The command wraps rather than scrolls. Without a - public address, with a loopback one, or with a Session address that is not - `wss://`, one Graphite note takes the command's place; an archived project - keeps the command and says the host still needs a credential. +- A self-hosted Session's **Executor credentials** section ends with **Connect + a host**, independent of console installer assets. Keep native distribution + guidance, a Linux/macOS or PowerShell selector and one copyable command here. + The command pre-fills the Session remote URL, Environment ID and workspace; + interactive installation asks for a privately saved credential file and local + installation choices. Link the native guide instead of inventing a release + download URL. Requirements and reconnection details belong in the title help. + Installation does not start the daemon; connection status comes only from Core. + Missing connection facts show a note instead of a command. Loopback ws is valid + for native local connections. Archived projects require an existing credential. ### Dialogs Dialogs are 448px Paper cards with 8px corners, a 48px header and a 52px footer @@ -526,15 +526,12 @@ request runs. red underneath. The issued key appears in a read-only field with a copy button, under a notice that it is shown once; only "I've saved this key" dismisses it. Closing the dialog moves the key into a pending notice card on the page. -- **Executor credential dialog** (640px): the shown-once notice, then one line - saying what to do in order. With Connect a host available, the install - command's Terminal block comes first, so it is copied and run before the - credential is pasted and Done pressed. Then the credential as one line of JSON - (wrapped, never pretty-printed), Copy credential (primary: it is pasted at the - installer's hidden prompt) and Download credential file (outline), with a - Graphite hint for automation (`chmod 600`, `--credential-file`). Done is - outline and forgets the credential; closing the dialog keeps it in a pending - card, which points to the Connect a host command below. +- **Executor credential dialog** (640px): the shown-once notice, then a prompt + to save the JSON privately before Done. Download credential file is primary; + Copy credential is secondary. Installation commands are not repeated here. + Done forgets the credential; closing preserves it in the pending card. The + native installer reads the unchanged JSON file through its interactive prompt + or `--credential-file`; tokens never enter command arguments. - **Add node**: the sandbox limits first, then the one-time command in a Terminal block (expiry countdown and Copy command in its header), the three progress steps, and, once the installer's minute passes, an amber card with the reason diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index af4851e24..7c4e19b5a 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -54,9 +54,8 @@ workbench. - The Core key is not an Agents API identity and cannot call `/v1`. An administrator who wants to call the Agents API issues a project API key like any other caller. - `/console/config` reports the node installer (`node_installer`, - `node_installer_sha256`) and the self-hosted executor installer - (`self_hosted_installer`, `self_hosted_installer_sha256`); an installer is - offered only with a 64-hex digest. It also lists the providers it has node files + `node_installer_sha256`), offered only with a 64-hex digest. Native self-hosted + installation does not depend on this endpoint. It also lists the providers it has node files for (`node_artifacts`); without the deployment's provider, Add node says so and issues no command. Signing in grants administration, so sandbox administration is available unless the console explicitly reports @@ -204,8 +203,8 @@ workbench. executor needs, with the deployment's Core key. A Session page whose environment is self-hosted has an Executor credentials section: issue a credential (shown once as one line of JSON, to copy or download, never stored), rotate it (the - old one stops working immediately) or revoke it (the executor disconnects and - won't retry; its container keeps running until stopped). The file lets one + old one stops working immediately) or revoke it (the executor disconnects; + installed Runtime state and workspace contents are not deleted). The file lets one executor connect for that environment only; it cannot call the Agents API. - **Default provider observations.** Each configured harness offers Usage details for Core's last successful use and any newer classified provider error. Missing @@ -217,19 +216,18 @@ workbench. share one five-second read while visible. Never connected, connected, disconnected, bound credential revoked, and unknown are distinct; a recent heartbeat alone never proves connectivity. Only a fresh connected read marks - Run on host done. Stale or failed reads withhold completion. Recovery rotates - the bound key and reruns the same command rather than suggesting a new key. -- **Connect a host.** When the console serves the self-hosted installer, the - section also gives the command that installs the executor on the - administrator's host from Core's `public_url` (checksum-verified, no secret in - it; the installer asks for the credential at a hidden prompt, or reads - `--credential-file`). The same command is safe to rerun. Revoking or rotating - disconnects the host's executor; reconnecting takes that same credential, - rotated (Restore on a revoked row rotates and restores it), and the same command, because a - newly issued credential does not reconnect an environment that already - connected. Without a `public_url`, with a loopback one, or when the Session's - `remote_url` is not `wss://`, the section says why instead of showing a - command. + Host connected. Stale or failed reads withhold completion. Recovery rotates + the bound key, replaces the host credential file and starts the installed daemon. +- **Connect a host.** Native Linux/macOS and PowerShell installation instructions + depend on the Session's remote URL, Environment ID and workspace, not console + installer flags or served Python assets. Users privately save the issued JSON, + obtain a matching native distribution through the linked guide, and run the + interactive install command from its root. Installation asks for the credential + file path and does not automatically start the daemon. Run the installed binary + in the installation's bin directory with `start`. No model readiness is implied. + Credential rotation requires replacing the configured file and starting that + same installed daemon. A new key cannot reconnect an already-bound Environment. + Accept wss or loopback ws; withhold commands for missing or invalid facts. - **Typed write errors.** Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Unknown codes retain Core's fallback message; uncertain write outcomes diff --git a/apps/web/e2e/diagnostics.spec.ts b/apps/web/e2e/diagnostics.spec.ts index 63948f790..962380260 100644 --- a/apps/web/e2e/diagnostics.spec.ts +++ b/apps/web/e2e/diagnostics.spec.ts @@ -74,7 +74,7 @@ test("connection reads govern host completion and bound-key guidance", async ({ await openConsole(page, request, "sessions"); await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); await expect(page.getByRole("region", { name: "Host connection", exact: true })).toContainText("Disconnected"); - await expect(page.getByText("Run on host · Done", { exact: true })).toHaveCount(0); + await expect(page.getByText("Host connected", { exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "Rotate bound credential", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: /^Rotate credential / })).toHaveCount(0); revoked = true; @@ -84,9 +84,9 @@ test("connection reads govern host completion and bound-key guidance", async ({ revoked = false; connected = true; await expect(page.getByRole("region", { name: "Host connection", exact: true })).toContainText("Connected", { timeout: 10_000 }); - await expect(page.getByText("Run on host · Done", { exact: true })).toBeVisible(); + await expect(page.getByText("Host connected", { exact: true })).toBeVisible(); unavailable = true; await expect(page.getByRole("region", { name: "Host connection", exact: true })).toContainText("Unknown", { timeout: 10_000 }); - await expect(page.getByText("Run on host · Done", { exact: true })).toHaveCount(0); + await expect(page.getByText("Host connected", { exact: true })).toHaveCount(0); expect(await writes(request)).toEqual([]); }); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index b79218fd6..3051a667d 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -36,7 +36,6 @@ const LOCAL_URL = "http://127.0.0.1:8091"; const OLD_URL = "https://core-old.example.com"; const publicUrl = () => (state.installation === "local" ? LOCAL_URL : PUBLIC_URL); /** The digest the console reports for its self-hosted executor installer; the same value as in monitoring.spec.ts. */ -const SELF_HOSTED_INSTALLER_SHA256 = "5e1f".repeat(16); /** Core reports one installation ID, a canonical UUID, in the installation and the deployment. */ const INSTALLATION_ID = "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f"; @@ -201,14 +200,11 @@ async function consoleRoute(request, response, url) { return send(response, 200, { mode: "login" }, { "set-cookie": `${SESSION_COOKIE.split("=")[0]}=; Path=/; Max-Age=0` }); } if (url.pathname === "/console/config") { - // As Core's console: signing in grants administration, so it reports only its installers, - // both served from the node installation payload, and without that payload neither, and the - // providers whose node files that payload holds (always a list, empty without it). + // Console assets cover node enrollment only; native self-hosted installation is independent. const served = state.installers; return send(response, 200, { node_installer: served, node_installer_sha256: served ? "a".repeat(64) : "", node_artifacts: served ? state.nodeArtifacts : [], - self_hosted_installer: served, self_hosted_installer_sha256: served ? SELF_HOSTED_INSTALLER_SHA256 : "", }); } return error(response, 404, "Not found."); diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index 4e73326f3..8f4c70302 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -6,7 +6,6 @@ import { archiveProject, expectManagementBoundary, openConsole } from "./console test.afterEach(async ({ request }) => expectManagementBoundary(request)); /** The self-hosted installer digest the fixture console reports. */ -const SELF_HOSTED_INSTALLER_SHA256 = "5e1f".repeat(16); test("shows the deployment's health on Overview and each monitor page", async ({ page, request }) => { await openConsole(page, request, "overview"); @@ -81,22 +80,21 @@ test("shows a self-hosted Session's install command, issues its credential once, // Connect a host: the exact install command, which carries no secret. const install = section.getByRole("region", { name: "Connect a host" }); - await expect(install.getByLabel("Executor install command").locator("pre")).toHaveText(`(umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT -curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example.com/node-install/self-hosted-install.pyz' -o "$d/install.pyz" && -printf '%s %s\\n' '${SELF_HOSTED_INSTALLER_SHA256}' "$d/install.pyz" | sha256sum -c --status && -python3 "$d/install.pyz" --source-url 'https://core.example.com' --environment-id '${environmentId}' --remote 'wss://core.example.com/api/v1/agent-daemon/ws')`); - await expect(install.getByRole("list", { name: "Host requirements" })).toContainText("HTTPS access to https://core.example.com"); + await expect(install.getByLabel("Executor install command").locator("pre")).toHaveText(`./oac-daemon install --interactive --remote 'wss://core.example.com/api/v1/agent-daemon/ws' --environment-id '${environmentId}' --workspace '/srv/work'`); + await expect(install.getByRole("link")).toHaveAttribute("href", /docs\/self-hosted-native.md$/); + await install.getByRole("combobox", { name: "Host platform" }).click(); + await page.getByRole("option", { name: "Windows · PowerShell" }).click(); + await expect(install.locator("pre")).toContainText(".\\oac-daemon.exe install --interactive"); + await install.screenshot({ path: test.info().outputPath("native-host.png") }); await section.getByRole("button", { name: "Issue credential" }).click(); const issued = page.getByRole("dialog", { name: "Executor credential" }); await expect(issued).toContainText("shown only once"); - // The command comes with it, so it can be run before the credential is pasted and the dialog closed. - await expect(issued).toContainText("Run this command on the host first, then paste the credential below at its prompt and press Done."); - await expect(issued.getByLabel("Executor install command")).toContainText("self-hosted-install.pyz"); - // One line of JSON to paste at the installer's hidden prompt; the file is for automation. - await expect(issued.getByRole("button", { name: "Copy credential" })).toHaveClass(/\bprimary\b/); + await expect(issued).toContainText("Download and privately save the credential file before choosing Done."); + await expect(issued.getByLabel("Executor install command")).toHaveCount(0); + await expect(issued.getByRole("button", { name: "Download credential file" })).toHaveClass(/\bprimary\b/); await expect(issued.getByLabel("Executor credential file")).toHaveText(new RegExp(`^\\{"key_id":"[0-9a-f-]{36}","environment_id":"${environmentId}","executor_token":"exec_fixture_\\d+"\\}$`)); - await expect(issued).toContainText("run chmod 600 and add --credential-file to the python3 line; the path must not go through a symlink"); + await expect(issued).toContainText("Enter its absolute path during interactive installation, or use --credential-file "); const download = page.waitForEvent("download"); await issued.getByRole("button", { name: "Download credential file" }).click(); expect((await download).suggestedFilename()).toMatch(/^executor-credential-[0-9a-f]{8}\.json$/); @@ -122,7 +120,7 @@ python3 "$d/install.pyz" --source-url 'https://core.example.com' --environment-i await credentials.getByRole("button", { name: /^Restore credential / }).click(); const rotation = page.getByRole("dialog", { name: "Restore credential?" }); await expect(rotation).toContainText("generating a new secret for the same credential"); - await expect(rotation).toContainText("rerun the Connect a host command there and paste the new credential at its prompt"); + await expect(rotation).toContainText("Replace the configured credential file on the host"); await rotation.getByRole("button", { name: "Restore" }).click(); const restored = page.getByRole("dialog", { name: "Executor credential" }); await expect(restored.getByLabel("Executor credential file")).toContainText("exec_fixture_2"); @@ -140,25 +138,22 @@ python3 "$d/install.pyz" --source-url 'https://core.example.com' --environment-i await expect(credentials.getByRole("button", { name: /^Rotate credential / })).toHaveCount(0); await expect(credentials.getByRole("button", { name: /^Revoke credential / })).toBeVisible(); // The command stays; the note says the host still needs a credential. - await expect(install).toContainText("It asks for a credential, which this archived project can't issue or rotate"); + await expect(install).toContainText("Installation requires an existing credential file"); }); -test("explains instead of giving the install command when Core's public address is loopback", async ({ page, request }) => { +test("offers the native command for a loopback Core", async ({ page, request }) => { await openConsole(page, request, "sessions", { installation: "local" }); await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); const install = page.getByRole("region", { name: "Connect a host" }); - await expect(install).toContainText("Core's public address http://127.0.0.1:8091 is reachable only on the Core machine"); - await expect(install.locator("pre")).toHaveCount(0); + await expect(install.locator("pre")).toContainText("ws://127.0.0.1:8091/api/v1/agent-daemon/ws"); }); -test("hides Connect a host when the console does not serve the self-hosted installer", async ({ page, request }) => { - const config = page.waitForResponse((response) => new URL(response.url()).pathname === "/console/config"); +test("offers native installation without console installer assets", async ({ page, request }) => { await openConsole(page, request, "sessions", { installers: "none" }); await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); const section = page.getByRole("region", { name: "Executor credentials" }); await expect(section).toContainText("No executor credentials yet"); - await config; - await expect(section.getByRole("region", { name: "Connect a host" })).toHaveCount(0); + await expect(section.getByRole("region", { name: "Connect a host" })).toContainText("install --interactive"); await expect(section.getByRole("button", { name: "Issue credential" })).toBeVisible(); }); @@ -186,7 +181,7 @@ test("issues a new executor credential after the unanswered one was rotated from const credentials = section.getByRole("table", { name: "Executor credentials" }); await credentials.getByRole("button", { name: /^Rotate credential / }).click(); // Rotating an active credential disconnects its host until the command is rerun with the new one. - await expect(page.getByRole("dialog", { name: "Rotate credential?" })).toContainText("The host's executor disconnects and won't retry until you rerun the Connect a host command"); + await expect(page.getByRole("dialog", { name: "Rotate credential?" })).toContainText("The host disconnects. Replace its configured credential file"); await page.getByRole("dialog", { name: "Rotate credential?" }).getByRole("button", { name: "Rotate" }).click(); await page.getByRole("dialog", { name: "Executor credential" }).getByRole("button", { name: "Done" }).click(); diff --git a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx index d81903d95..928d5c408 100644 --- a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx +++ b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx @@ -23,7 +23,7 @@ function Probe() { function render(latest: SandboxDeployment, previous = configured, failed = false) { const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const snapshot: FleetSnapshot = { deployment: previous, nodes: [node("n1")], allocations: [], loadedAt: 1 }; - const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "", self_hosted_installer: false, self_hosted_installer_sha256: "" }; + const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "" }; cache.setQueryData(consoleConfigQuery.queryKey, () => config); cache.setQueryData(fleetQuery(false).queryKey, snapshot); cache.setQueryData(sandboxDeploymentQuery.queryKey, latest); diff --git a/apps/web/src/features/sandbox/console-config.test.ts b/apps/web/src/features/sandbox/console-config.test.ts index 37895afdd..d95d292cb 100644 --- a/apps/web/src/features/sandbox/console-config.test.ts +++ b/apps/web/src/features/sandbox/console-config.test.ts @@ -7,24 +7,13 @@ describe("bundled console capabilities", () => { const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ node_installer: true, node_installer_sha256: "a".repeat(64) }))); vi.stubGlobal("fetch", fetch); const controller = new AbortController(); - expect(await sandboxConsoleConfig(controller.signal)).toEqual({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64), self_hosted_installer: false, self_hosted_installer_sha256: "" }); + expect(await sandboxConsoleConfig(controller.signal)).toEqual({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }); expect(fetch).toHaveBeenCalledWith("/console/config", { credentials: "include", signal: controller.signal }); }); it.each([{}, { sandbox_admin: "true", node_installer: true }, { sandbox_admin: false, node_installer: true, node_installer_sha256: "bad" }])("does not enable installation without a verified digest %j", async (body) => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body)))); expect((await sandboxConsoleConfig(new AbortController().signal))?.node_installer).toBe(false); }); - it.each([ - [{ self_hosted_installer: true, self_hosted_installer_sha256: "b".repeat(64) }, true], - [{ self_hosted_installer: false, self_hosted_installer_sha256: "b".repeat(64) }, false], - [{ self_hosted_installer: "true", self_hosted_installer_sha256: "b".repeat(64) }, false], - [{ self_hosted_installer: true }, false], - [{ self_hosted_installer: true, self_hosted_installer_sha256: "B".repeat(64) }, false], - [{ self_hosted_installer: true, self_hosted_installer_sha256: "b".repeat(63) }, false], - ])("offers the self-hosted installer only when enabled with a verified digest %j", async (body, offered) => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body)))); - expect((await sandboxConsoleConfig(new AbortController().signal))?.self_hosted_installer).toBe(offered); - }); it("reports unavailable capability on an absent console endpoint", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("Not found", { status: 404 }))); expect(await sandboxConsoleConfig(new AbortController().signal)).toBeNull(); diff --git a/apps/web/src/features/sandbox/console-config.ts b/apps/web/src/features/sandbox/console-config.ts index b90512eb0..ed139bbe5 100644 --- a/apps/web/src/features/sandbox/console-config.ts +++ b/apps/web/src/features/sandbox/console-config.ts @@ -5,9 +5,6 @@ export interface SandboxConsoleConfig { sandbox_admin: boolean; node_installer: boolean; node_installer_sha256: string; - /** The self-hosted executor installer (`/node-install/self-hosted-install.pyz`); false without a verified digest. */ - self_hosted_installer: boolean; - self_hosted_installer_sha256: string; /** * The providers whose node files this console serves. Absent when the console * does not report them (an older console), which blocks nothing; a reported @@ -20,8 +17,7 @@ const SHA256 = /^[a-f0-9]{64}$/; /** * The console's capability flags. Signing in with the Core key grants - * administration, so Core reports only its installers (node and self-hosted - * executor, each with its digest) and the providers it has node files for; + * administration, so Core reports only its node installer and digest and the providers it has node files for; * sandbox administration is available * unless the console says `sandbox_admin: false`. An installer is offered only * with a well-formed SHA-256 digest. @@ -38,8 +34,6 @@ export async function sandboxConsoleConfig(signal: AbortSignal): Promise { const digest = "a".repeat(64); @@ -36,13 +36,6 @@ $s python3 "$d/node-install.pyz" --uninstall --installation-id '7f3c2a90-5b1e-4c expect(nodeLogCommand("7f3c2a90-fixture", "user")).toBe("journalctl --user -u oac-node-7f3c2a90-fixture.service"); expect(nodeLogCommand("a b", "user")).toBe("journalctl --user -u 'oac-node-a b.service'"); }); - it("creates the exact self-hosted executor install command, every value quoted", () => { - const command = selfHostedInstallCommand({ publicUrl: "https://core.example", digest: "b".repeat(64), environmentId: "env'1", remoteUrl: "wss://core.example/api/v1/agent-daemon/ws" }); - expect(command).toBe(`(umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT -curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example/node-install/self-hosted-install.pyz' -o "$d/install.pyz" && -printf '%s %s\\n' '${"b".repeat(64)}' "$d/install.pyz" | sha256sum -c --status && -python3 "$d/install.pyz" --source-url 'https://core.example' --environment-id 'env'\\''1' --remote 'wss://core.example/api/v1/agent-daemon/ws')`); - }); // "as root": a root shell runs the sudo command without sudo; "no sudo": the no-sudo command. it.each(["success", "download failure", "checksum mismatch", "installer failure", "as root", "no sudo"])("executes safely, passes the token only on stdin and cleans private downloads after %s", (scenario) => { const parent = join(homedir(), ".oac", "tests"); diff --git a/apps/web/src/features/sandbox/enrollment-command.ts b/apps/web/src/features/sandbox/enrollment-command.ts index 18486ba7c..9123a7647 100644 --- a/apps/web/src/features/sandbox/enrollment-command.ts +++ b/apps/web/src/features/sandbox/enrollment-command.ts @@ -48,19 +48,6 @@ export function nodeUninstallCommand({ sourceUrl, installationId, scriptDigest, return `${nodeInstaller(sourceUrl, scriptDigest, mode)}${runInstaller(mode)} --uninstall --installation-id ${quote(installationId)}${force ? " --force" : ""})`; } -/** - * Installs a self-hosted executor for one Environment from this Core's - * public address. It carries no secret: the installer asks for the executor - * credential at a hidden prompt (or reads `--credential-file`), and rerunning - * it resumes the same installation. - */ -export function selfHostedInstallCommand({ publicUrl, digest, environmentId, remoteUrl }: { publicUrl: string; digest: string; environmentId: string; remoteUrl: string }): string { - return `(umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT -curl -fsS --max-time 30 --max-filesize 1048576 ${quote(publicUrl + "/node-install/self-hosted-install.pyz")} -o "$d/install.pyz" && -printf '%s %s\\n' ${quote(digest)} "$d/install.pyz" | sha256sum -c --status && -python3 "$d/install.pyz" --source-url ${quote(publicUrl)} --environment-id ${quote(environmentId)} --remote ${quote(remoteUrl)})`; -} - /** * The node service's journal. The installer names the unit after the * installation (node_install.py `unit_name`): a system unit in sudo mode, a user diff --git a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx index adb03fd2c..cb869e3d4 100644 --- a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx +++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx @@ -21,7 +21,7 @@ function cache(provider: SandboxDeployment["provider"]) { mode: provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, }; - const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "", self_hosted_installer: false, self_hosted_installer_sha256: "" }; + const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "" }; client.setQueryData(sandboxConsoleConfigQuery.queryKey, () => config); client.setQueryData(sandboxDeploymentQuery.queryKey, deployment); client.setQueryData(sandboxSnapshotQuery.queryKey, { deployment, nodes: [], allocations: [], nodesError: null, readAt: 0 }); diff --git a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx index 5930adfc7..9b9d29e24 100644 --- a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx +++ b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx @@ -68,20 +68,18 @@ function seconds(value: string | null): number | null { * administrator rotates it (issued, secret lost) or issues it again (not * issued). A kept key ID that is listed is never sent again: its row's actions * own it. Rotating a revoked credential restores it with a new secret, which - * is how a host whose credential was revoked reconnects: its installer accepts - * only the same key ID. An archived project's credentials are listed and + * is how a host whose credential was revoked reconnects: Core retains + * the bound key ID. An archived project's credentials are listed and * revoked but neither issued nor rotated. Below the list, Connect a host gives * the command that installs the executor with one of these credentials. */ -export function ExecutorCredentialsSection({ projectId, sessionId, environmentId, remoteUrl }: { projectId: string; sessionId: string; environmentId: string; remoteUrl: string }) { +export function ExecutorCredentialsSection({ projectId, sessionId, environmentId, remoteUrl, workspaceDirectory = "" }: { projectId: string; sessionId: string; environmentId: string; remoteUrl: string; workspaceDirectory?: string }) { const { t, i18n } = useTranslation("sessions"); const locale = i18n.resolvedLanguage; const toast = useToast(); const { byId, refresh: refreshProjects } = useProjects(); const archived = byId.get(projectId)?.status === "archived"; - const install = useExecutorInstall(environmentId, remoteUrl); - // The one-time dialog repeats the command, so it is copied and run before the credential is pasted. - const command = install.kind === "ready" ? install.command : null; + const install = useExecutorInstall(environmentId, remoteUrl, workspaceDirectory); const query = useQuery(executorConnectionQuery(projectId, sessionId, environmentId)); const credentials = query.data?.data ?? null; const connectionStale = failedLast(query) || query.isStale; @@ -296,7 +294,7 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId {archived ?

{t("executor.archived")}

: null} {shown && !shown.open && !shown.done ? (
- +
) : null} @@ -309,7 +307,7 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId onClose={dismissShown} footer={} > - {shown ? : null} + {shown ? : null}

{t(`executor.rotateDialog.${rotation.reason}`, { id: shortId(rotation.keyId) })}

{rotation.reason === "revoked" ? null :

{t("executor.rotateDialog.consequence")}

} - {/* A connected host needs the command again; the text names it only when it is shown here. */} - {rotation.reason === "lost" ? null :

{t(`executor.rotateDialog.${rotation.reason === "revoked" ? "reconnect" : "disconnect"}.${command ? "command" : "installer"}`)}

} + {/* Reconnection uses the installed daemon and the replacement credential file. */} + {rotation.reason === "lost" ? null :

{t(`executor.rotateDialog.${rotation.reason === "revoked" ? "reconnect" : "disconnect"}`)}

} ) : null} diff --git a/apps/web/src/features/sessions/ExecutorInstallPanel.tsx b/apps/web/src/features/sessions/ExecutorInstallPanel.tsx index 344f63bcf..a4a0d56da 100644 --- a/apps/web/src/features/sessions/ExecutorInstallPanel.tsx +++ b/apps/web/src/features/sessions/ExecutorInstallPanel.tsx @@ -1,82 +1,37 @@ -import { useQuery } from "@tanstack/react-query"; import { Check, Copy } from "lucide-react"; -import { useEffect, useId, useRef, type RefObject } from "react"; +import { useEffect, useId, useRef, useState, type RefObject } from "react"; import { useTranslation } from "react-i18next"; - import { HelpTip } from "../../components/console-ui"; -import { installationQuery } from "../../lib/installation"; +import { ConsoleSelect } from "../../components/console-select"; import { useCopy } from "../api-keys/IssuedKey"; -import { sandboxConsoleConfigQuery } from "../sandbox/sandbox-queries"; -import { executorInstall, type ExecutorInstall } from "./executor-install"; - -const DOCKER_SOCKET = "/var/run/docker.sock"; +import { executorInstall, type ExecutorInstall, type HostShell } from "./executor-install"; -/** The install command's state, including the reads it waits for. */ -export type ExecutorInstallRead = ExecutorInstall | { kind: "loading" } | { kind: "failed"; retry: () => void }; - -/** - * Reads what Connect a host needs: the console's self-hosted installer and, - * only when it is offered, Core's public address. - */ -export function useExecutorInstall(environmentId: string, remoteUrl: string): ExecutorInstallRead { - const config = useQuery(sandboxConsoleConfigQuery); - const offered = config.data?.self_hosted_installer === true; - const installation = useQuery({ ...installationQuery, enabled: offered }); - if (!offered) return { kind: "unavailable" }; - if (installation.data === undefined) { - return installation.isError && !installation.isFetching ? { kind: "failed", retry: () => void installation.refetch() } : { kind: "loading" }; - } - return executorInstall({ config: config.data, publicUrl: installation.data.public_url, localOnly: installation.data.local_only, environmentId, remoteUrl }); +export function useExecutorInstall(environmentId: string, remoteUrl: string, workspaceDirectory: string): ExecutorInstall { + return executorInstall({ environmentId, remoteUrl, workspaceDirectory }); } -/** - * Connect a host: the command that installs this Environment's self-hosted - * executor from Core's public address. It holds no secret; the installer asks - * for a credential from the section above at a hidden prompt. Shown only when - * the console serves the self-hosted installer with a verified digest. - */ -export function ExecutorInstallPanel({ install, archived, connected = false }: { install: ExecutorInstallRead; archived: boolean; connected?: boolean }) { +/** One home for native installation instructions; issuing a credential does not establish a connection. */ +export function ExecutorInstallPanel({ install, archived, connected = false }: { install: ExecutorInstall; archived: boolean; connected?: boolean }) { const { t } = useTranslation("sessions"); - const { t: tCommon } = useTranslation("common"); const headingId = useId(); - if (install.kind === "unavailable") return null; - - let body; - if (install.kind === "failed") { - body =

{t("executor.install.failed")}

; - } else if (install.kind === "loading") { - body =
; - } else { - body = install.kind === "no_address" ?

{t("executor.install.noAddress")}

- : install.kind === "local_only" ?

{t("executor.install.localOnly", { url: install.publicUrl })}

- : install.kind === "not_wss" ?

{t("executor.install.notWss", { remote: install.remoteUrl || "—" })}

- : <> -

{archived ? t("executor.install.archived") : t("executor.install.steps")}

- -
- {/* The list carries the label for assistive technology; the visible one is not read twice. Safari drops a list's semantics without list-style unless its role is explicit. */} - -
    - {["Linux amd64", "Python 3.9+", "curl", "sha256sum", "Docker CLI", t("executor.install.docker", { socket: DOCKER_SOCKET }), t("executor.install.https", { url: install.publicUrl })].map((item, index) => ( -
  • {index ? : null}{item}
  • - ))} -
-
- ; - } - return ( -
-
-

{t("executor.install.title")}

- {t("executor.install.lifecycle")} - - {connected ? -
- {body} -
- ); + const [shell, setShell] = useState("posix"); + return
+
+

{t("executor.install.title")}

+ {t("executor.install.lifecycle")} + + {connected ? +
+

{t(archived ? "executor.install.archived" : "executor.install.steps")}

+ {t("executor.install.guide")} + {install.kind === "ready" ? <> + { if (value === "posix" || value === "powershell") setShell(value); }} /> + +

{t("executor.install.start")}

+ :

{t("executor.install.unavailable")}

} +
; } /** Selects a block's text when the clipboard refused it (a plain-HTTP console has none), to copy by hand. */ diff --git a/apps/web/src/features/sessions/SessionPage.tsx b/apps/web/src/features/sessions/SessionPage.tsx index 66d62003f..512f49850 100644 --- a/apps/web/src/features/sessions/SessionPage.tsx +++ b/apps/web/src/features/sessions/SessionPage.tsx @@ -118,7 +118,7 @@ export function SessionPage() { const waiting = waitingFor(session); const metadata = Object.entries(session.metadata).filter(([key]) => key !== "title"); const harness = session.agent.x_agents_core?.harness; - const environment = session.environment as { type: string; id?: unknown; remote_url?: unknown }; + const environment = session.environment as { type: string; id?: unknown; remote_url?: unknown; workspace_directory?: unknown }; body = ( {history.gone ?

{t("detail.gone", { time })}

: null} @@ -170,7 +170,7 @@ export function SessionPage() { {environment.type === "self_hosted" && typeof environment.id === "string" ? ( - + ) : null}